diff --git a/Cargo.lock b/Cargo.lock index e296c3aebdb..3e36e2ac425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" [[package]] name = "dashcore-rpc" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "bincode", "dashcore-private", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" [[package]] name = "glob" @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b4779fcfb16ae7de5bd763ccba403dc070591b6c#b4779fcfb16ae7de5bd763ccba403dc070591b6c" dependencies = [ "async-trait", "bincode", @@ -5064,8 +5064,9 @@ version = "4.0.0-rc.2" dependencies = [ "aes", "cbc", - "dashcore", - "hex", + "hmac", + "secp256k1", + "sha2", "thiserror 1.0.69", ] @@ -5175,6 +5176,7 @@ name = "platform-wallet-ffi" version = "4.0.0-rc.2" dependencies = [ "anyhow", + "async-trait", "bincode", "bs58", "cbindgen 0.27.0", @@ -6340,6 +6342,7 @@ dependencies = [ "libc", "log", "once_cell", + "platform-encryption", "reqwest 0.12.28", "rs-sdk-trusted-context-provider", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5d5f2960f1d..0ac8b64ab43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "b4779fcfb16ae7de5bd763ccba403dc070591b6c" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "b4779fcfb16ae7de5bd763ccba403dc070591b6c" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "b4779fcfb16ae7de5bd763ccba403dc070591b6c" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "b4779fcfb16ae7de5bd763ccba403dc070591b6c" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "b4779fcfb16ae7de5bd763ccba403dc070591b6c" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "b4779fcfb16ae7de5bd763ccba403dc070591b6c" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "b4779fcfb16ae7de5bd763ccba403dc070591b6c" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "b4779fcfb16ae7de5bd763ccba403dc070591b6c" } tokio-metrics = "0.5" diff --git a/docs/dashpay/BLOCK_SPEC.md b/docs/dashpay/BLOCK_SPEC.md new file mode 100644 index 00000000000..6b2e12869b1 --- /dev/null +++ b/docs/dashpay/BLOCK_SPEC.md @@ -0,0 +1,567 @@ +# DashPay "Block sender" — design spec + +Status: **SUPERSEDED (2026-06-18) — replaced by the implemented local-only Ignore.** +This single-device design is 4-lens reviewed (§0 R1–R10) and stays valid as the +reference, but the **shipped** feature is the simpler per-sender, reversible +**Ignore** (= block) in `docs/dashpay/SYNC_CORRECTNESS_SPEC.md` / Spec 2 — see the +TODO. The intermediate plan to carry block/reject **via `contactInfo`** for +cross-device sync was **REJECTED**: R1 found that a `contactInfo` about a +*non-established* sender leaks *who* you ignored (its public `$createdAt`/`$updatedAt` +correlate with the inbound `contactRequest`'s timestamp via the public indexes), and +the DIP-15 ≥2-contacts ambiguity gate doesn't cover a fresh non-established sender. +What actually landed: +1. **`CONTACTINFO_FORMAT_SPEC.md`** — privateData CBOR→DIP-15 varint. **IMPLEMENTED** + (carries alias/note/displayHidden/acceptedAccounts; does NOT carry ignore — R1). +2. **Ignore = per-sender, reversible, LOCAL-ONLY.** **IMPLEMENTED** across all layers + (changeset, FFI, SwiftData, *and* the SQLite persister's `ignored_senders` table). +3. **Cross-device ignore** — deferred to a future **encrypted field on the `profile` + document** (contract / governance track), whose update timing is conflated with + normal profile edits so it doesn't leak the per-sender existence/count. Not built. + +Owner: platform-wallet / swift-sdk +Relates to: `docs/dashpay/SPEC.md` (G5 rejection), the existing per-request +Decline flow. + +--- + +## 0. Review resolutions (authoritative — supersedes inline text where they conflict) + +Folded from a 4-lens multi-agent spec review (feasibility / scope / adversarial / +security-privacy). Ordered by severity. + +**R1 (CRITICAL, security+feasibility) — Cross-device via `contactInfo`-reuse is OUT.** +Creating a `contactInfo` *about a non-contact* breaks the DIP-15 ≥2-contacts +unlinkability gate: the doc's public existence + `$createdAt` correlates with the +inbound `contactRequest` (public `userIdCreatedAt` index) to re-identify *who* you +blocked, even though `encToUserId` is encrypted. The "displayHidden is precedent" +claim is a false equivalence — displayHidden rides a doc that exists anyway; a +block-of-a-non-contact *creates* the leaking doc. It's also mechanically blocked: +`set_contact_info_with_external_signer`→`set_contact_metadata` hard-requires an +established contact (returns `false`→error otherwise), and the apply side drops +non-established. **Resolution:** if cross-device ever ships, it is a **single +owner-scoped, self-encrypted blocklist document** (leaks only "a blocklist exists" ++ edit-count, not one-doc-per-victim) — a contract change on the later track. +§11.2's contactInfo-reuse is struck as the primary plan; open-question-g resolves +to **"yes, it leaks — demonstrably."** + +**R2 (CRITICAL, security) — Unblock vs Phase-2 incremental fetch contradiction.** +§2/§8 promise "unblock → requests reappear next sync," but Phase-2 high-water +`$createdAt` will have advanced past the blocked request, so it never refetches — +unblock silently does nothing after Phase 2. **Resolution:** `unblock_sender` must +**rewind the identity's received-request high-water** (so the sender's docs +refetch) — OR the guarantee changes to "unblock does not resurface already-passed +requests" and the UI says so. *Decision needed (see Q1 below); default = rewind.* + +**R3 (CRITICAL, adversarial+feasibility) — Gate the auto-establish paths, not just +the read loop.** The §4.2 gate must sit at the **top of the per-sender loop body, +before the `tracked_reference`/rotation dispatch** — otherwise a blocked +established sender's rotation runs `apply_rotated_incoming_request`, which clears +`payment_channel_broken` and **reactivates a payment channel to someone you +blocked.** Additionally, `is_sender_blocked` must be consulted inside the +state-layer establish methods (`add_sent_contact_request`, +`add_incoming_contact_request`, `apply_rotated_incoming_request`), and +`block_sender` must clear/guard `sent_contact_requests[sender]`. The read-loop gate +alone is necessary-but-not-sufficient. + +**R4 (CRITICAL, adversarial) — Rust `apply.rs` + `Merge` must handle the new +fields.** `ContactChangeSet` is destructured exhaustively (no `..`) in +`apply.rs`, so adding `blocked`/`unblocked` breaks the build until handled — +specify: apply inserts `blocked` into `blocked_senders`, removes `unblocked` +keys, **applies `unblocked` after `blocked`** (latest-action-wins), extend +`Merge::merge`/`is_empty`, and `block`/`unblock` never emit both for the same +sender in one changeset. The Rust apply-restore is **separate from and additional +to** the FFI/Swift restore, not optional. + +**R5 (HIGH, all three) — Do NOT drop reject tombstones on block.** There is no +`removed_rejected` changeset channel (rejected is upsert-only); adding one is a +cross-layer change. And dropping them creates a lost-request hole under the +`limit:100` truncation + resurrects previously-declined refs on unblock. They're +**inert** under the sender-superset block (the gate short-circuits first). +**Resolution:** keep them; delete §3's "drop on block" + §4.3 step 3. Simpler AND +correct. + +**R6 (HIGH, security) — §11.1 countable index leaks the inbound social graph.** +Count proofs are **public, not recipient-private**, and return cleartext +`{sender_id → count}`. A countable `[toUserId, $ownerId]` lets *anyone* scrape +"who contacted R, with counts" in O(log n). **Resolution:** drop the per-sender +`GROUP BY` axis; keep at most an aggregate `COUNT(*) WHERE toUserId == me` (a +single number) for a badge; carry the graph-exposure analysis into the DIP. + +**R7 (HIGH, adversarial) — Persist-failure atomicity.** `block_sender` mutates +in-memory then stores; a store failure leaves memory=blocked / disk=unblocked. +**Resolution:** persist-first, mutate-`blocked_senders`-after-success (clean +rollback on failure). (Less severe now that R5 stops dropping tombstones.) + +**R8 (HIGH, adversarial) — Multi-device shared identity: blocked-becomes-established.** +Device A blocks B; device B accepts → A reconciles the sent reciprocal → A holds +`blocked + established` (which §8 calls impossible). **Resolution (Q2):** on sync, +if a blocked sender becomes established, **auto-lift the block + UI notice** +(accept wins) — default — or suppress-while-blocked. *Decision needed.* + +**R9 (HIGH, security) — Threat-model honesty (§9 additions).** State plainly: the +economic gate prices *identity creation* (one-time), NOT recurring same-identity +requests (per-cheap-doc — that's *why* Block exists); the inbound edge is +**permanent public chain metadata** Block can't retract; v1 is per-device so a +harasser reappears on un-blocked devices. + +**R10 (HIGH, security) — Data-at-rest / residue (§9 checklist).** "Wiped with the +wallet" is overstated. Required: `blocked_contacts.sender_id` at-rest encryption +status named; **no `sender_id` in logs above debug**; FFI restore-buffer lifetime +bounded; and the sleeper — **the SwiftData container may be iCloud-backed**, so a +"local" block list can sync to iCloud backup. Confirm/exclude that. + +**Cleanups (low-risk):** migration → fold `blocked_contacts` into **V001** (the +storage crate has no released consumers; the V002→V001 squash set the precedent); +`blocked`/`unblocked` extend the **existing** `on_persist_contacts_fn` callback, +not a new vtable slot; there is **no production SQLite reader** for the rejected +precedent (restore is FFI/Swift only) — match that, don't invent a loader; fix +`blocked_at_ms` (ms) vs SQL `unixepoch()` (seconds); broaden the self-block guard +to **any wallet-owned identity**, enforced at the `blocked_senders` insertion +boundary. + +**Open decisions for you:** +- **Q1 (R2):** unblock **rewinds the high-water** (requests reappear; default) vs + **doesn't** (unblock is "fresh start", UI says so)? +- **Q2 (R8):** blocked-then-established-via-other-device → **auto-lift block** + (default) vs suppress-the-established-contact-while-blocked? + +--- + +## 1. Problem & motivation + +Today's "Reject / Decline" is keyed by `(sender_id, account_reference)` — it +suppresses **one specific request**. `contactRequest` documents are immutable +and never deleted on-chain, so the tombstone's real job is to stop that exact +request from re-appearing in the incoming list on every sync sweep. + +It is **not** a block: a previously-rejected sender can re-request with a +bumped `accountReference` (a DIP-15 rotation) and the new request reaches the +user, because `is_request_rejected` matches only the exact reference. That is +deliberate (a sender who rotated keys should be able to reconnect), but it +means there is no way for a user to say *"I never want to hear from this +person again."* + +This spec adds a **per-sender Block** alongside the per-request Decline. + +### What Block can and cannot be + +Block is necessarily a **local mute**, not protection: + +- The chain has no block-list and does not stop anyone from *creating* a + `contactRequest` addressed to your identity. Any block is a filter applied + in your own wallet on read/ingest. +- So Block = "auto-hide every request from this sender id, regardless of + `accountReference`, on this device." It stops the nagging; it does not stop + the sender from writing documents. + +We will be explicit about this in the UI copy (see §7) so it is not mistaken +for true protection. + +## 2. Goals / non-goals + +**Goals** +- A `block(sender_id)` action that suppresses **all** incoming requests from + that sender — current and future, across rotations. +- An `unblock(sender_id)` that lifts it; the sender's still-on-chain requests + reappear on the next sync. +- Durable across relaunch, on **both** persisters (SQLite + SwiftData), with + restore-at-load — i.e. no resurrection bug (mirror the rejected-tombstone + restore we just added). +- Correct wallet-wipe behaviour (no plaintext rows left on disk). +- UI to Block from an incoming-request row + a "Blocked" list to Unblock. + +**Non-goals (v1)** +- Cross-device sync of the block list (see §6 — no on-chain home for it). +- Blocking an **established** contact (that is "remove + suppress" — a + different flow; see §8). +- Any on-chain or consensus-level enforcement. + +## 3. Semantics — Block vs Decline + +| | **Decline** (exists) | **Block** (new) | +|---|---|---| +| Key | `(sender, accountReference)` | `sender` only | +| Suppresses | that one request | every request from the sender | +| Rotation (new ref) | gets through | stays suppressed | +| Intent | "dismiss this request" | "mute this person" | +| Reversible | n/a (a new ref is a new request) | yes — `unblock` | +| Scope | local | local | + +Block **supersedes** Decline for a sender: once blocked, the per-reference +reject tombstones for that sender are redundant. On block we drop them (tidy; +the sender check covers everything). On unblock we do **not** restore them — +a fresh start, the user re-decides per request. + +## 4. Architecture (Rust — `platform-wallet`) + +### 4.1 State (`ManagedIdentity`) + +Add, mirroring `rejected_contact_requests`: + +```rust +/// Senders this identity has BLOCKED (G5 stage 3). Every incoming request +/// from a key in this map is suppressed regardless of accountReference — +/// the per-sender superset of `rejected_contact_requests`. Local-only. +pub blocked_senders: BTreeMap, +``` + +```rust +pub struct BlockedContact { + pub owner_id: Identifier, + pub sender_id: Identifier, + pub blocked_at_ms: u64, // local wall-clock; UI "blocked on …", not consensus +} +``` + +Accessor: `is_sender_blocked(&self, sender_id) -> bool`. + +### 4.2 Ingest suppression + +In `sync_contact_requests`, the received-doc loop already calls +`is_request_rejected(sender, ref)`. Add a **sender-level** gate *before* it: + +```rust +if managed.is_sender_blocked(&sender_id) { continue; } // drop silently +``` + +This covers rotations automatically (no `accountReference` in the check) and +short-circuits both new-request and rotation handling. + +### 4.3 Operations + changeset + +Extend `ContactChangeSet` with two fields: + +```rust +pub blocked: BTreeMap, // upserts +pub unblocked: BTreeSet, // tombstones +``` + +`block_sender(&mut self, sender_id, persister) -> ContactChangeSet`: +1. `blocked_senders.insert(sender_id, BlockedContact{…})`. +2. `incoming_contact_requests.remove(sender_id)` + emit `removed_incoming` + (so the persisted `state='received'` row is DELETEd on **both** backends — + the exact two-backend consistency lesson from the reject fix). +3. Drop any `rejected_contact_requests` entries for that sender (and emit the + matching `removed`/no-op — they become redundant under the sender block). +4. `cs.blocked.insert(sender_id, …)`. + +`unblock_sender(&mut self, sender_id, persister) -> ContactChangeSet`: +1. `blocked_senders.remove(sender_id)`. +2. `cs.unblocked.insert(sender_id)`. +3. Next sync re-ingests the sender's on-chain requests as fresh incoming. + +Both go through the persister exactly like reject. Failures **propagate** (the +reject path's C1 lesson: a swallowed block-persist would silently un-block on +restart). + +## 5. Persistence (both backends + restore) + +### 5.1 Rust SQLite (`platform-wallet-storage`) + +New table: + +```sql +CREATE TABLE blocked_contacts ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + sender_id BLOB NOT NULL, + blocked_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, sender_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); +``` + +Writer: `cs.blocked` → upsert; `cs.unblocked` → `DELETE`. Loader rehydrates +`blocked_senders`. **Migration placement is an open decision** (§12.a): fold +into `V001` (consistent with the recent V002→V001 squash, if the crate is +still pre-release) or add `V002`. + +### 5.2 SwiftData (example app) + +- New model `PersistentDashpayBlockedContact` (mirror of the rejected model): + `(networkRaw, ownerIdentityId, senderIdentityId)` unique, cascade-owned by + `PersistentIdentity` via a new `dashpayBlockedContacts` relationship. +- `persistContacts` upserts on `cs.blocked`, deletes on `cs.unblocked`. +- **Restore at load:** add a `blocked` FFI array on `IdentityRestoreEntryFFI` + (reuse the `ContactRequestRejectionFFI` plumbing pattern) + + `restore_dashpay_blocked` → rebuilds `blocked_senders`. Without this the + block resurrects-as-unblocked on relaunch (the bug class we just fixed). +- **Wallet-wipe PHASE 1:** add `dashpayBlockedContacts` to the pre-delete + loop — it has a non-optional `owner`, so omitting it re-introduces the + mid-wipe fatal we just fixed. +- **Storage Explorer:** add the model to all three explorer views (the + `check-storage-explorer.sh` CI gate requires it). + +## 5.5 Fetch model, spam & DoS (the part that actually matters) + +The received-request query today is, every sweep: + +```rust +where toUserId == me, order_by $createdAt, limit: 100, start: None +``` + +`start: None` ⇒ **non-incremental**: we re-fetch the same first page from the +beginning each sweep and re-verify its proofs. Two consequences: + +- **Truncation:** beyond 100 requests we never paginate forward — a flood of + ≥100 junk requests can **bury** legitimate ones so they're never fetched. +- **Repeated cost:** every sweep pays fetch + GroveDB proof-verify for the + whole page again. + +**Threat: a sender (or a funded Sybil swarm) creates many requests.** Invalid +ones are the worst — they fail parse/validation but still cost fetch + +proof-verify + parse each sweep. The only built-in deterrent is **economic**: +each `contactRequest` document costs the sender platform credits. Spam isn't +free, but it isn't prevented. + +**What Block can and cannot do here.** It CANNOT cut fetch cost: the index is +keyed by recipient (`toUserId == me`), there is no `sender NOT IN (…)` index, +and Sybil senders are unpredictable — so block is a **local read-filter after +fetch**, full stop. + +**The real lever — incremental fetch (high-water).** Track the high-water +`$createdAt` (or core height) of the newest request seen per identity and +query `WHERE toUserId == me AND $createdAt > high_water`, paginating forward. +Then: + +- each request is fetched **exactly once**, never re-fetched; +- pagination goes *forward*, so legit requests can't be buried past 100; +- block/decline become **one-time-on-first-sight** — suppress once, never see + it again (no re-suppress-every-sweep). + +This is the actual DoS/cost mitigation and is **worth doing independently of +Block.** It bounds steady-state work to O(new requests per sweep). It does not +stop the *first* fetch of a request from a new sender (impossible without +server-side sender exclusion), but nothing in the protocol can. + +> **Scope note (sequencing decided):** incremental fetch touches +> `sync_contact_requests` and the received query, and changes reject/block from +> "re-suppress each sweep" to "suppress once." It is **Phase 2** (after +> single-device Block — see §12). Block works correctly without it (it just +> re-suppresses each sweep until Phase 2 lands); Phase 2 is the efficiency win. + +## 6. Cross-device sync — local v1, self-encrypted blocklist as the real fix + +Today reject **and** block are per-device local state. `displayHidden` +(contactInfo) syncs, but only for **established** contacts gated by the +≥2-contacts privacy rule — a blocked *sender* is not an established contact, +so no existing document carries it. + +**v1: local-only** (simplest, ship-able). You re-block per device. + +**v2 (recommended target): a self-encrypted owner-private blocklist +document.** One document the owner encrypts to themselves (same key family as +contactInfo `privateData`, but a single owner-scoped list, not per-contact and +not gated by the 2-contact rule). Every device reads + applies it, so block +(and optionally decline) apply everywhere. Costs: each edit is a document +write (credits); it reveals encrypted *that* a blocklist exists, never its +contents. This is the honest fix for "I shouldn't have to block on every +device" — promoted from "future" to a v2 decision (§12.b), because the user +explicitly wants cross-device behaviour. + +## 7. UI (example app) + +- **Incoming-request row** (`ContactRequestsView`): add **Block** beside + Accept / Decline, behind a confirm ("Block ? You won't see future + requests from them on this device. This doesn't stop them on-chain."). +- **Blocked list:** a screen listing `blocked_senders` with **Unblock**. +- Optimistic overlay identical to accept/reject (in-flight set + error row). + +## 8. Edge cases & interactions + +- **Rotation:** blocked sender bumps `accountReference` → still suppressed + (per-sender check). ✔ the whole point. +- **Block then unblock:** on unblock, the sender's on-chain requests reappear + on next sync as fresh incoming (we do not restore old reject tombstones). +- **Established contact:** Block is offered only on incoming-request rows in + v1. Blocking someone you're already contacts with = "remove contact + + suppress" — a distinct flow (delete the `EstablishedContact`, its accounts, + then add the block). Deferred; called out so the UI doesn't offer Block on + established rows yet. +- **Self-block:** guard against blocking your own identity id (no-op + log). +- **Decline + Block ordering:** Block supersedes; declining first then + blocking is fine (block drops the tombstone). + +## 9. Security / abuse / limits + +- Block is a **local mute**, not protection — say so in the UI. A determined + sender keeps writing on-chain docs; we just never surface them. +- **Storage growth:** the block list grows with user action only (bounded, + benign). No index needed — same access pattern as contacts (load per + owner, filter in memory); no query filters by `sender_id` alone beyond the + PRIMARY KEY. +- **Privacy:** the block list reveals who you blocked; it is local + wiped + with the wallet (hence the PHASE 1 requirement). + +## 10. Test / verification plan + +Rust (`platform-wallet`): +- `block_sender_suppresses_all_references` — block, then ingest a request with + a *different* accountReference → not ingested (rotation can't bypass). +- `block_emits_removed_incoming` — block drops the pending row on both + backends (red→green like the reject fix). +- `unblock_then_sync_reingests` — after unblock, the sender's request ingests + again. +- `block_supersedes_reject_tombstones` — blocking clears per-ref tombstones. + +`platform-wallet-storage`: +- `blocked_contacts` round-trip + loader rehydrates `blocked_senders`. +- migration-applies test (whichever placement §12.a lands on). + +`platform-wallet-ffi`: +- `restore_blocked_rows_rebuilds_block_set` (mirror the rejected-restore test). + +Swift / example app: +- wipe test: a wallet with a blocked contact wipes cleanly (no fatal). +- `check-storage-explorer.sh` passes (model in all three views). +- UAT: block a sender → relaunch → still blocked; unblock → request returns. + +## 11. DashPay data-contract improvements (network-governance track) + +These enable the features above more efficiently, but they are **changes to the +registered `dashpay` data contract** — a system contract on-chain. Adding an +index or a document type is a **contract update / DIP-level coordination**, not +a wallet-side change. So this track is separate from the wallet phases; the +wallet ships what works on today's contract (Phase 0), and these are proposals +for the contract maintainers. Each added index also makes **every** +`contactRequest` write a bit more expensive (more index trees to maintain) — +a system-wide cost borne at document creation, so additions must earn their keep. + +### 11.0 What needs NO contract change + +**Incremental fetch (Phase 0) is free.** The existing `userIdCreatedAt` +index `[toUserId, $createdAt]` already serves `WHERE toUserId == me AND +$createdAt > high_water` (range-after-equality). Ship it on today's contract. +*Don't* add an index for this. + +### 11.1 Countable index → O(1) counts + spam detection (GROUP BY sender) + +Today, to know "how many pending requests do I have" or "is one sender +flooding me", we must **fetch the documents**. Platform's count/group-by +queries (see `book/src/drive/count-index-group-by-examples.md`) answer those +from a proof **without enumerating documents** — but only over a `countable` +index. + +Proposal: a new **countable** index on the recipient→sender axis: + +``` +byRecipientSender = [{ toUserId: asc }, { $ownerId: asc }] // countable +``` + +(`$ownerId` of a `contactRequest` *is* the sender; the existing `ownerIdUserId` +is the reverse order and can't serve a `toUserId ==`-prefixed group-by.) + +Unlocks, all as O(1)/O(log n) **count proofs, no doc fetch**: +- `COUNT(*) WHERE toUserId == me` → a pending-request **badge** for free. +- `GROUP BY $ownerId` → **requests-per-sender**; with a `HAVING count > N`-style + threshold, cheaply **flag a spammer** (a sender who created many requests to + you) and auto-suggest Block — *before* paying to fetch their docs. + +Caveat: GROUP BY returns **counts, not documents**. It tells you *who* and *how +many*, not the request contents — you still fetch (incrementally) the ones you +want to act on. So it's a detection/triage layer on top of Phase 0, not a +replacement for fetch. + +### 11.2 Cross-device block — reuse `contactInfo` `privateData` (no new doc type, no contract change) + +Cross-device is **out of scope for v1** (single-device, decided). When we do +it, the chosen mechanism is **not** a new document type — it **reuses the +`contactInfo` we already have**: + +- `contactInfo.privateData` is an **opaque encrypted blob at the contract + level** (the contract only enforces 48–2048 bytes). The CBOR array inside — + `[aliasName, note, displayHidden]` — is **our client convention**, so adding + a positional element (e.g. `relationshipState: active|declined|blocked`, or a + bare `blocked: bool`) is a **client-side change with NO contract update**. +- `displayHidden` is already the precedent: a per-contact, self-encrypted, + cross-device-syncing hide flag. Block is the same idea, generalised. +- It rides the codec + sync + restore we already built (`fetch_decrypted_contact_infos`, + `encode/decode_private_data`), so a device applies blocks during the existing + contactInfo sweep. + +Two things to resolve before building it (tracked, not v1): + +1. **Non-established targets.** `contactInfo` is created today only for + *established* contacts. Blocking a non-contact means creating a `contactInfo` + *about* a non-contact. The contract allows it (no link to a `contactRequest`), + but it interacts with the **≥2-contacts privacy gate** and doc-existence + metadata — needs a privacy pass (does an extra `contactInfo` for a + non-contact leak anything an observer can use? `encToUserId` is encrypted, so + *who* is hidden; the *count* is not). +2. **Block vs Decline granularity.** Sync the deliberate **Block** (rare); keep + ephemeral **Decline** local (frequent — a doc write per decline is too + noisy). For *established* contacts, `displayHidden` already covers + cross-device hide. + +A standalone `contactBlock` document type is the **fallback** only if reusing +`contactInfo` runs into the privacy gate — and it would then be a real contract +change (§11.3). Reuse is strictly cheaper, so it's the primary plan. + +### 11.3 Versioning / rollout reality + +Two tiers, sequenced honestly: + +- **Client-only, no contract change:** incremental fetch (§11.0, the existing + index serves it) and the `contactInfo.privateData` block flag (§11.2, opaque + blob). These ship through the normal wallet path. +- **Contract update (DIP / maintainer coordination):** the countable + `[toUserId, $ownerId]` index (§11.1) and any *query-level* filter-out-blocked + / standalone blocklist doc. `dashpay-contract` is registered on-chain, so + these affect the whole network + need backward-compat for existing documents. + **TODO / later track**, proposed separately from this wallet work. + +## 12. Roadmap (decided) + remaining open questions + +**Decided sequencing:** +1. **Phase 1 — single-device Block** (this spec → multi-agent review → implement). + Local per-sender suppression, both persisters + restore + wipe + explorer + UI. + No cross-device. +2. **Phase 2 — incremental fetch** (high-water `$createdAt`; client-only, no + contract change). Bounds steady-state fetch + stops the ≥100 burying. +3. **Later / TODO (contract track):** real query-level DoS protection — filter + blocked/rejected senders out *before* fetching. Requires a contract change + → DIP / maintainer coordination. (NB: the once-proposed countable + `[toUserId, $ownerId]` index is **struck** per R6 — its public count proof + leaks the inbound social graph; at most an aggregate `COUNT(*)` total.) +4. **Cross-device (later):** **per R1, NOT via `contactInfo`-reuse** (a + `contactInfo` about a non-contact breaks DIP-15 unlinkability). If it ships, + it is a **single owner-scoped, self-encrypted blocklist document** — a + contract change on this later track, with the metadata-leak analysis done up + front. +5. **TODO (contactInfo format — DashPay-wide, NOT block-specific):** migrate + `contactInfo.privateData` from the CBOR-array encoding to the **DIP-15 + versioned-varint** format DIP-15 actually defines (`version` + aliasName + + note + displayHidden + acceptedAccounts), and **reconcile the deployed + contract's field description** (currently says "cbor") to match DIP-15. + Rationale: DIP-15 is the interop authority and its description currently + contradicts the contract's; **no client implements contactInfo yet**, so we + can fix it cleanly now. The DIP-15 `version` field is the forward-compat + lever — append-only fields behind a version bump, with **tolerant decoders** + (read known fields, ignore trailing) so older readers don't break (a *strict* + decoder would). Replaces our current `encode/decode_private_data` codec. + Also fixes the internal doc inconsistency (`research/01` wrongly says "CBOR + per DIP-0015"; the contract validates `privateData` by **length only** — its + "array in cbor" description is advisory, not enforced — so we follow DIP-15 + varint, the authoritative format). + **Verified 2026-06 against github.com/dashpay:** no client decodes + `contactInfo.privateData` today — `android-dashpay` has no `ContactInfo` + class (the schema is bundled as JSON only; its Kotlin handles `contactRequest`), + and `dash-wallet` has only a `// TODO` comment. So we have **full format + freedom now**; the window to align to DIP-15 is *before* `dash-wallet` + implements its TODO (it will follow DIP-15 = varint, not our CBOR → otherwise + the two won't interop). Format discipline once readers exist: **version = + breaking changes only; for additive changes do NOT bump — append + tolerant + decode** so older readers ignore trailing fields. + +**Remaining open questions for the Phase-1 review:** +- a. **Migration placement** — fold `blocked_contacts` into `V001` (matches the + recent V002→V001 squash) or a new `V002`? Pending whether + `platform-wallet-storage` is considered released. +- c. **State shape** — `BTreeMap` (carries + `blocked_at` for UI) vs bare `BTreeSet`. Recommend the map. +- d. **Established-contact block** — confirmed **out of v1** ("remove + suppress" + is a separate flow). +- e. **Naming** — "Block" vs "Decline" (vs "Ignore"); UI copy must make the + local-mute nature explicit. +- g. **Privacy of a `contactInfo` for a non-contact** (Phase-4 prerequisite) — + does it leak anything? Resolve before cross-device. diff --git a/docs/dashpay/CONTACTINFO_FORMAT_SPEC.md b/docs/dashpay/CONTACTINFO_FORMAT_SPEC.md new file mode 100644 index 00000000000..d93143522d2 --- /dev/null +++ b/docs/dashpay/CONTACTINFO_FORMAT_SPEC.md @@ -0,0 +1,205 @@ +# contactInfo `privateData` — DIP-15 varint format (migrate off CBOR) + +Status: **IMPLEMENTED** (2026-06-18) — DIP-15 varint codec in `crypto/contact_info.rs`, +byte-vector + compat tests. (The tolerant minor-version decode stays available for a +future additive field, but **ignore state does NOT ride contactInfo** — R1 found +that leaks who you ignored; ignore is local-only, cross-device via a future encrypted +profile field. See TODO R1.) +Owner: platform-wallet / platform-encryption +Relates to: Spec 2 (Ignore, adds `relationshipState`), `BLOCK_SPEC.md`, +`research/07-contactinfo-conventions.md`. + +## Format decision: DIP-15 varint, NOT CBOR (2026-06-18) + +`contactInfo.privateData` is an **opaque encrypted byteArray** — the registered +contract validates only its **length** (`byteArray:true, minItems:48, +maxItems:2048` in `dashpay.schema.json`); the field description's "…encoded as an +array in cbor" is **advisory documentation, not a structural constraint**. The +plaintext inside the AES-256-CBC ciphertext is therefore a writer/reader +convention we are free to choose — and we choose **DIP-15**, the authoritative +protocol spec, so we interop with DIP-15-compliant clients (the reference +`dash-wallet` / `kotlin-platform` will follow the DIP when it implements +contactInfo). **No contract change is needed** (length-only validation accepts any +48..2048-byte ciphertext). No client decodes contactInfo today, so this is a free +window: we set the de-facto format and it matches the DIP. + +> An earlier pass briefly "reconciled" this the other way (keep CBOR, per the +> schema *description* + `research/07 §C`). That over-weighted an advisory +> description as binding. Corrected: the contract enforces length only, DIP-15 is +> authoritative — use varint. + +This is **Spec 1** of the DashPay-privacy track. (The minor-version forward-compat +seam — §3 — remains for any future additive field. Note: ignore state is **not** +carried here — R1 found a per-sender `contactInfo` leaks who you ignored, so ignore +is local-only with cross-device deferred to a future encrypted `profile` field.) + +--- + +## 1. Problem + +Our `contactInfo.privateData` codec (`crypto/contact_info.rs::encode/decode_private_data`) +emits a **CBOR array** `[aliasName, note, displayHidden, padding?]`. **DIP-15 +defines a different format** (verified against `github.com/dashpay/dips/dip-0015.md`, +§"Contact Info" / §"Encrypting Private Data"): + +- **Serialization:** "the private data should be serialized in the same way as + done for **Dash message data**" (dip-0015.md:811) — i.e. the Bitcoin/Dash + protocol binary format (var-int-length-prefixed strings/arrays), **not CBOR**. +- **Fields (v0), in order:** + | # | Field | Type | Encoding | + |---|-------|------|----------| + | 0 | `version` | uInt32 | `major << 16 \| minor` (dip-0015.md:771) | + | 1 | `aliasName` | String | var-int length + UTF-8 | + | 2 | `note` | String | var-int length + UTF-8 | + | 3 | `displayHidden` | uInt8 | 1 byte | + | 4 | `acceptedAccounts` | array | var-int count + u32s (dip-0015.md:805) | +- **Crypto:** AES-256-CBC with the `rootEncryptionKey/(2^16+1)'/idx'` derived key + (we already do this — only the *plaintext serialization* changes). + +**Our gaps vs DIP-15:** (a) CBOR instead of Dash-message varint; (b) **no +`version` field**; (c) **no `acceptedAccounts`**. + +**Why now (the one cheap window):** verified 2026-06 that **no client decodes +`contactInfo.privateData` today** — `android-dashpay` has no `ContactInfo` class +(the schema is bundled as JSON only); `dash-wallet` has only a `// TODO: choose +the contactRequest based on the ContactInfo.accountRef value`. So there is **no +reader to break.** When `dash-wallet` implements its TODO it will follow DIP-15 +(varint), not our CBOR — so if we don't align now, the two clients won't interop. +We're the only writer; fix the wire format while it's free. + +## 2. Goal + +- Replace the CBOR codec with the **DIP-15 Dash-message varint** serialization, + including the `version` field and `acceptedAccounts`. +- Adopt DIP-15's **major/minor version forward-compat** model. +- **Define** (not yet populate) `reject` / `block` fields as a **minor-version + extension**, so a later spec can sync reject/block via contactInfo without + another format change. +- No behavior change to alias/note/hidden; pure wire-format + versioning. + +## 3. DIP-15 versioning model (verbatim, because it drives everything) + +dip-0015.md:771-776: `version = major << 16 | minor`. +- **Major** change = **incompatible**: a client that doesn't understand the major + version **discards the whole contactInfo**. +- **Minor** change = "most likely additional fields": an un-updated client + "should still be able to parse the first fields … and **ignore data past the + final field known in the version**." + +Consequence (this answers the "won't old clients break?" question): **adding +`reject`/`block` is a MINOR bump** → a DIP-15-v0 reader parses `version … +acceptedAccounts` and ignores our trailing fields. **No breakage.** Only a major +bump locks old readers out. So: +- our baseline = **major 0, minor 0** (DIP-15 v0 fields exactly); +- our reject/block extension = **major 0, minor 1** (appended fields); +- decoders MUST be **tolerant**: read the fields the known minor defines, ignore + trailing bytes; on an unknown **major**, discard. + +## 4. The reject/block fields — DEFINED but NOT ADOPTED (R1, resolved 2026-06-18) + +> **Resolution.** This field was the proposed cross-device carrier for +> reject/block. It is **not implemented** and is **not part of the shipped DIP-15 +> codec** (which carries only `aliasName` / `note` / `displayHidden` / +> `acceptedAccounts`). Per R1, a `contactInfo` about a *non-established* sender +> leaks *who* you ignored (the timing-correlation argument below), so **Ignore is +> local-only** (Spec 2) and cross-device sync is deferred to a future **encrypted +> field on the `profile` document** (contract / governance track) — NOT to +> `contactInfo`. The design below is retained for reference only. + +Appended after `acceptedAccounts`, present from **minor 1** (design only — unused): + +| # | Field | Type | Meaning | +|---|-------|------|---------| +| 5 | `relationshipState` | uInt8 | 0 = active, 1 = declined, 2 = blocked (extensible) | + +Rationale for a single `relationshipState` byte over two bools: declined/blocked +are mutually-exclusive states of one relationship; one enum is smaller, avoids +the "both set" ambiguity, and extends cleanly (e.g. 3 = muted). `displayHidden` +(field 3) stays as-is for backward DIP-15 compat; `relationshipState` is the +richer superset we read first when present. + +**Scope boundary (critical):** this spec only **defined** the field + its +encoding. *Whether and how* a `contactInfo` is created to carry it — especially +for a **non-established** declined/blocked sender — was the **privacy question +(R1 from the block review)**, **RESOLVED (2026-06-18): not via `contactInfo` at +all.** Ignore is local-only (Spec 2); cross-device goes through an encrypted +`profile` field later. Kept for context: + +> A `contactInfo` *about a non-contact* is a brand-new on-chain document whose +> existence + `$createdAt` can be timing-correlated with the inbound +> `contactRequest` (public `userIdCreatedAt` index) to re-identify *who* you +> blocked — even though `encToUserId` is encrypted, and the ≥2-contacts gate +> (dip-0015.md:697-699) can't cover a non-contact. **Spec 2 resolved this: a +> per-sender `contactInfo` is leaky (above) and even a single owner-scoped list +> on `contactInfo` still signals "an ignore happened", so ignore is kept +> local-only and cross-device is deferred to an encrypted `profile` field whose +> update timing is conflated with ordinary profile edits.** This format spec is agnostic to that +> choice — it just provides the field. + +## 5. Padding / 48-byte floor + +The contract validates `privateData` at **48–2048 bytes** (dip-0015.md:727). +Our CBOR codec appends a padding element to reach 48; the Dash-message format has +no field for that, and trailing padding would collide with the "ignore data past +the final known field" rule (a future reader could mis-read padding as a higher- +minor field). **Decision needed (Q-pad):** +- (a) Pad the **ciphertext region** only — encode the exact fields, then rely on a + reserved **`padding` length-prefixed byte field** placed *last in every minor* + and documented as "ignore"; or +- (b) define the floor purely as an encryption-layer concern (pad plaintext to ≥ + the size that yields 48-byte ciphertext, inside AES-CBC, with the pad length + recoverable) so the field stream itself carries no padding. +Recommend **(a) an explicit trailing `padding` var-bytes field** that is *always +the final field* and always skipped — it's self-describing (var-int length) so +"ignore trailing" still works, and it's the closest analog to today's behavior. + +## 6. Migration / compatibility of *existing* data + +contactInfo docs are immutable on-chain. Any docs **we** already wrote (CBOR, +no version field) become unreadable by the new varint decoder. +- DashPay is **pre-release** (not on mainnet); existing CBOR docs are + testnet/devnet UAT artifacts → **acceptable to abandon** (they'll simply fail + to decode and be skipped, same as a foreign-root doc today). +- Do **not** build a CBOR↔varint dual-reader unless we find we must preserve + specific test data. (Open question Q-dual.) +- The **local** SwiftData/SQLite mirror is rebuilt from chain on sync, so no + local migration is needed beyond the decoder swap. + +## 7. Implementation surface + +- `packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs`: + rewrite `encode_private_data` / `decode_private_data` to the Dash-message varint + format (var-int string/array helpers; `version` first; tolerant decode that + stops at the known-minor field count and skips trailing). Keep the AES-CBC layer. +- `ContactInfoPrivateData` struct: add `version: u32` (or major/minor accessors), + `accepted_accounts: Vec`, and `relationship_state: u8` (minor ≥ 1). + `displayHidden` stays. (Note: the in-memory struct already flows through + `set_contact_metadata(ContactInfoPrivateData)` after the recent refactor.) +- No FFI/Swift signature change (privateData is opaque bytes across the boundary); + only the bytes' internal layout changes. + +## 8. Test plan + +- **Round-trip:** encode→decode every field incl. empty/None strings, empty and + non-empty `acceptedAccounts`, `relationshipState` 0/1/2. +- **Forward-compat:** a **minor-0** decoder reading **minor-1** bytes parses + v0 fields and ignores `relationshipState` (the DIP-15 guarantee) — pin it. +- **Major-incompat:** a decoder reading an unknown **major** discards (returns + None / skips), not a partial parse. +- **Vector:** if any DIP-15 / reference test vector for privateData exists, match + it byte-for-byte (none found in dashj/android-dashpay; we may be authoring the + first — note that). +- **Floor:** encoded output is ≥ 48 bytes after padding (Q-pad), ≤ 2048. + +## 9. Open decisions + +- **Q-pad** — explicit trailing `padding` field (recommended) vs encryption-layer + padding. +- **Q-dual** — abandon existing CBOR docs (recommended, pre-release) vs build a + CBOR/varint dual-reader. +- **Q-state** — single `relationshipState: uInt8` (recommended) vs separate + `declined`/`blocked` flags. +- **Q-minor-now** — define `relationshipState` (minor 1) in *this* spec/PR, or + ship the pure DIP-15-v0 alignment first (minor 0) and add the field in Spec 2? + (Leaning: ship v0 alignment here; add the field in Spec 2 where it's used — + keeps this PR a clean wire-format fix.) diff --git a/docs/dashpay/IMPORTED_IDENTITY_KEY_MATERIALIZATION_SPEC.md b/docs/dashpay/IMPORTED_IDENTITY_KEY_MATERIALIZATION_SPEC.md new file mode 100644 index 00000000000..192c1c7f464 --- /dev/null +++ b/docs/dashpay/IMPORTED_IDENTITY_KEY_MATERIALIZATION_SPEC.md @@ -0,0 +1,352 @@ +# Imported-Identity Key Materialization — carry the derived scalar + +Status: draft for review +Scope: fixes the second, on-device-blocking defect of "imported identity cannot +sign" (the first — discovery emitting a breadcrumb only for the master key — is +already fixed; see `IMPORTED_IDENTITY_SIGNING_SPEC.md`). + +## 1. Problem + +On a freshly-imported wallet the discovered identities' keys are all persisted +**watch-only**, so the identity cannot sign any state transition +("No PersistentPublicKey row matches the supplied public-key bytes"). + +On-device diagnosis (testnet, SwiftExampleApp) proved the chain end-to-end: + +- Rust discovery derives + verifies every key and emits a breadcrumb for each + (`candidates_derived=5 breadcrumbed=5`, indices correct). +- The breadcrumb reaches Swift `persistIdentityKeys` with the correct + `(identity_index, key_index)`. +- `deriveAndStoreIdentityKey` then returns `nil` for **every** key with + `⚠️ mnemonic missing … Mnemonic not found`. + +Root cause: `deriveAndStoreIdentityKey` **re-derives** the scalar by reading the +wallet's BIP-39 mnemonic back out of `WalletStorage` (iOS Keychain) and running +`mnemonic → seed → path → key`. During import, identity discovery (which +materializes keys) runs **before** `CreateWalletView.createWallet` persists the +mnemonic to `WalletStorage`, so the read fails and the key is dropped to +watch-only. Even outside that race the re-derive is fragile: a watch-only-loaded +wallet, a missing/biometric-gated mnemonic, or any keychain hiccup silently +yields watch-only keys. + +This is precisely the anti-pattern `packages/swift-sdk/CLAUDE.md` forbids: Swift +must not "fetch the mnemonic from Keychain, hand it back to Rust, … and write +those to Keychain". The same doc states the sanctioned shape: "accept +`(path_string, 32_private_key_bytes)` from a Rust FFI call and write to +Keychain." + +## 2. Chosen approach — carry the verified scalar to the client + +Discovery already derives **and verifies** each candidate scalar +(`discovery::derive_key_breadcrumbs` → `breadcrumb_decisions`, gated on +`IdentityPublicKey::validate_private_key_bytes`). Today `breadcrumb_decisions` +**discards** that scalar and keeps only `(wallet_id, identity_index, key_index)`, +forcing the client to re-derive. Instead, carry the already-derived, already- +verified 32-byte scalar through the persister changeset to the client, which +stores the bytes directly — no mnemonic read, no re-derivation, no timing +dependency. + +Why this over the alternatives: + +- **Reorder mnemonic-store-before-discovery** (rejected as the primary fix): the + network-scoped `walletId` is returned *by* `createWallet`, so storing the + mnemonic first is a chicken-and-egg, and it leaves the fragile re-derive (and + the anti-pattern) in place — every future caller that materializes a key still + depends on a readable keychain mnemonic. +- **Re-run a from-0 discovery after storeMnemonic** (rejected): the default scan + resumes past known indices, so it would not re-emit; forcing from-0 is hacky + and still keeps the re-derive. + +Precedent: `dash_sdk_derive_and_persist_identity_keys` already passes +`PersistKeyArgs.private_key_bytes: *const u8` (32 bytes) over the FFI to the +Swift persister, which writes them to Keychain. We extend the *changeset* +persister path (`IdentityKeysChangeSet` → `on_persist_identity_keys`) to carry +the same secret, reusing `KeychainManager.storeIdentityPrivateKey(_:derivationPath:metadata:)`. + +## 3. Data flow & interface changes + +Secret path (new), one secret per re-derivable key, `None` for watch-only: + +``` +discovery::breadcrumb_decisions (already holds the verified Zeroizing<[u8;32]>) + → IdentityKeyWithBreadcrumb carry Zeroizing<[u8;32]> alongside the indices + → ManagedIdentity::add_keys move the secret into the changeset entry + → IdentityKeyEntry + private_key: Option> + → IdentityKeysChangeSet (persister.store) + → FFI persistence dispatch (persistence.rs) + → IdentityKeyEntryFFI + private_key_is_some: bool, private_key: [u8;32] + (from_entry copies bytes; free_identity_key_entry_ffi zeroes them) + → Swift persistIdentityKeysCallback build IdentityKeyEntrySnapshot.privateKey: Data? + → persistIdentityKeys store bytes directly (verify-then-store), no re-derive +``` + +Layer-by-layer: + +1. **`changeset.rs`** + - `KeyDerivationBreadcrumb` gains the scalar: + `(wallet_id, identity_index, key_index, Zeroizing<[u8;32]>)`. (It already + transits only in-process; not serialized in any persisted form that matters + — see Failure modes #5.) + - `IdentityKeyEntry` gains `pub private_key: Option>`. + `PartialEq`/`Clone` keep working (`Zeroizing` is both). The `serde` + derives must **skip** `private_key` (`#[serde(skip)]`) so a secret never + lands in any serialized changeset. +2. **`discovery::breadcrumb_decisions`**: on `reproduces`, clone the scalar out + of `candidate_scalars` into the breadcrumb. The verify gate is unchanged and + still load-bearing — only a scalar that reproduced the on-chain key is + carried. +3. **`identity_ops.rs::add_keys`**: move the scalar from the breadcrumb into + `IdentityKeyEntry.private_key`. `add_key` (single) delegates unchanged. + `keys_snapshot_changeset` sets `private_key: None` (watch-only snapshot). +4. **`identity_persistence.rs::IdentityKeyEntryFFI`**: add + `private_key_is_some: bool` + `private_key: [u8; 32]`. `from_entry` copies the + bytes (or zero + false). `free_identity_key_entry_ffi` zeroes the 32 bytes. + The struct is `#[repr(C)]`; field order documented in the byte-layout comment. +5. **`persistence.rs`** identity-keys dispatch: unchanged except it now hands the + FFI struct (with the secret) to the callback; the existing + `free_identity_key_entry_ffi` loop already runs after the callback returns and + will zero the secret. +6. **Swift `persistIdentityKeysCallback`**: read `private_key_is_some` → copy the + 32 bytes into a `Data`, build `IdentityKeyEntrySnapshot.privateKey: Data?`. +7. **Swift `persistIdentityKeys`**: when `privateKey != nil`, verify-then-store: + compute `platform_wallet_pubkey_hash_from_private_key(scalar)` and compare to + `entry.publicKeyHash` (the §7.2 mirror-check, now applied to the carried + bytes); on match call `storeIdentityPrivateKey(data, derivationPath:, metadata:)` + and set `privateKeyKeychainIdentifier`; on mismatch leave watch-only. The + `derivationPath` string is built in Swift from `(network, identity_index, + key_index)` via the existing `KeyDerivation.getIdentityAuthenticationPath` + (a pure string format for the keychain account label — not key derivation), + matching the account shape `storeIdentityPrivateKey` already uses. Scrub the + `Data` after storing. `deriveAndStoreIdentityKey` (the mnemonic-re-derive + path) is **deleted** — no caller remains. + +The non-secret breadcrumb (`derivation_indices`) is **retained** on +`IdentityKeyEntry`/the FFI/the snapshot: it still populates the keychain metadata +(identity/key index) and the explorer, and is the watch-only-vs-signable +discriminant for any consumer that doesn't want the bytes. + +## 4. Security + +- **Bytes over the FFI are sanctioned** for the Keychain-write exception + (`swift-sdk/CLAUDE.md`) and already precedented (`PersistKeyArgs`). No *new* + secret class crosses the boundary; the same scalar create-in-app already + materializes. +- **Verify-before-store stays double-gated**: (a) Rust only carries a scalar that + passed `validate_private_key_bytes` against the on-chain key; (b) Swift + re-verifies the carried bytes' pubkey-hash equals the published hash before + writing — a corrupted/mismatched transfer drops to watch-only, never stores a + wrong key. +- **No secret at rest outside Keychain**: `IdentityKeyEntry.private_key` is + `#[serde(skip)]`; SwiftData stores only `privateKeyKeychainIdentifier` + (account string), never the bytes; the FFI buffer is zeroed in + `free_identity_key_entry_ffi`; the Swift `Data` is `resetBytes` after store. +- **No secret logging**: no `tracing`/`print` of `private_key`/derived bytes; + logs carry only `key_id` / public hashes (enforced in review). +- **Confused-deputy / cross-wallet**: unchanged — the carried scalar only exists + because *this* wallet's seed reproduced *this* identity's key under the verify + gate. + +## 5. Failure modes + +1. Scalar absent (watch-only / foreign / non-ECDSA): `private_key: None` → + `private_key_is_some=false` → Swift leaves the key watch-only (correct, no + regression). +2. Carried bytes fail the Swift mirror-check: drop to watch-only + warn (public + hashes only). Loud, fail-safe. +3. Keychain write fails: `storeIdentityPrivateKey` returns `nil` → watch-only; + next persister upsert retries. +4. Ordering race that caused this bug: **eliminated** — no mnemonic read on the + materialization path. +5. Serialized changeset: `private_key` is `#[serde(skip)]`, so any + serialize/deserialize round-trip yields `None` (degrades to watch-only, never + leaks). Audit: confirm no production path persists `IdentityKeysChangeSet` and + expects the secret to survive serde (the secret is an in-process, + same-tick FFI hand-off only). + +## 6. Migration + +An already-imported (broken, all-watch-only) wallet heals on the next full +from-index-0 discovery (a wipe + re-import does this; the resident wallet path +needs no mnemonic). No persisted-data migration; the change is additive. + +## 7. Test / verification plan + +- **Rust unit** (`discovery.rs` tests): extend the existing + `breadcrumb_decisions_*` tests to assert the breadcrumb now carries the + verified scalar for reproducible keys and `None` for non-reproducible. + `add_keys` tests assert `IdentityKeyEntry.private_key` is `Some(scalar)` for + breadcrumbed keys, `None` otherwise. +- **FFI round-trip** (`identity_persistence.rs` tests): `from_entry` sets + `private_key_is_some` + bytes; `free_identity_key_entry_ffi` zeroes them. +- **Serde guard**: a test that serializes an `IdentityKeyEntry` with a secret and + asserts the secret is absent from the output / `None` after round-trip. +- **Swift**: unit-test the snapshot mapping (private_key_is_some → Data?) and the + verify-then-store branch. +- **On-device** (the acceptance test): wipe → fresh import → confirm + `ZPERSISTENTPUBLICKEY` rows flip to signable (keychain id set) → sign a state + transition (set DashPay profile / register DPNS) as a discovered identity → + success, no "No PersistentPublicKey row matches". +- **Red→green**: capture the pre-fix all-watch-only histogram and the post-fix + signable histogram in the same store (within-store contrast). + +## 8. Out of scope + +- The first defect (master-only breadcrumb) — already fixed. +- Reworking `CreateWalletView`'s create/store ordering (no longer needed once the + materialization path is mnemonic-independent). +- Android/other clients (the changeset/FFI carries the secret generically; only + the iOS persister is wired here). + +## 9. Review outcomes — must-fixes folded in + +Four independent reviews (blockchain-security, feasibility, scope, adversarial) +ran against §1–8. Consolidated must-fixes (these REVISE the sections above): + +### Correctness — the two ways the fix silently produces watch-only keys + +- **MF-A (HASH160 verify is arithmetically wrong).** `entry.publicKeyHash` = + `ripemd160_sha256(pub_key.data())` (`identity_ops.rs::pubkey_hash_of`). For an + `ECDSA_HASH160` key `data()` is *already* the 20-byte hash, so the field is + `hash160(hash160(pubkey))` — a double hash — while + `platform_wallet_pubkey_hash_from_private_key` returns single `hash160(pubkey)`. + They never match → a HASH160 key would always drop to watch-only (a regression: + the current `deriveAndStoreIdentityKey` stores it because it gates the hash + compare on `keyType == ecdsaSecp256k1`). **Fix:** the new verify-then-store + branch keeps that exact gate — re-verify only `ECDSA_SECP256K1`; for any other + carried type store directly and rely on the Rust `validate_private_key_bytes` + gate (which is type-correct). Document that the Swift mirror-check is + ECDSA-SECP256K1-only and that a future non-ECDSA carry must grow a per-type + branch. + +- **MF-B (clear-after-set race).** `persistIdentityKeys`'s no-secret branch sets + `privateKeyKeychainIdentifier = nil` unconditionally. In `discover_inner` the + order is `add_identity` (watch-only snapshot of *all* keys) → `add_keys` + (secret-bearing) — so the secret currently wins only by emit order, and a + watch-only snapshot of a key cannot be told apart on the wire from a genuinely + watch-only key. **Fix:** the Swift no-secret branch must **preserve** an + existing `privateKeyKeychainIdentifier` rather than nil it — a key that was + materialized stays materialized (the Keychain item is durable; a genuinely + watch-only key never had an id to preserve). This makes materialization + order-INDEPENDENT. (The "drop the snapshot from `add_identity`" alternative was + rejected: `add_identity` is shared by flows that do NOT follow with `add_keys` + — `platform_wallet.rs:791`, `register_from_addresses.rs:131`, + `payments.rs:885/950` — so its `keys_snapshot_changeset` is load-bearing there + and can't be removed wholesale.) **Test:** emit a watch-only snapshot *after* + the secret upsert and assert the key stays signable. + +### Security — secret hygiene (revises §4) + +- **MF-C (volatile zeroize, by-value precedent).** Embed the secret by value + (`private_key: [u8; 32]` + `private_key_is_some: bool`) mirroring the existing + `IdentityKeyPreviewFFI` (`derive_identity_key_at_slot.rs:241`), NOT the + `PersistKeyArgs` pointer. `free_identity_key_entry_ffi` MUST scrub via + `zeroize::Zeroize::zeroize(&mut entry.private_key)` (volatile — the codebase + already replaced non-volatile `*byte = 0` scrubs for exactly this reason; see + `identity_keys_from_mnemonic.rs:53` / `zeroize_and_free_row`), and scrub + **unconditionally** (even when `private_key_is_some == false`, the 32 bytes are + still present). The post-callback `free_identity_key_entry_ffi` loop in + `persistence.rs:912-914` then covers the `Vec` copy. +- **MF-D (strip secret from the `pending` copy).** `FFIPersister::store` clones + the whole changeset into `self.pending` (`persistence.rs:1506-1511`) — a + long-lived, un-zeroized second copy of the scalar. `pending` is never replayed + to the callbacks (only `flush` consumes it). **Fix:** do not carry + `private_key` into the `pending` accumulator — strip/replace it with `None` + before the `merge`/insert (the secret is only needed for the immediate + synchronous callback dispatch). This keeps the "no secret at rest outside + Keychain" guarantee true; update §4/§5's "same-tick only" wording accordingly. +- **MF-E (Debug leak).** `Zeroizing`'s derived `Debug` prints the inner bytes + (it is a tuple-struct derive, not redacting). `IdentityKeyEntry` derives + `Debug` and rides inside `IdentityKeysChangeSet`/`PlatformWalletChangeSet`, + which ARE logged on persist errors. **Fix:** hand-write `Debug` for + `IdentityKeyEntry` redacting `private_key` (or wrap the scalar in a newtype with + a redacting `Debug`); unit-test that `format!("{:?}", entry)` contains no secret. +- **MF-F (scrub every Swift copy).** The intermediate tuple decode + (`var t = e.private_key; Data(...)`) is an un-scrubbed stack copy (the existing + by-value precedent at `ManagedPlatformWallet.swift:956` never scrubs it). + Scrub the intermediate tuple immediately after the `Data` copy, and + `resetBytes` `IdentityKeyEntrySnapshot.privateKey` for EVERY entry at the end of + `persistIdentityKeys` — including the watch-only-skip and mismatch-drop + branches, not only the stored one. + +### Feasibility / scope (revises §3) + +- **MF-G (don't widen `KeyDerivationBreadcrumb`).** Keep + `KeyDerivationBreadcrumb = ([u8;32], u32, u32)` (a shared navigation token). + Carry the scalar by replacing the `IdentityKeyWithBreadcrumb` tuple with a + named struct, e.g. `KeyWithBreadcrumb { key: IdentityPublicKey, breadcrumb: + Option, verified_scalar: Option> }`. + `breadcrumb_decisions` produces it; `add_keys` consumes it. +- **MF-H (enumerate ALL construction sites).** Adding the non-defaulted + `IdentityKeyEntry.private_key` breaks every struct literal — beyond the two the + spec named: `rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs:70` + (`into_entry`, production), `identity_persistence.rs` FFI tests + (~1069/1114/1147/1179), and storage tests (`sqlite_structural_hardening.rs:310`, + `sqlite_persist_roundtrip.rs:225`). All get `private_key: None`. The + `rs-platform-wallet-storage` crate was missing from the §3 scope list — add it. +- **MF-I (serde + on-disk audit).** `#[serde(skip)]` on `private_key` is required + for **compilation** (`Zeroizing` impls neither `Serialize` nor `Deserialize`). + The one production serde-ish persister, `IdentityKeyWire` + (`rs-platform-wallet-storage/.../identity_keys.rs:34-79`), is secret-free *by + construction* (field-selective transcription, never reads `private_key`). Add a + regression asserting `IdentityKeyWire` has no secret field so a future + "serialize `IdentityKeyEntry` straight to the blob" refactor can't start + persisting it. +- **MF-J (FFI layout guard + no hand Swift mirror).** Recompute the + `const _: [u8; 184] = [0u8; size_of::()]` guard + (`identity_persistence.rs:335`) and the byte-offset comment; place + `private_key_is_some` + `private_key` **last** to minimize padding churn. There + is NO hand-maintained Swift mirror struct — cbindgen regenerates the header at + build (`build.rs`), so Swift auto-sees the fields after `build_ios.sh`; the + Rust comment claiming a "Swift mirror in PlatformWalletFFI.swift" is stale — + correct it. Only `persistIdentityKeysCallback` needs updating to read the new + fields. + +### Confirmed sound / no change + +- The Rust verify gate (`validate_private_key_bytes`) is type-correct and + load-bearing; only a scalar that reproduces the on-chain key is ever carried. + No path stores a wrong key and signs with it. +- `deriveAndStoreIdentityKey` has exactly one caller (`persistIdentityKeys`) — + safe to delete. `retrieveMnemonicUTF8Bytes` / `Mnemonic.toSeed` stay used by + `MnemonicResolverAndPersister.swift` (resolver path) — no dead-import fallout. +- Keeping BOTH `derivation_indices` (label/explorer/discriminant for watch-only + wallets) and `private_key` (the secret) is justified — neither is redundant. +- Keychain account `identity_privkey..` is unchanged; build the + path label from `entry.walletId ?? scopeWalletId` + the carried indices via the + mnemonic-free FFI path-formatter `getIdentityAuthenticationPath`. +- §6 heal requires an explicit from-index-0 rescan (`start_index: Some(0)`) or + wipe+reimport — a default resident `sync()` resumes past known indices and will + NOT re-materialize an already-known-but-watch-only identity. The durable + signability marker across restarts is the `privateKeyKeychainIdentifier` + column (the Keychain item persists), so a healed wallet stays healed. + +## 10. Implementation review — fixes folded in + +A second four-agent panel (blockchain-security, swift-ios, adversarial, rust-quality) +reviewed the implemented diff. All ten spec must-fixes verified correctly applied; +the Rust side passed clean. Additional fixes applied from this round: + +- **IR-1 (must-fix, secret hygiene).** The end-of-loop `resetBytes` scrub in + `persistIdentityKeys` was defeated by copy-on-write: the C-shim's `upserts` + array still referenced the buffers, so the subscript mutation zeroed a CoW + fork and left the scalar handed to the Keychain un-wiped in freed heap. + Fixed: `persistIdentityKeys` is now strictly read-only over `upserts`, and the + scrub moved to `persistIdentityKeysCallback` AFTER the call returns, where that + array is the sole owner — an in-place wipe of the actual bytes. +- **IR-2 (should-fix, registration regression).** Deleting `deriveAndStoreIdentityKey` + removed the only setter of `PersistentPublicKey.privateKeyKeychainIdentifier` + for self-registered (not imported) identities (signing still worked via the + keychain pubkey-hex fallback scan, but the `hasPrivateKey` UI marker + fast + path regressed). Fixed: when a key carries a breadcrumb but no scalar (the + registration case, whose keychain item is written by its own path), Swift + adopts the existing keychain account via a public-key-hex lookup + (`KeychainManager.identityPrivateKeyAccount`) — no derivation, no secret loaded. +- **IR-3 (low, footgun).** Coupled the carried scalar to the breadcrumb in + `add_keys` so a `verified_scalar`-without-breadcrumb is dropped (can't reach + the client without the indices it needs); pinned by + `add_keys_drops_scalar_without_breadcrumb`. +- **IR-4 (nit).** `unwrap()` → `expect()` in the new utils test. + +Confirmed correct, no change: volatile zeroize, `pending`-strip, redacting Debug, +`#[serde(skip)]`, layout guard, the ECDSA-only verify gate (HASH160 relies on the +Rust gate). On-device re-verified after these fixes: clean import → 23/23 signable. diff --git a/docs/dashpay/IMPORTED_IDENTITY_SIGNING_SPEC.md b/docs/dashpay/IMPORTED_IDENTITY_SIGNING_SPEC.md new file mode 100644 index 00000000000..c957c68038b --- /dev/null +++ b/docs/dashpay/IMPORTED_IDENTITY_SIGNING_SPEC.md @@ -0,0 +1,269 @@ +# Spec — Imported identity cannot sign: emit full key-derivation breadcrumbs on discovery + +Status: DRAFT v2 (reviewed by 4 lenses incl. blockchain-security; must-fixes folded in) +Scope: `packages/rs-platform-wallet` (discovery) + 1 optional Swift defense-in-depth check. +Related: `docs/dashpay/TODO.md` → "BUG (UAT 2026-06-19): an IMPORTED identity cannot sign". + +## 1. Problem + +An identity **rediscovered from a mnemonic** (gap-limit "discover identities" scan / +re-import) cannot **sign state transitions**. Signed ops fail with: + +``` +SDK error: Protocol error: Generic Error: No PersistentPublicKey row matches the supplied public-key bytes +``` + +A **create-in-app** identity signs fine. On-device the on-chain public keys *are* +persisted correctly; what is missing is the signing key's **private** material. + +Concretely this restores **identity authentication-key signing** — register DPNS name, +set DashPay profile, and identity-credit transfers. It does **not** by itself fix +DashPay-contact-request ECDH (see §3 non-goals). + +## 2. Root cause + +Signing reads a **pre-stored** private key; an imported identity never materializes the +private key for its non-master keys. + +- The Swift persist handler `persistIdentities` → `deriveAndStoreIdentityKey` + (`PlatformWalletPersistenceHandler.swift:1584`) is the single key-materialization + path. Per persisted key it branches on the **breadcrumb** `entry.derivationIndices`: + present → re-derive the 32-byte scalar from `(seed, path)` via + `key_wallet_derive_private_key_from_seed` at + `getIdentityAuthenticationPath(identityIndex, keyIndex)` and store in the Keychain + (**no private bytes cross the FFI** — Swift re-derives from the mnemonic); absent → + `privateKeyKeychainIdentifier = nil` (watch-only). +- `KeychainSigner` (identity path, `key_type < 5`) signs from the **pre-stored** + Keychain bytes; it does not re-derive on demand. + +Breadcrumb coverage differs: + +- **Create** (`registration.rs:297-304`): breadcrumb for **every** key (`key_index = + key_id`) → all materialized → all signable. +- **Discovery** (`discovery.rs:281-285`): breadcrumb for **only the MASTER key**; + the other keys arrive via `add_identity` → `keys_snapshot_changeset` + (`identity_ops.rs:51-71`) with `derivation_indices: None` (watch-only). The selected + signing key is a non-master HIGH/CRITICAL auth key → no private key → the misleading + "No PersistentPublicKey row matches" (row exists; private key does not). + +### Not the cause: the pasta-bridge derivation path + +Bridge keys (`github.com/PastaPastaPasta/dash-bridge`, `src/crypto/hd.ts`) are derived +at `m/9'/{coin}'/5'/0'/{keyType}'/{identityIndex}'/{keyIndex}'`, registered with +`keyId == key.id == keyIndex` — **byte-for-byte identical** to platform-wallet's +`identity_auth_derivation_path` (key-wallet `dip9.rs`: 9 / testnet-coin 1 / identities +5 / subfeature 0 / ECDSA 0) and to create-in-app's `key_index = key_id`. The keys are +re-derivable from the mnemonic; the bridge is standard-compliant. The bug is purely the +discovery breadcrumb gap. + +## 3. Goal / non-goals + +Goal: a discovered identity whose keys are re-derivable from the wallet mnemonic at the +DIP-9 auth path can sign with its authentication keys, exactly like create-in-app — by +emitting a derivation breadcrumb for every such key during discovery so the existing +Swift handler materializes its private key. + +**Seed-availability precondition (load-bearing).** A breadcrumb is a *claim the client +may be unable to honor*. Swift materializes a key only if the wallet's mnemonic is +retrievable from the Keychain (`retrieveMnemonicUTF8Bytes(walletId)`); otherwise +`deriveAndStoreIdentityKey` returns `nil` and the row stays watch-only (no half-state, +no regression). So this fix makes keys signable **iff the wallet's seed is on-device**. +For a normal mnemonic import that holds; for a true seed-less watch-only wallet the +breadcrumb is correctly inert. + +Non-goals: +- No FFI/Swift behavioral change to the materialization path (it already works); the one + optional Swift change is a defensive re-verify (§7.2). +- No on-demand re-derivation in `KeychainSigner` (larger refactor; unnecessary once + breadcrumbs are complete). +- **DashPay contact-request / contactInfo ECDH is out of scope.** + `send_contact_request_with_external_signer` derives the ECDH private key + DashPay + xpub directly from the **resident** in-process `Wallet` + (`contact_requests.rs` CAVEAT: "watch-only wallets — no seed Rust-side — WILL fail at + this step"). That is a separate seed-residency concern; this spec restores **auth-key + signing** only. Whether contact requests already work for the app's import flow + depends on whether that wallet is resident-key vs external-signable — to be confirmed + during on-device verification (§8), tracked separately if still broken. +- Keys NOT re-derivable from this wallet's mnemonic at the DIP-9 auth path (BLS/EdDSA, + foreign/rotated/imported keys) — stay watch-only (correct). + +## 4. Chosen approach + +In `discover_inner` (`discovery.rs`), replace the master-only breadcrumb emission +(`discovery.rs:277-286`) with a per-key verify-and-emit pass. Key shape (the master key +becomes the `key_id == 0` case of the loop; its dedicated `add_key` is removed): + +**(a) Derive + verify candidates BEFORE taking the write lock** (resolves the borrow +conflict: candidate derivation needs `&Wallet`/`&master`, breadcrumb emission needs +`&mut info` — they cannot co-borrow the manager guard). After the identity is fetched +(network, no lock held): + +For each `(key_id, on_chain_key)` in `identity.public_keys()`: +1. Derive the candidate ECDSA auth **keypair** at `(identity_index, key_index = key_id)` + from the same `KeyHashSource` as the master probe: + - `KeyHashSource::Master(master)` → `derive_ecdsa_identity_auth_keypair_from_master` + (lock-free; yields `private_key: Zeroizing<[u8;32]>`). + - `KeyHashSource::ResidentWallet` → re-acquire a **read** lock on the manager, fetch + `&Wallet`, call `derive_identity_auth_keypair` (yields `ExtendedPrivKey`; take + `.private_key.secret_bytes()`), drop the read lock before the write lock below. +2. **Verify** with the canonical consensus primitive — do NOT hand-roll a per-type + pubkey/hash compare: + ```rust + let reproduces = on_chain_key + .validate_private_key_bytes(&candidate_private_scalar, network) + .unwrap_or(false); + ``` + `validate_private_key_bytes` (rs-dpp, `identity_public_key/v0/methods/mod.rs`) is the + **same primitive the protocol uses to validate key ownership**, so the wallet's match + is identical to consensus and cannot drift. For ECDSA it recomputes the **compressed** + pubkey from the candidate scalar and compares; it also handles ECDSA_HASH160 + (ripemd160_sha256) and returns `false`/`Err` for BLS/EdDSA/unsupported — so a + non-reproducible key is fail-safe. + **Empirically verified (TDD) + corrected by the security re-review:** `validate_private_key_bytes` + is *compressed-only* for ECDSA (it does NOT match a 65-byte uncompressed on-chain key), + and contrary to an earlier draft of this spec, Platform does **NOT** reject uncompressed + identity keys at registration — `UncompressedPublicKeyNotAllowedError` lives only in the + asset-lock signing path, and identity proof-of-possession accepts uncompressed keys. + Compressed-only matching is nonetheless **correct**: the wallet only ever *derives* the + 33-byte compressed form, so an uncompressed externally-registered key is simply not + wallet-derivable and correctly stays watch-only (graceful degradation, never a wrong-key + hazard). No code change needed — just don't justify it with the false "rejected at + registration" claim. +3. Record the decision: `key_id → Some((wallet_id, identity_index, key_id))` if + `reproduces`, else `None` (watch-only). Zeroize the scalar. + If an **ECDSA** auth key fails to verify at its `key_id` candidate, log at + `warn!` (not `debug`) with `key_id` (no key material) so a still-unsignable import is + field-diagnosable. + +**(b) Emit one batched changeset under the write lock.** Take the write lock once; +`add_identity` (as today) then a **single** `IdentityKeysChangeSet` carrying every key +with its decided breadcrumb-or-`None` (new `ManagedIdentity::add_keys(Vec<(IdentityPublicKey, +Option)>)` helper, or inline). This replaces the N separate `add_key` +round-trips, and removes the order-dependent "watch-only then override" merge (one +authoritative key changeset per identity). Document that this batch is the single +source of per-key breadcrumbs for a discovered identity. + +**(c) Shared with the index-load path.** Steps (a)+(verify) are extracted into +`IdentityWallet::derive_key_breadcrumbs(identity, identity_index, network, master: +Option<&ExtendedPrivKey>)`, used by **both** `discover_inner` AND +`load_identity_by_index_inner` (`loading.rs`) — the latter had the identical master-only +bug (it backs the public `loadIdentity(atIndex:)` API). The resident-source `get_wallet` +lookup fails loud (consistent with every other manager lookup) rather than silently +leaving all keys watch-only. + +The candidate-derivation cost is ≤ (#on-chain keys) secp256k1 derivations per discovered +identity, no network. + +## 5. Alternatives considered / rejected + +- **Assume `key_index = key_id`, emit unconditionally (no verify).** Would store a + WRONG private key for any on-chain key not re-derived from this seed at that slot — + not just hypothetical future creators, but any rotated / multisig / imported / + foreign key. Swift would then sign with an unauthorized key → Drive rejects at best. + Rejected; verify-before-emit is load-bearing. +- **Hand-rolled per-type pubkey match.** Rejected — it can drift from consensus + per-type semantics, whereas `validate_private_key_bytes` IS the consensus primitive. + (The review feared a hand-rolled compressed-only match would miss uncompressed keys; + that turned out moot — uncompressed ECDSA keys are protocol-disallowed — but using the + canonical primitive is still the right call for drift-resistance.) +- **Per-key gap scan (`key_index` 0..N per on-chain key).** O(keys×gap) for zero present + benefit (`key_id == key_index` holds for bridge + app). Possible future fallback; + not now. +- **On-demand re-derivation in `KeychainSigner`.** Larger surface; breadcrumb + completeness is its prerequisite anyway, so this spec is a strict subset. Deferred. +- **Pass private bytes across the FFI.** Violates the swift-sdk no-secret-transit rule. + Rejected. + +## 6. Failure modes & edge cases + +- **Non-ECDSA on-chain key** (BLS/EdDSA): `validate_private_key_bytes` returns + false/err → watch-only. Correct. +- **Disabled key** (`disabled_at` set): **emit** the breadcrumb (matches create / + `registration.rs`, which doesn't special-case; the key is never selected for signing — + every signer uses `allow_disabled = false` — so materializing it is inert). Decision + closed: emit. +- **`key_index != key_id`** (non-conforming creator): candidate won't verify → + watch-only → that key stays unsignable (no regression), `warn!`-logged. +- **`ECDSA_HASH160` auth key**: covered by `validate_private_key_bytes`. +- **Key rotation / keys added after first discovery**: a fresh **full** rescan re-reads + `identity.public_keys()` and verify-emits each → rotated-in keys are picked up + (provided `key_index == key_id`). No separate rotation handling. +- **ENCRYPTION (ECDH) key**: if it's an ECDSA key at `key_id`, it verifies and gets a + (harmless) breadcrumb. DashPay ECDH does **not** consume the materialized scalar — it + derives from the resident seed separately (`contact_info.rs`) — so this fix neither + fixes nor breaks ECDH (which remains seed-residency-bound, §3 non-goal). +- **Partial failure** (per-key granularity): with the batched changeset (§4b) the keys + land atomically per identity; a persist failure leaves the whole batch unpersisted and + is retried on the next full rescan. (If kept as per-key calls instead, some keys could + be breadcrumbed and others not within one identity — another reason to batch.) +- **Resident vs master source parity**: candidate derivation uses the same source as the + master probe, so an external-signable wallet derives from the resolved xpriv. +- **Observed (out-of-wallet) identities**: never enter `discover_inner` (added via + `add_out_of_wallet_identity`) → no breadcrumbs → watch-only. Correct. + +## 7. Security considerations + +1. **Wrong-key signing** — primary risk, eliminated by verify-before-emit using the + canonical `validate_private_key_bytes` (§4 step 2). No path emits a breadcrumb + without a successful match; preimage resistance precludes a false-positive. +2. **(Optional, recommended) Swift cross-FFI mirror check** — the Rust verify and the + Swift re-derivation are different code on opposite sides of the FFI. Add to + `deriveAndStoreIdentityKey`: after deriving the scalar, compute + `ripemd160(sha256(pubkey))` and compare to the already-passed `entry.publicKeyHash`; + on mismatch, log + return `nil` (watch-only) instead of storing. Turns a future + cross-side derivation drift into a loud, fail-safe miss. Cheap; small Swift change. +3. **Confused-deputy / cross-wallet** — the breadcrumb carries the scanning wallet's id; + the verify gate requires that wallet's seed to actually reproduce the key, so a + foreign identity sharing a `key_id` cannot bind (its candidate won't verify). Sound. +4. **identity_index correctness** — all keys of one identity share the scan-cursor + `identity_index`; `key_index = key_id` per key. A wrong index fails safe (no verify). +5. **Private-key-at-rest** — materializes the non-master auth scalars into the Keychain, + the **same exposure create-in-app already has**; no new secret class, no bytes over + the FFI. **No secret logging**: the candidate scalar stays in `Zeroizing`; logs carry + only `key_id` / public hashes. (Implementation must not log `private_key`/`derived`.) +6. **DoS / cost** — bounded (≤ #keys derivations, no network). + +## 8. Test / verification plan + +Rust unit tests (`discovery.rs` test module, mock SDK + planted identity, +`RecordingPersister` capturing changesets): +- `discovery_emits_breadcrumb_for_every_reproducible_key`: N keys derivable at + `key_index = key_id` → N breadcrumbed `IdentityKeyEntry`s + (`derivation_indices == Some((identity_index, key_id))`). +- `discovery_leaves_non_reproducible_key_watch_only`: a planted key whose data does NOT + reproduce at its `key_id` (foreign pubkey / non-ECDSA) → `derivation_indices == None`. +- `breadcrumb_decisions_matches_hash160_key`: an `ECDSA_HASH160` key verifies by hash and + gets a breadcrumb (pins the second valid ECDSA representation). (Uncompressed ECDSA is + NOT tested — protocol-disallowed at registration, so not a valid on-chain key.) +- `discovery_backfills_breadcrumbs_on_full_rescan`: re-running discovery from index 0 + over an already-present identity re-emits the breadcrumbs (idempotent upsert; pins the + §9 migration claim). +- Master-key parity: the master key still gets its breadcrumb (now via the loop/batch). + +On-device (testnet sim, simulator-control): +- Re-import the testnet mnemonic, run a **full rescan from index 0** (see §9), then + perform an auth-signed op (set DashPay profile / register a name) as a bridge-created + identity. Expected: succeeds (was "No PersistentPublicKey row matches"). Cross-check + `privateKeyKeychainIdentifier` is set for the signing key. +- ECDH check (scopes §3 non-goal): attempt a DashPay **contact request** from the + imported identity. If it fails with the seed-residency CAVEAT, file the ECDH item; + if it succeeds, the app's import wallet is resident-key and ECDH is already covered. +- Regression: a create-in-app identity still signs (unchanged path). + +## 9. Rollout / migration + +Rust change in `discover_inner` (+ optional small Swift mirror check, §7.2). No +schema/FFI signature change. + +**Backfill requires a FULL rescan from index 0**, not the default "Re-scan for +Identities" resume. `discover()` with `start_index = None` resumes at +`highest_registration_index + 1` (`discovery.rs:181-184`) and **skips** an +already-imported (broken) identity at index N — so the default re-scan does NOT heal it. +The migration must drive `start_index = Some(0)` (the FFI's +`start_index_or_neg1 = 0` cold-rescan path). Action items: +- Confirm which iOS control passes `0` (cold full rescan) vs `nil` (resume); document + the exact user action (or add a "Full rescan" affordance) that backfills. +- Alternative for an affected user: delete + re-import the wallet (resets the bucket so + index 0 is re-scanned). +Document the chosen migration action in the PR so users with an already-broken import +know how to heal it. diff --git a/docs/dashpay/KOTLIN_PLATFORM_COMPARISON.md b/docs/dashpay/KOTLIN_PLATFORM_COMPARISON.md new file mode 100644 index 00000000000..c8627cc8c79 --- /dev/null +++ b/docs/dashpay/KOTLIN_PLATFORM_COMPARISON.md @@ -0,0 +1,85 @@ +# DashPay: our Rust impl vs kotlin-platform / dashj / dash-wallet — deep comparison + +Status: **complete** — all 5 areas. Method: 5 parallel agents, each diffing our code against the +canonical Android stack: `dashpay/kotlin-platform` (`dpp` module, +`org.dashj.platform.dashpay`), `dashpay/dashj` (core crypto/keychains), and +`dashpay/dash-wallet` (the app: sync, UI, DAOs). All on `master`. Findings are +code-verified with file:line on both sides. + +**`android-dashpay` is the STALE predecessor (last push 2024-01); the live lib is +`kotlin-platform` (`org.dashj.platform:dash-sdk-*`, which dash-wallet depends on).** + +--- + +## Headline (severity-ranked) + +| # | Finding | Severity | Area | +|---|---------|----------|------| +| 1 | **`accountReference` ASK28 byte order** — we read `be(ASK[28..32])` (iOS conv.); dashj/Android reads `le(ASK[0..4])`. Proven-different values for identical inputs. | 🔴 **INTEROP-BREAK** | crypto / derivation | +| 2 | **`account'` path segment hardcoded `0'`** — key-wallet drops the account index from the friendship path; dashj derives under the counterparty's real account. Breaks when a counterparty uses account ≠ 0. | 🔴 **INTEROP-BREAK (latent)** | derivation | +| 3 | **Fetch truncates at 100, no pagination/high-water** — >100 contactRequests ⇒ newest buried permanently; dashj drains all via a `startAt` cursor loop. | 🔴 **BUG** | sync | +| 4 | **Sent payments stuck on `Pending` forever** — no `Pending→Confirmed` transition; the `contains_key` guard blocks status updates (only a *test* flips it). | 🔴 **BUG** | payments | +| 5 | **Contact-profile sync entirely absent** — we sync our *own* profile but never fetch contacts' displayName/avatar (`all_identities()` excludes contacts). | 🔴 **BUG / feature gap** | sync | +| 6 | **`update_profile` doesn't merge — wipes sibling fields** — editing one field (e.g. displayName) deletes publicMessage/avatarUrl on-platform; kotlin `Profiles.replace` read-modify-writes. | 🔴 **BUG (data loss)** | profile | +| 7 | **No incremental high-water + 10-min overlap** — re-fetch from start each sweep; lets #3 never self-heal. | 🟠 MISSING | sync | +| 8 | **`encryptedAccountLabel`: no space-padding + omitted when absent** — kotlin always pads to ≥16 chars and always emits; labels <16 chars currently **error** in our code. | 🟠 MISSING | crypto | +| 9 | **No per-contact tx-history query / no tx→contact reverse for *sent* txs** — data exists (`counterparty_id`) but no accessor; `match_in_collection` searches only receival pools. | 🟠 MISSING | payments | +| 10 | **Key selection narrower than canonical** — no AUTHENTICATION fallback on send (kotlin has one); DECRYPTION-first vs kotlin's ENCRYPTION-first. | 🟡 WORSE | crypto | +| 11 | multi-account contacts (one-request-per-direction; `accepted_accounts` unpopulated); contactInfo fetch also 100-truncated; send address-reuse if SPV drops our own broadcast. | 🟡 minor | model / sync / payments | + +### Where we are OK or AHEAD (don't "fix") +- **encryptedPublicKey**: 69-byte compact `fp‖cc‖pk` → `IV(16)‖AES-256-CBC/PKCS7` = 96 B — **byte-identical** to dashj `serializeContactPub`. +- **Friendship path geometry** (`m/9'/coin'/15'/0'/A/B/index`), sender/recipient swap (receive vs send), and **DIP-14 256-bit non-hardened CKD** (32-byte BE feed) — **match dashj byte-for-byte** for account 0. +- **ECDH** `SHA256((y&1|2)‖x)` — matches (one byte-level assumption on dashj's agreement; recommend a known-answer test to lock). +- **Send-address advancement** (`currentAddress` semantics via the SPV pipeline) and **incoming detection** — equivalent; our `reconcile_incoming_payments` recovery path is **more robust** than dashj. +- **AHEAD:** we implement **contactInfo** (kotlin/dashj have *no* ContactInfo class); our **rotation idempotency** (`newest_received_per_sender`) beats their exact-`(sender,toUserId,ref)` dedup; **account/keychain self-heal** (`collect_account_build_candidates`) ≈ their `checkDatabaseIntegrity`; our sync **re-entrancy/shutdown** discipline is stricter. + +--- + +## Area 1 — contact-request creation + crypto + +- **[INTEROP-BREAK] accountReference ASK28** (`dip14.rs:221`): ours `u32_be(ASK[28..32])>>4`; dashj `BlockchainIdentity.getAccountReference` = `wrapReversed(ASK).toBigInteger().toInt() ushr 4` = `u32_le(ASK[0..4])>>4`. The two **reference clients (iOS vs Android) genuinely disagree** on this field; we chose iOS, dashj is Android. The on-chain census should pick the canonical one (likely dashj/Android). The `version` nibble (`>>28`) interoperates; only the low-28 masked bits diverge → breaks cross-impl rotation/unmask, not basic payment (recipient disregards the ref). +- **[MISSING] encryptedAccountLabel** (`contact_request.rs:319-334`): kotlin `padAccountLabel()` pads to ≥16 chars w/ spaces and *always* emits (even empty → 16 spaces → 48 B). We make it optional + unpadded; a label <16 chars trips our own `≥48` check and errors. Fix: pad to ≥16, trim on decrypt, always emit. +- **[WORSE] key selection** (`select_recipient_key_index`, `contact_requests.rs:395`): kotlin = ENCRYPTION-first, **AUTH/HIGH fallback**; ours = DECRYPTION-first, ENCRYPTION fallback, **no AUTH fallback** → we cannot send to an identity that only has an AUTH ECDSA key, which kotlin can. (Partly a deliberate key-separation choice — product decision.) +- **[DIFFERENT-OK]** entropy/doc-id (both `generate_document_id_v0`; our old consensus bug fixed), encryptedPublicKey (byte-identical), ECDH (recommend dashj KAT), IV/AES-CBC. + +## Area 2 — sync / fetch + +- **[BUG] pagination** (`contact_request_queries.rs:65,117`): single `limit:100, start:None`, no loop. kotlin `Documents.getAll` loops `startAt = last.id` while `size >= 100`, `retrieveAll`⇒`limit(-1)`. Ordered `$createdAt ASC` ⇒ **newest** dropped past 100, permanently. +- **[MISSING] high-water + 10-min overlap** (`PlatformSyncService.kt:346-372`, `DashPayContactRequestDao.kt:50-54`): kotlin `MAX(timestamp)` per direction, rewinds 10 min for skew; we have neither. +- **[BUG] contact-profile sync absent**: `sync_profiles` iterates `all_identities()` (own + out-of-wallet only, `accessors.rs:54`), never contacts; kotlin `updateContactProfiles` batch-fetches all contacts' profiles (`Profiles.getList`, chunks of 100, `whereIn $ownerId`). We never get contacts' displayName/avatar. +- **[DIFFERENT-OK / AHEAD]** both directions (we're more rotation-robust), account self-heal (parity+), contactInfo ordering (we're ahead — they have none), cadence/re-entrancy (stricter). Latent: our contactInfo fetch also 100-truncated. + +## Area 3 — friendship key derivation / payment addresses + +- **[INTEROP-BREAK latent] account' = 0'** (key-wallet `account_type.rs:486,509`; `contacts.rs:474` `let account_index = 0`): the path's account segment is hardcoded `0'` and the `index` field is dropped. dashj `FriendKeyChain.getContactPath` uses `contact.getUserAccount()` (receive) / `getFriendAccountReference()` (send). Disjoint address spaces if a counterparty uses account ≠ 0. **Compounds #1** (even with the index wired in, the ASK28 mismatch unmasks the wrong account). Fix needs an upstream key-wallet/rust-dashcore change. +- **[MATCH]** path geometry, ordering, DIP-14 256-bit CKD, send-chain reconstruction — all byte-identical for account 0. Gap limit 10 (DIFFERENT-OK, local concern). + +## Area 4 — payments send/receive + tx↔contact + +- **[BUG] Sent status stuck Pending** (`payment.rs:87`, `payments.rs:68,153`): `new_sent`=Pending; live recorder + reconcile + send all skip existing txids (`contains_key`), and **no production path** flips Pending→Confirmed (only a test does). UI shows all sends Pending forever. dashj derives status live from `TransactionConfidence`. +- **[MISSING] tx→contact reverse for sends** (`match_in_collection`, `contacts.rs:357`): searches only `dashpay_receival_accounts`, never `dashpay_external_accounts`. dashj `getFriendFromTransaction` scans both. (Compensated for our own sends by direct `counterparty_id` recording, but a recovered/other-device send isn't classifiable.) +- **[MISSING] per-contact tx-history query** (`getContactTransactions` equiv): data exists (`PaymentEntry.counterparty_id`) but no `filter by contact` accessor; the FFI getter returns the whole flat list. +- **[DIFFERENT-OK]** send-address advancement (`next_address`/`next_unused` + SPV `mark_address_used` = `currentAddress` semantics), incoming detection (more robust w/ reconcile), idempotency. Minor: no `mark_address_used` at broadcast ⇒ address-reuse if SPV drops our own tx. + +## Area 5 — profile / contactInfo / data-model + +- **[BUG] `update_profile` is destructive — it doesn't merge** (`profile.rs:412-428`): we build a **fresh** property map from only the `Some(...)` input fields, so any field the caller leaves `None` is **dropped from the new revision and deleted on-platform**. So editing just the display name **wipes** publicMessage + avatarUrl. kotlin's live path `Profiles.replace` does read-modify-write (`profileData.putAll(currentProfile.toObject())` then overlay). We already fetch the existing doc for id+revision (`profile.rs:354-392`) — seed the map from its `properties()` first. (avatarHash/fingerprint are the one exception we preserve; displayName/publicMessage/avatarUrl are not.) **User-facing data loss; quick fix.** +- **[AHEAD] contactInfo** — confirmed: `kotlin-platform` has **no `ContactInfo` class** (only `Contact`/`ContactRequest`/`ContactRequests`); `dash-wallet` has a TODO referencing `ContactInfo.accountRef` but never built it. We're the de-facto reference. Caveat: our wire format is unverified against any other client (none exists), and `displayHidden`-as-reject-signal is ours-only. +- **[DIFFERENT-OK] relationship model — derive vs materialize:** kotlin has **no persisted "established" and no "rejected" at all** — friendship is a read-time join over the flat `dashpay_contact_request` table (`requestSent && requestReceived ⇒ FRIENDS`). We materialize `established_contacts` + incoming/sent/**rejected** maps and collapse reciprocals into one `EstablishedContact`. Both valid; ours carries richer per-contact state. +- **[MISSING] multi-account contacts** (`contact_requests.rs:323-393`): kotlin keeps **every** `(userId,toUserId,accountReference)` row (a contact on multiple accounts ⇒ multiple rows); we keep **one request per direction** and a rotation *replaces* the prior (`accepted_accounts` exists but is never populated). Fine for the single-account common case; a structural gap for simultaneous multi-account (our own comments defer it). +- **[DIFFERENT-OK] avatarHash** = single SHA-256 both sides (matches); **avatarFingerprint** dHash byte/bit layout coincidentally matches BUT pixel pipeline differs (greyscale **average vs luma-weighted**, resize filter, 9×9 vs 9×8) ⇒ fingerprints **won't be byte-identical cross-client** — that's inherent to perceptual hashing (used for Hamming distance, never equality). **Do NOT write a cross-client exact-match test on the fingerprint.** +- **[DIFFERENT-OK]** profile field set identical (displayName/publicMessage/avatarUrl/avatarHash/avatarFingerprint); signing key HIGH-or-CRITICAL (ours) ⊇ HIGH (theirs) — superset, fine. + +--- + +## Recommended fix priority + +0. **`update_profile` merge (#6)** — quick, user-facing data-loss fix: seed the new property map from the existing doc's `properties()` before overlaying inputs (read-modify-write, like `Profiles.replace`). Smallest diff, biggest immediate user impact. +1. **`accountReference` ASK28 byte order (#1)** — flip to `u32_le(ASK[0..4])>>4` to match dashj/Android (the deployed-cohort canonical), fix `unmask_account_reference` symmetrically, add a **dashj known-answer test**. Decide iOS-vs-dashj canonical explicitly (they disagree). +2. **Sync correctness (#3/#6)** — the already-drafted `SYNC_CORRECTNESS_SPEC.md` (high-water + 10-min overlap + cursor pagination, both directions). +3. **Contact-profile sync (#5)** — fetch contacts' profiles (the Friends UI has no names/avatars without it); mirror `updateContactProfiles` (batch `whereIn $ownerId`, incremental). +4. **Sent payment Pending→Confirmed (#4)** — confirm-path must update-in-place, not skip-if-present. +5. **encryptedAccountLabel padding (#7)** — pad ≥16 chars, trim on decrypt, always emit. +6. **account' path segment (#2)** — upstream key-wallet change to use the `index` field; pass the real account on registration. Track (cross-repo). +7. **ECDH KAT, per-contact tx query (#8), key-selection AUTH fallback (#9)** — lower priority / product decisions. diff --git a/docs/dashpay/NEEDS_UNLOCK_SIGNAL_SPEC.md b/docs/dashpay/NEEDS_UNLOCK_SIGNAL_SPEC.md new file mode 100644 index 00000000000..7f3e8bacee5 --- /dev/null +++ b/docs/dashpay/NEEDS_UNLOCK_SIGNAL_SPEC.md @@ -0,0 +1,348 @@ +# DashPay — Needs-Unlock / Verify-Failed UI Signal — Spec + +Source backlog item: `SIGNER_SEED_ELIMINATION_SPEC.md` §4.7 / §9-7 (MEDIUM) — +"UI marker for contacts pending an unlock-drain"; `TODO.md` Q2 follow-up +("needs-unlock / verify-failed UI signal"). + +> **Status:** DONE (`9963923e05` Rust+FFI, `841802c587` Swift). REVIEWED (4-lens: +> feasibility / scope / failure-modes / domain-fit, 2026-06-23); must-fixes folded +> in (§9). The headline design changed materially from the first draft — the count +> tracks only **account-build** ops, and the Swift surface collapsed to one +> Equatable struct. On-device (devnet paloma, iPhone 17 Pro sim): the three banner +> states all verified — "N waiting" + Unlock → "Finishing…" → cleared — and the +> banner does **not** re-trip after a full sweep cadence (the M1 regression check). +> The `seedMismatch` red banner is covered by the `verify_seed_binds` unit test + +> the scoped-catch logic (a live wrong-seed import is destructive, so not staged). + +## 1. Problem + +A seedless wallet (`WalletType::ExternalSignable`) signs DashPay crypto per-op +through the Keychain-backed `MnemonicResolverCoreSigner`. The recurring sweep +has **no signer** — when it meets a contact whose payment account can't be built +because key material is unavailable (Keychain locked / not yet unlocked this +session), it **enqueues** a `PendingContactCrypto` op (§4.6) instead of failing, +and the contact stays visible but "needs unlock to finish setup." The queue is +**drained** when the signer becomes available (unlock, or any signer-present +action). + +Two states reach the user inadequately today: + +1. **Pending account-build backlog is invisible.** `PlatformWalletInfo.pending_contact_crypto` + accumulates deferred `RegisterReceiving` / `RegisterExternal` ops (the ones + that build a contact's payment account), but nothing surfaces "some contacts + are waiting for an unlock to finish setup." The only production drain caller is + the unlock path, and it logs its result `print()`-only inside a fire-and-forget + `Task.detached` (`PlatformWalletManager.swift:531-556`). + +2. **Seed-verification failure is only weakly surfaced.** `verify_seed_binds` (run + at unlock) returns `SeedMismatch` when the resolver is mapped to the wrong + seed. The restore loop already classifies it and sets `self.lastError` + (`PlatformWalletManager.swift:419-429`) — but `lastError` is **global, + transient, and not keyed by wallet**, so a banner can't latch a per-wallet + "this wallet's seed doesn't match" state off it. (The first draft wrongly + called this `print()`-only; corrected per review.) + +### Goal + +Surface both as observable, per-wallet status the UI can render: +- **A. needs-unlock**: a live count of *deferred account-build ops* (> 0 ⇒ a + banner "N contact(s) waiting to finish setup" with an Unlock action). +- **B. verify-failed**: a per-wallet `seedMismatch` flag (a hard wrong-seed + rejection — loud, actionable), distinct from a transient verify error. + +### Non-goals + +- No new on-chain artifact; no data-contract change. +- **No change to the enqueue / drain / classification logic itself** (§4.6/§4.7 + already landed and tested) — this is purely a read/observe + UI surface. (This + is why the M1 fix counts a *subset* of ops rather than re-gating the enqueue.) +- Not the §6b upstream per-wallet restore (`LOAD_UNIMPLEMENTED`). We read the + in-memory queue, which converges on its own (§2), so we don't depend on it. + +## 2. Chosen approach — and why it diverges from "mirror `paymentChannelBroken`" + +The TODO says "surface … through persistence the way `paymentChannelBroken` +already is." Research traced that pattern end-to-end and it is the **wrong fit**. +`paymentChannelBroken` is a **durable per-contact attribute** of an *established* +contact: Rust `EstablishedContact` field → `ContactChangeSet.established` → SQLite +`contacts.payment_channel_broken` column → `ContactRequestFFI` field on both rows +→ SwiftData `PersistentDashpayContactRequest` column → per-row UI icon. It is +permanent state that must survive restart. + +Neither needs-unlock signal is durable: + +- **The account-build count is live, self-converging operational state.** It lives + in-memory on `PlatformWalletInfo.pending_contact_crypto` (`platform_wallet.rs:55`), + populated by the signerless sweep's enqueue path. Crucially, **a cold restart + restores no wallet from SQLite at all** — `SqlitePersister::load()` returns empty + `wallets` (`persister.rs:909-946`, `LOAD_UNIMPLEMENTED = ["ClientStartState::wallets"]`); + the wallet returns only by **re-import**, which re-syncs contact requests from + scratch (cursors reset) and re-enqueues the same account-build ops + (`contact_requests.rs:1115-1156`). So the count converges with zero special + handling — we do **not** need (and cannot use, today) the persisted-queue + restore. Persisting it the `paymentChannelBroken` way would duplicate + self-converging state, depend on the blocked-upstream restore, and risk a + stale-positive banner after the user already unlocked. **Wrong model.** + + (Side note: `platform_wallet.rs:54-55`'s doc comment claims the queue is + "restored at load," which is doc-rot — `load.rs:104` restores `Vec::new()`. + Fix the comment in passing.) + +- **The verify outcome is per-session.** `verify_seed_binds` needs the signer, so + it's only evaluable at unlock; it's re-evaluated each session and a persisted + value goes stale the moment the user fixes the Keychain mapping. + +The **correct, simpler** fit reuses two patterns already in the codebase: + +- **Count (A)** → a pollable FFI getter over in-memory state, read by the existing + ~1 Hz `startProgressPolling()` loop into an inequality-gated `@Published` + property — like `isDashPaySyncing()` / `spvProgress` + (`PlatformWalletManager.swift:967-997`). (One caveat: this getter is *per + wallet*, so the poll is O(wallets)/tick, not the O(1) of `isDashPaySyncing()`. + Cheap for 1–2 wallets; not literally identical.) +- **Verify (B)** → a per-wallet `@Published` flag set from the verify FFI result + at the unlock call sites (which already exist). + +This is a **scope reduction** vs the literal TODO note: no SQLite column, no +changeset field, no SwiftData migration, no per-row plumbing. One small Rust read +method + one FFI getter + Swift observable wiring + one banner. + +## 3. Granularity & semantics + +- **Wallet-scoped count (not per-contact icon).** §4.7 phrases it as a marker + "for deferred contacts," but the remedy (a Keychain unlock) is **wallet-global** + — `drain_pending_contact_crypto` drains the whole wallet's queue + (`contact_requests.rs:1308`). A single actionable banner matches the user's next + step better than per-contact icons, and the TODO asks for a *count*. Per-contact + icons are deferred (YAGNI). +- **Count is a wallet-scoped upper bound.** The queue is keyed + `(owner_identity_id, contact_id)`, so a wallet with multiple identities + aggregates across them; and an op that will resolve to `Permanent` + (channel-broken) on the next drain is included until drained. Banner copy must + therefore be honest ("N contact(s) waiting to finish setup"), **not** promise + "N will succeed on unlock." +- **Count excludes `ContactInfoDecrypt` (the M1 fix).** That op is re-enqueued + *unconditionally every sweep* (no already-decrypted gate, `contact_info.rs:311`), + so it is structurally always ≥1 and would make the banner re-trip ~15s after + every unlock forever. Only `RegisterReceiving` / `RegisterExternal` converge to + 0 once their external account is built (`contact_requests.rs:1137-1144`); the + count tracks **only those**. A contact whose contactInfo can't decrypt but whose + payment account *is* built is not "needs unlock" for payment purposes (the + account-build ops are what gate payability). + +## 4. Interface / data flow + +### 4.1 Rust — read method (rs-platform-wallet, `network/contact_requests.rs`, next to `drain_pending_contact_crypto`) + +```rust +/// Count of deferred **account-build** contact-crypto ops queued for this +/// wallet (in-memory): the `RegisterReceiving` / `RegisterExternal` ops that +/// build a contact's payment account and need a signer unlock to complete. +/// +/// `ContactInfoDecrypt` is intentionally excluded: it is re-enqueued every +/// signerless sweep (no already-decrypted gate), so it is structurally always +/// present and is not an actionable backlog. Account-build ops converge to 0 +/// once drained (candidate selection skips contacts whose external account +/// already exists), so this count is a true "waiting for unlock" indicator. +pub async fn pending_contact_crypto_count(&self) -> usize { + use crate::changeset::PendingContactCryptoOp; + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .map(|info| { + info.pending_contact_crypto + .iter() + .filter(|e| { + matches!( + e.op, + PendingContactCryptoOp::RegisterReceiving + | PendingContactCryptoOp::RegisterExternal { .. } + ) + }) + .count() + }) + .unwrap_or(0) +} +``` + +Unit test (non-tautological — pins the M1 exclusion): enqueue a mix of +`RegisterReceiving`, `RegisterExternal`, and `ContactInfoDecrypt` into a test +`PlatformWalletInfo`; assert the count == the account-build ops only. + +### 4.2 FFI — getter (rs-platform-wallet-ffi/src/dashpay.rs), mirroring the drain FFI minus the signer + +```rust +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_pending_contact_crypto_count( + wallet_handle: Handle, + out_count: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(out_count); + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.pending_contact_crypto_count().await }) + }); + let count = unwrap_option_or_return!(option); // unknown handle -> NotFound + unsafe { *out_count = count as u32; } + PlatformWalletFFIResult::ok() +} +``` + +FFI tests: null `out_count` → `ErrorNullPointer`; unknown handle → `NotFound`. +(`blocking_read` is an alternative to `block_on_worker`, per +`platform_wallet_get_managed_identity:78`, but `block_on_worker` matches the drain +and keeps the read method `async`/unit-testable; keep it for consistency.) + +### 4.3 Swift — one Equatable per-wallet status struct (PlatformWalletManager, `@MainActor`) + +Collapse to a single snapshot per wallet (not parallel dicts): + +```swift +public struct DashPayUnlockStatus: Equatable { + public var pendingAccountBuilds: UInt32 = 0 + public var seedMismatch: Bool = false + public var draining: Bool = false +} +@Published public private(set) var dashPayUnlockStatus: [Data: DashPayUnlockStatus] = [:] +``` + +**Count (A)** — in the poller, per wallet, inequality-gated *per key* (the struct +is `Equatable`): + +```swift +for (walletId, _) in self.wallets { + if let n = try? self.pendingAccountBuildCount(for: walletId) { + var s = self.dashPayUnlockStatus[walletId] ?? .init() + if s.pendingAccountBuilds != n { s.pendingAccountBuilds = n; self.dashPayUnlockStatus[walletId] = s } + } +} +// prune ghost keys (M2): self.dashPayUnlockStatus.keys not in self.wallets -> removeValue +``` + +**Verify (B)** — set `seedMismatch` from the **verify FFI result code only**, at +both unlock sites (the M3 fix: do *not* wrap a broad catch around +`unlockWalletFromKeychain`, whose `walletId.count == 32` guard at :493 also throws +`.invalidParameter`). In `unlockWalletFromKeychain` after the verify `.check()`: +`.invalidParameter` ⇒ `seedMismatch = true`; success ⇒ `false`; other ⇒ leave +unchanged (transient). Mirror in the restore-loop catch (`:419-429`). + +**Drain (C)** — the drain stays fire-and-forget, but stop the `print()`-only +swallow and add the in-flight guard (failure-modes S1/S3, cross-actor M1): + +```swift +// before launching: self.dashPayUnlockStatus[walletId]?.draining = true (on MainActor) +Task.detached(priority: .utility) { + let result = ... drain ... + await MainActor.run { + self.dashPayUnlockStatus[walletId]?.draining = false + if /* failed */ { self.lastError = error } // no longer print-only; no new flag + } +} +``` + +No separate `lastDrainFailed` dict: a stuck drain leaves `pendingAccountBuilds > 0` +(banner stays), and the rare cleared-but-failed edge surfaces via `lastError`. + +**deleteWallet** — `dashPayUnlockStatus.removeValue(forKey: walletId)` (M2). + +**Unlock entry point** — `unlockWalletFromKeychain(_:)` is already `public` and +takes a `ManagedPlatformWallet` (resolve via `walletManager.wallet(for: walletId)`, +as `ContactsView.swift:159` does). It is synchronous + throwing and kicks the +drain into a detached task, so the button calls `try? walletManager +.unlockWalletFromKeychain(wallet)` and lets the poller/`draining` reflect the +outcome (no spinner-until-drained). + +### 4.4 UI — banner (SwiftExampleApp, `DashPayTabView`) + +Host: `DashPayTabView.content`, between `dashPayBalanceRow` and the segmented +Picker (domain S2 — covers both Contacts and Requests; has `activeIdentity` + +`identity.wallet?.walletId` in scope). It is a **net-new small component** (no +reusable banner exists; only the per-row `paymentChannelBroken` icon — domain S3), +reusing the `exclamationmark.triangle.fill` + `.orange`/`.red` styling. Reads +`walletManager.dashPayUnlockStatus[walletId]`, priority: + +1. `seedMismatch` → **red** "Seed verification failed — this wallet's Keychain + seed doesn't match. DashPay signing is disabled." (precondition; intentionally + subsumes the count — N1.) +2. else `draining` → **orange** "Finishing contact setup…" (no action; the + in-flight guard that prevents a double-drain — S1/S3). +3. else `pendingAccountBuilds > 0` → **orange** "N contact(s) waiting to finish + setup" + **Unlock** button. +4. else → nothing (`.unknown`/healthy renders no banner). + +UI-only SwiftUI; verified on-device per the CLAUDE.md UI exception. + +## 5. Failure modes (post-review) + +- **Permanent false "needs unlock" (M1) — FIXED** by counting only account-build + ops; `ContactInfoDecrypt` excluded. Without this the banner re-trips every ~15s. +- **Ghost banner after wipe (M2) — FIXED** by purging `dashPayUnlockStatus` in + `deleteWallet` + pruning poller keys (walletIds are deterministic from the + mnemonic, so a reused id would otherwise inherit a stale banner). +- **False wrong-seed banner (M3) — FIXED** by setting `seedMismatch` from the + verify FFI result only, not a broad call-site catch. +- **Cross-actor `@Published` write (M1-conc) — FIXED** by `await MainActor.run` + in the detached drain. +- **Double-drain / flicker (S1/S3) — MITIGATED** by the `draining` guard (button + disabled, "Finishing…" shown) for the whole multi-contact drain window. +- **Count is an upper bound (S2 + multi-identity M3)**: includes poison/Permanent + ops and sibling identities; copy says "waiting," not "will succeed." +- **Cold restart**: count reads 0 until re-import re-syncs and re-enqueues + (convergent; the queue restore is blocked upstream and intentionally unused). +- **Watch-only wallet**: `unlockWalletFromKeychain` early-returns (`hasMnemonic` + false), so `seedMismatch` stays false and no banner shows — correct. + +## 6. Test / verification plan + +- **Rust unit** (`pending_contact_crypto_count`): mixed-op queue → count equals + the account-build ops only (pins the M1 exclusion; fails if `ContactInfoDecrypt` + is counted). +- **FFI** (`dashpay.rs` tests): null `out_count` → `ErrorNullPointer`; unknown + handle → `NotFound`; seeded queue → correct filtered count marshalled. +- **Swift build**: `build_ios.sh` (xcframework + app) green. +- **On-device** (UI exception): seedless wallet, locked → sweep discovers an + inbound contact needing an external account → banner "1 contact waiting to + finish setup"; tap Unlock → "Finishing…" → banner clears and does NOT re-trip + after the next sweep (the M1 regression check). Wrong-seed import → red banner. +- **Acceptance**: the drain catch no longer `print()`-only; verify sets + `seedMismatch` at both unlock sites. + +## 7. Alternatives rejected + +- **Full `paymentChannelBroken`-style persistence**: persists ephemeral, + self-converging state; depends on the blocked-upstream restore (not buildable + today); risks staleness. §2. +- **Count all queue entries** (`len()`): broken — `ContactInfoDecrypt` re-enqueues + every sweep → permanent false positive. §3 / M1. +- **Re-gate the contactInfo enqueue to fix the count**: out of scope (changes the + drain/enqueue logic, a non-goal); counting the right subset is the surgical fix. +- **4-state `SeedVerification` enum + `lastDrainFailed` dict + 3 parallel dicts**: + over-modeled — 3 enum cases had no UI consumer, the drain flag duplicates the + count, parallel dicts break the one-snapshot-per-wallet convention. Collapsed to + one Equatable struct with `seedMismatch` + `draining`. +- **Per-contact needs-unlock icon**: deferred — the remedy is wallet-global. §3. +- **Surface verify via `lastError` only**: global/transient/unkeyed; can't latch a + per-wallet banner. + +## 8. Layer-by-layer change list + +| Layer | File | Change | +|---|---|---| +| Rust read | `rs-platform-wallet/src/wallet/identity/network/contact_requests.rs` (next to `drain_pending_contact_crypto`) | `pending_contact_crypto_count(&self) -> usize` (account-build ops only) + unit test | +| Rust doc | `rs-platform-wallet/src/wallet/platform_wallet.rs:54-55` | fix the "restored at load" doc-rot | +| FFI | `rs-platform-wallet-ffi/src/dashpay.rs` | `platform_wallet_pending_contact_crypto_count` + tests | +| Swift wrap | `swift-sdk/.../PlatformWalletManager.swift` | `pendingAccountBuildCount(for:) throws -> UInt32` FFI wrapper | +| Swift observe | `swift-sdk/.../PlatformWalletManager.swift` | `DashPayUnlockStatus` struct + `@Published dashPayUnlockStatus`; poller line + prune; verify publish at 2 sites; drain `draining` guard + MainActor hop; `deleteWallet` purge | +| Swift UI | `SwiftExampleApp/.../DashPay/DashPayTabView.swift` (+ small banner view) | banner between balance row and Picker; Unlock action | + +## 9. Review resolutions (4-lens, 2026-06-23) + +- **Feasibility (M1, critical):** count account-build ops only; `ContactInfoDecrypt` + re-enqueues every sweep. Corrected the self-heal rationale (re-import, not SQLite + restore) and noted the `platform_wallet.rs:54-55` doc-rot + the O(wallets) poll. +- **Scope:** confirmed poller-over-persistence; fixed the overstated §1 verify + premise; collapsed the 4-state enum to `seedMismatch`; dropped `lastDrainFailed`. +- **Failure-modes:** M1 cross-actor write, M2 ghost banner, M3 false-mismatch, + S1/S3 drain races (→ `draining` guard), S2 upper-bound copy. +- **Domain-fit:** one Equatable struct (M1/M2), wallet-vs-identity scoping in copy + (M3), `DashPayTabView` host + net-new banner (S2/S3), verify publish at both + unlock sites (S4). diff --git a/docs/dashpay/QR_AUTO_ACCEPT_RESEARCH.md b/docs/dashpay/QR_AUTO_ACCEPT_RESEARCH.md new file mode 100644 index 00000000000..ca68b08c2cd --- /dev/null +++ b/docs/dashpay/QR_AUTO_ACCEPT_RESEARCH.md @@ -0,0 +1,120 @@ +# DashPay QR Auto-Accept — Research Findings (DIP-15 + reference-client audit) + +Date: 2026-06-24. Scope: should we wire the dormant `autoAcceptProof` / +`auto_accept.rs` into a QR-based contact flow? Sources: canonical DIP-0015 + +DIP-0013, `dashpay/dashj`, `dashpay/android-dashpay`, `dashpay/dash-wallet` +(GitHub code search + raw reads), and our in-repo code. + +## Verdict (read this first) + +**QR auto-accept via DIP-15 `autoAcceptProof` is unimplemented in every reference +client — it is spec-only, dormant everywhere, with no interoperable counterparty.** +Building it would mean inventing an iOS-only convention that no other Dash client +verifies. **Recommendation: do not build QR auto-accept now.** If the underlying +goal is "scan/tap to add or onboard a friend," the real shipped, interoperable +feature is **Invitations** (a *separate* mechanism — see §5), not `autoAcceptProof`. + +## 1. Reference-client status — dormant everywhere + +| Client | `autoAcceptProof` status | +|---|---| +| `dashpay/dashj` (L1/SPV + HD wallet) | **Zero references.** No `ContactRequest` model at all — DashPay docs aren't in dashj. The payment-channel path `9'/5'/15'` *is* implemented (`FriendKeyChain`); the auto-accept path `16'` is not. | +| `dashpay/android-dashpay` (the Kotlin DashPay SDK) | Field **defined** (`ContactRequest.kt`) + an unused builder `autoAcceptProof(...)` with **0 callers**. `ContactRequests.create()` never sets it. No signing, no verification, no auto-accept-on-receive (no `accept` method exists; `watchContactRequest` only polls + callbacks). | +| `dashpay/dash-wallet` (Android app) | Field is a **dormant Room DB pass-through** (`DashPayContactRequest.kt` + migrations 13–18): read from the inbound doc, forwarded to the builder, **never constructed, never used to skip approval**. The QR scanner (`ScanActivity` → `InputParser`) only handles payments / WIF sweep / raw tx — it **cannot add a contact**. Adding a contact is username-search → manual send → manual accept (acceptance = sending a reciprocal request). | + +**Interop implication:** sending `autoAcceptProof` gains nothing (no client verifies +it); omitting it costs nothing (it's optional, the reference SDK never sets it). An +iOS implementation would only ever auto-accept against *another copy of our own SDK*. + +## 2. What DIP-15 actually mandates vs leaves open + +**Mandated (wire-fixable):** +- `autoAcceptProof` is an optional `contactRequest` byteArray, **38–102 bytes** + (matches our `packages/dashpay-contract/schema/v1/dashpay.schema.json`, not in `required`). +- **QR key blob** (the `dapk` URI param): `key type (1) | timestamp (4) | key size (1) | key (32–64)`. +- **On-document proof blob**: `key type (1) | key index (4) | signature size (1) | signature (32–96)`. +- **Derivation path**: `m / 9' / 5' / 16' / timestamp'` — feature fixed at `16'`, + the final hardened index **is the expiry timestamp** (≤ 2^31−1 → bounded ~2038). +- **Signed pre-image content**: `$ownerId + toUserId + accountReference`. +- **URI scheme** (BIP21/BIP72 extension): `du` = username, `dapk` = the auto-accept + key blob. E.g. `dash:?du=bobspizza&dapk=…` (contact) or with `amount=…` (merchant). + +**Left implementation-defined (the spec is silent — security-critical half):** +- The **recipient-side verification algorithm** (the whole thing). +- The **signature scheme** + the `key type` byte's meaning (ECDSA vs BLS not enumerated). +- The **exact serialization** of the signed pre-image (raw concat vs hashed; field + encodings; endianness of `accountReference`). ← byte-level interop is undefined. +- Whether **expiry is enforced** (only "essential to verify" is stated, not a reject rule). +- Any **nonce / one-time-use / replay** protection (none; the only structural defense + is that `($ownerId, toUserId, accountReference)` is the doc's immutable primary key). + +## 3. The trust model (corrected) + +Earlier reasoning here assumed the *counterparty* signs the proof, making our +self-deriving `verify` look broken. The DIP-15 mechanism is the opposite and our +self-verify is actually **correct**: + +- The **QR shower** (merchant/host, "Bob") derives an auto-accept key at + `m(Bob)/9'/5'/16'/timestamp'` and **puts the 32-byte *private* key in the QR** + (`dapk`; size field 32 ⇒ a private key, handed out deliberately, expiry-bounded). +- The **scanner** signs `$ownerId ‖ toUserId ‖ accountReference` with that handed-out + key and attaches the proof to the contact request they send to Bob. +- **Bob (recipient) re-derives the same key from his own seed** (he knows `timestamp` + from the proof), gets the pubkey, checks the signature → **self-verification against + his own key is the intended model.** So `verify_auto_accept_proof(wallet, …)` is right. + +Security note: because the QR hands out a usable private key, *anyone* who sees the +QR before it expires can get auto-accepted as Bob's contact. That's acceptable for +the merchant/proximity use case (low stakes: it only establishes a contact channel), +but it's a deliberate trade-off, not an oversight. + +## 4. Our current Rust code vs DIP-15 + +`packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs` (tested, dormant): +- ✅ Proof **structure** matches (`key_type | index | sig_size | sig`). +- ✅ Derivation path `m/9'/coin'/16'/timestamp'` matches. +- ✅ `verify` (self-derive from the recipient's wallet) matches the corrected model. +- ⚠️ **Signed bytes**: we use `SHA256($ownerId ‖ $toUserId ‖ accountReference_LE)`. + DIP names the same three fields but does **not** specify hashing/encoding, so this + is *our* convention — unverifiable against a reference (none exists). +- ❌ **Scanner-side signing is the wrong actor**: `generate_auto_accept_proof` derives + the key from a full wallet seed (models *Bob*), but per spec the *scanner* signs with + the loose QR-provided key. There is no "sign-with-provided-32-byte-key → proof" fn. +- ❌ **QR encode/decode** (`dapk`/`du` URI, the key blob layout) doesn't exist. +- ❌ **Expiry enforcement** (reject when `now > timestamp`) isn't done. +- ❌ **No FFI** exposes generate/verify; the accept path loads `auto_accept_proof` from + chain but never verifies or acts on it. + +So even the "we already have the crypto" framing is partial: the crypto we have models +the wrong actor for the scanner side, and the QR/URI + expiry + FFI + accept-hook are +all absent. + +## 5. Invitations ≠ auto-accept (the real shipped feature) + +The shipped "scan/link to bring a friend in" feature is **Invitations**, a *separate* +subsystem with no shared code: +- Protocol primitive: **DIP-0013 sub-feature `3'`** ("Identity Invitation Funding keys"). +- dash-wallet impl: a **deep link** `dashpay://invite?du=…&assetlocktx=…&pk=&islock=…` + (web fallback `https://invitations.dashpay.io/applink?…`), handled by + `InviteHandlerActivity` — **not** the QR scanner. Claiming rebuilds the + `AssetLockTransaction`, decodes the embedded WIF, and calls + `initializeAssetLockTransaction(...)` to **register a brand-new identity** for someone + who has no Dash and no identity. +- Purpose: **onboarding** (fund + bootstrap an identity), not contact auto-acceptance. + +If the product goal is "let an existing user invite a friend who isn't on DashPay yet," +Invitations is the interoperable target — a much larger feature (L1 asset-lock funding + +identity registration from an embedded WIF), separate from this `auto_accept.rs` work. + +## 6. Recommendation + +1. **Do not wire QR auto-accept now.** It has no interoperable counterparty (every + reference client leaves it dormant), the spec leaves the security-critical half + undefined, and our crypto models the wrong actor for the scanner side. Keep + `auto_accept.rs` as-is (tested, dormant); update the TODO to reflect "spec-only, + no interop — deprioritized," not "ready to wire." +2. **If contact-onboarding is wanted**, scope **Invitations** (DIP-13 `3'`, + `dashpay://invite` + AssetLock) as its own feature — that's what ships and interops. +3. Either way, the keep-it-honest fix already noted in `SPEC.md` Part 8.5 stands: *if* + anything ever acts on `autoAcceptProof`, it MUST call `verify_auto_accept_proof` + first. Nothing acts on it today, so there's nothing to gate yet. diff --git a/docs/dashpay/QR_AUTO_ACCEPT_SPEC.md b/docs/dashpay/QR_AUTO_ACCEPT_SPEC.md new file mode 100644 index 00000000000..83522439848 --- /dev/null +++ b/docs/dashpay/QR_AUTO_ACCEPT_SPEC.md @@ -0,0 +1,270 @@ +# DashPay QR Auto-Accept (DIP-15) — Implementation Spec + +Decision (2026-06-24): build the DIP-15 `autoAcceptProof` QR flow, **faithful to the +DIP-15 wire formats** so we are a correct reference implementation. Research (incl. the +finding that no reference client implements this today, so it is iOS-first / convention- +setting) is in `QR_AUTO_ACCEPT_RESEARCH.md`. Invitations (DIP-13) are queued next. + +> **Status:** IMPLEMENTED (2026-06-24) across Rust + FFI + Swift; `build_ios.sh` green, +> platform-wallet 299 + ffi 117 tests green. REVIEWED (4-lens: DIP-fidelity / security / +> feasibility / scope) and revised — §10. The first draft's §4 was materially wrong +> (verify can't use `&Wallet` in the seedless drain; the drain lacks the identity signer; +> the sweep parser drops the proof) — all fixed. **Owner decisions:** TTL = 1h fixed; +> auto-accept = always automatic; whole feature in one pass; DIP-literal HD-derived owner +> key (scoped raw-key export). **On-device:** My-QR UI + DPNS-name guard verified; the full +> QR-generate→scan→auto-accept loop is pending a DPNS-named *local* identity (the available +> devnet wallets have on-chain names not cached in `PersistentIdentity.dpnsName`). Follow-up +> (P3): resolve the owner's DPNS name on-chain in `build_auto_accept_qr` when the local +> field is empty. + +## 1. Problem & goal + +DashPay contact establishment is two manual taps. DIP-15 defines an optional +`autoAcceptProof` so a party can pre-authorize automatic acceptance — the canonical use +case is a **merchant / in-person QR**: show a QR, the scanner sends a contact request that +the owner's client auto-accepts with no manual tap. The proof crypto exists and is +unit-tested (`auto_accept.rs`) but is **dormant** — nothing generates, verifies, or acts +on it. Goal: wire the full three-role flow, end to end (Rust + FFI + Swift + on-device). + +### Non-goals +- Not Invitations (DIP-13 `dashpay://invite` + AssetLock onboarding) — separate, queued next. +- No Android interop today (no reference client verifies the proof); iOS-first. We still + follow DIP-15 byte layouts so a future client can interop. +- No new on-chain artifact beyond the already-defined optional `autoAcceptProof` field. +- No `di=` identity-id URI fallback in v1 (DIP uses `du`; require a DPNS name — §9). +- No TTL picker, no opt-in toggle (always automatic) in v1 (§9). + +## 2. The DIP-15 model — three roles + +1. **Owner (QR shower, "Bob").** Derives an auto-accept key at `m/9'/5'/16'/expiry'`, + embeds the **private key + expiry** in a QR (`dash:?du=&dapk=`), + shows it. (`expiry = now + 1h`.) +2. **Scanner ("Carol").** Scans, resolves `du`→Bob's identity, decodes `dapk`→(private key, + expiry), derives her friendship `accountReference` to Bob, **signs `Carol.$ownerId ‖ + Bob.toUserId ‖ accountReference` with the handed key**, and sends a contactRequest to + Bob carrying that proof. +3. **Owner receives + auto-accepts.** Bob's client (at a signer-present drain) verifies the + proof against **his own** re-derived auto-accept **public** key and, if valid and + unexpired, **auto-accepts** (sends the reciprocal) with no manual tap. + +Why the scanner signs (not the owner): the signed message includes the scanner's +`$ownerId`, unknown at QR-create time — so the owner delegates signing via the (expiry- +bounded) private key. This per-sender binding means a leaked proof can't be replayed by a +*different* sender. + +## 3. DIP-15 wire formats — normative-for-us + +These are wire-faithful to DIP-15 (fidelity review: byte-for-byte match). Where DIP-15 is +silent, the value below is **normative for our implementation** — a future interop client +MUST match it or verification silently fails. + +**Auto-accept key blob** (`dapk` value), 38 bytes for ECDSA: + +| field | size | value | +|---|---|---| +| key type | 1 | `0x00` (ECDSA_SECP256K1) | +| timestamp/expiry (= derivation index) | 4 | u32, **big-endian** *(DIP-silent → normative)* | +| key size | 1 | `0x20` (32) | +| key | 32 | secp256k1 **private** key | + +**Proof blob** (`autoAcceptProof` field), 70 bytes for ECDSA, 38–102 range: + +| field | size | value | +|---|---|---| +| key type | 1 | `0x00` | +| key index (= expiry, same value as the blob) | 4 | u32, **big-endian** | +| signature size | 1 | `0x40` (64) | +| signature | 64 | compact ECDSA | + +**Signed message** *(DIP names the fields; hashing/encoding DIP-silent → normative)*: +`SHA256($ownerId(32) ‖ toUserId(32) ‖ accountReference(4, little-endian))`, where +`$ownerId` = the contactRequest **sender (scanner)**, `toUserId` = the QR **owner**, and +`accountReference` is the **raw masked u32** the contactRequest carries (`version<<28 | +masked_index`). Matches the existing `auto_accept.rs::build_message_hash`. **Security pin +(§6):** the verifier MUST bind `$ownerId` to the **consensus-authenticated document owner +id** (`doc.owner_id()`), never a self-reported field. + +**Derivation path**: `m / 9' / 5'(mainnet, else 1') / 16' / expiry'`, all hardened; `expiry` +≤ 2^31−1 (hardened-index bound, ~year 2038 — reject at encode time). Matches code. + +**URI**: `dash:?du=&dapk=` (contact-only). Matches the +DIP-15 example. No `di=` fallback in v1. + +## 4. Seedless integration (the corrected crux) + +Our wallets are `ExternalSignable` — no seed in Rust; key material is reachable only via +the Keychain resolver/provider. The background sweep is **signerless**. Both verify +(needs the owner's auto-accept key) and auto-accept (sends a signed state transition) need +key material, so **neither runs in the sweep** — they ride the deferred-crypto queue + +the signer-present drain. The first draft got the mechanics wrong; corrected: + +### 4.1 Sweep (signerless) — read the proof, enqueue, bounded +- **FIX (feasibility #2):** `parse_contact_request_doc` must read + `props.get("autoAcceptProof")` into the parsed `ContactRequest` (today it's hard-coded + `None`, so the proof is dropped before the queue). Mirror the outgoing reader. +- After `add_incoming_contact_request`, if the request carries an `autoAcceptProof` that + passes a cheap **structural pre-check** (length 38–102, key-type `0x00`), enqueue + `PendingContactCryptoOp::AutoAccept { sender_id }` (dedup key `(owner, sender, AutoAccept)`). +- **DoS bound (security #4):** cap queued `AutoAccept` ops per owner (constant, e.g. 64); + beyond the cap, skip enqueue (the request is still manually acceptable — nothing lost). + Log the drop (no silent cap). + +### 4.2 Drain (signer present) — needs BOTH signers +- **FIX (feasibility #3 / scope M1):** the drain needs the identity `Signer` + (to send the reciprocal) **and** the `ContactCryptoProvider`. Thread a `signer` into + `drain_pending_contact_crypto` and add a `signer_handle` to the drain FFI (matching the + send/accept FFIs). Existing arms ignore it (additive bound). **Note:** the drain FFI is + the same one `unlockWalletFromKeychain` calls (needs-unlock work) — that call site now + passes the Swift `KeychainSigner` too. +- Per `AutoAccept` entry, in order: + 1. **Local verify FIRST, before any network fetch** (security #4 — anti-DoS): build the + path `m/9'/coin'/16'/expiry'` (expiry from the proof header), derive the owner's + auto-accept **public** key via `provider.receiving_xpub(&path).public_key` (**FIX + feasibility #4** — verify needs only the pubkey; no `&Wallet`), then + `verify_auto_accept_proof_with_pubkey(pubkey, proof, sender_id = request.sender_id + (= doc.owner_id), recipient_id = self_identity, account_ref = request.account_reference)`. + 2. **Expiry check** against the **same** timestamp that keyed verification + (`now > expiry → reject`). + 3. If valid + unexpired → `accept_contact_request_with_external_signer(request, signer, + provider)` (sends the reciprocal; idempotent — adopts if already reciprocated). +- **Verdict mapping (security #3):** invalid signature / wrong params / expired / + out-of-range index (the `Err` from path derivation) ⇒ **permanent: clear the entry** + (the request falls back to a normal manual-acceptable pending request). Signer/network + unavailable ⇒ **transient: leave queued** for the next drain. Never `mark_channel_broken` + (there's no channel yet). + +Consequence: auto-accept completes at the owner's next signer-present moment (unlock or any +DashPay action), not instantly in the background. Consistent with the seedless model. + +## 5. Interface / data flow per layer + +### 5.1 Rust — `auto_accept.rs` (extend; keep existing tested fns) +- KEEP `derive_auto_accept_private_key(wallet, network, expiry)` (owner, QR-create). +- ADD `encode_auto_accept_key_blob(secret_key, expiry) -> Vec` / + `decode_auto_accept_key_blob(&[u8]) -> Result<(SecretKey, u32)>` (38-byte `dapk`). +- ADD `sign_auto_accept_proof(secret_key, scanner_id, owner_id, account_ref, expiry) -> Vec` + — scanner signs with the **handed** key. Message bytes = `scanner_id ‖ owner_id ‖ + account_ref(LE)` (the existing `build_message_hash`); a doc-comment ties the param names + to DIP roles (**scope M2** — the current `generate` models the owner as `sender_id`, + the opposite; don't invert at wiring). +- ADD `verify_auto_accept_proof_with_pubkey(pubkey, proof, scanner_id, owner_id, account_ref) -> bool` + — pure, no wallet (the drain's verify path). +- ADD `auto_accept_proof_expiry(proof) -> Option` and fold the expiry check into the + acceptance entry point — **do not** expose a public bare `verify` that returns `true` + for an expired proof (**security #2** foot-gun). Keep `verify_auto_accept_proof(wallet,…)` + for owner-side tests only. +- ADD a URI codec `encode_dashpay_contact_uri(username, key_blob)` / + `parse_dashpay_contact_uri(&str) -> Result<(username, key_blob)>` (pure, testable). +- Refactor `generate_auto_accept_proof` to `derive + sign` (test/convenience). +- Remove the stale `// TODO: Where and how we use these helpers?` and fix the docstring + that references a now-real `verify_auto_accept_proof_with_pubkey` (**scope N3**). + +### 5.2 Rust — changeset.rs + contact_requests.rs (flow) +- `PendingContactCryptoOp::AutoAccept { sender_id }` + `PendingContactCryptoKind::AutoAccept` + — 9 sites (feasibility #1 change-list): enum, kind, `kind()`, storage `KIND_LABELS`, + `kind_db_label`, the `kind_labels_match_enum` test, the drain's exhaustive `match`, and + `count_account_build_ops` (decide inclusion — **yes**, so the needs-unlock banner counts + pending auto-accepts; reword the banner copy, **scope S3**). +- `parse_contact_request_doc` reads `autoAcceptProof` (§4.1). +- `sync_contact_requests` enqueues `AutoAccept` (bounded) when a proof is present. +- `drain_pending_contact_crypto` gains the `signer` param + the `AutoAccept` arm (§4.2). +- Scanner send reuses `send_contact_request_with_external_signer(..., auto_accept_proof)` + (already threaded). **scope M3:** the scanner must derive its `accountReference` first + (in-signer, masked over the friendship xpub) and sign the proof over that **exact** value + before broadcast — test that the signed `accountReference` equals the document's. +- DPNS resolve: `IdentityWallet::resolve_name(&str) -> Option` (feasibility #5; + not `search_names`). + +### 5.3 FFI (rs-platform-wallet-ffi) +- `platform_wallet_build_auto_accept_qr(wallet, identity_id, out_uri…)` — owner: resolve + the wallet's DPNS name (error if none), `expiry = now + 3600`, derive the key, build the + `dash:?du=…&dapk=…` URI; return it. (Single Rust entry — no decisions in Swift.) `now` is + passed in from Swift (FFI can't read the clock deterministically) or read via a host hook. +- `platform_wallet_send_contact_request_from_qr(wallet, signer, core_signer, uri, out…)` — + scanner: parse URI → resolve `du` → decode `dapk` → (derive accountRef, sign proof) → send + the contactRequest with the proof. One call. +- `platform_wallet_drain_pending_contact_crypto` gains `signer_handle: *mut SignerHandle` + (the identity signer) alongside the existing `core_signer_handle` (§4.2). + +### 5.4 Swift (SwiftExampleApp) +- **My QR** (net-new, `DashPayProfileView`): a "Show my QR" affordance rendering the URI + from `build_auto_accept_qr` via the existing `generateQRCode` helper. +- **Scan** (net-new entry in the DashPay tab toolbar): present `QRScannerView`; add a new + parse branch + result type (`ScannedContact{username, keyBlob}`) to `QRPayloadParser` + (the existing `ScannedPayment` path doesn't fit a no-address URI — **scope N2**), route to + `platform_wallet_send_contact_request_from_qr`. +- **Drain call-site:** `unlockWalletFromKeychain` now passes the `KeychainSigner` to the + drain FFI (the added `signer_handle`). +- **Feedback (scope S4):** the auto-accepted contact lands in `ContactsView` via `@Query`; + add a light signal (the needs-unlock banner already counts the pending `AutoAccept`, so it + shows "1 contact waiting…" until the drain completes, then it appears as a contact). + +## 6. Security (4 must-fixes folded) + +1. **Consensus-authenticated sender binding (must-fix #1):** verify binds `$ownerId = + doc.owner_id()`. A malicious holder of a leaked QR key *can* sign a proof naming any + sender, but cannot broadcast a contactRequest *as* a victim — platform consensus + requires the doc to be signed by the owner's identity key. The verify gate MUST use the + document owner id, never a proof-internal/client value. +2. **No expired-but-valid foot-gun (must-fix #2):** the only acceptance entry checks expiry + against the same timestamp that keyed verification; no public bare `verify` returns + `true` for an expired proof. +3. **Drain verdict mapping (must-fix #3):** invalid/expired/bad-index → permanent-clear; + signer/network → transient-leave (§4.2). Prevents forever-churn. +4. **Queue bound + verify-before-fetch (must-fix #4):** cap `AutoAccept` per owner; run the + local ECDSA verify + expiry before any `Identity::fetch`, so a spam-N-identities attacker + can't turn the owner's unlock into O(N) network round-trips. +- **Private key in QR:** intrinsic to DIP-15 (the scanner must sign; the owner can't + pre-sign without the scanner's id). Scoped (only auto-accept, not payments/identity), + expiry-bounded (**1h**), blast radius = unwanted contact spam (removable via ignore). + Acceptable documented trade-off, tightened by the short TTL (no off-switch since + auto-accept is always-on, so the short TTL is the mitigation). +- **Replay:** signed message binds `(sender, owner, accountReference)`; the doc's unique + index is `($ownerId, toUserId, accountReference)` — no cross-sender replay, no on-platform + dup. Cross-network separated by coin-type in the path. + +## 7. Failure modes +- **Signer absent when a proof arrives:** enqueued (bounded), completes on next drain; + surfaced by the needs-unlock banner. +- **Expired / invalid / forged proof:** verify-gate rejects, entry cleared (permanent); + request remains manually acceptable. +- **`du` resolves to wrong/missing identity:** scanner send fails loudly; no contact. +- **Owner has no DPNS name:** `build_auto_accept_qr` errors at QR-create (v1 requires `du`). +- **Queue flood:** bounded per owner; junk cleared by local verify before any fetch. +- **Expiry index overflow (> 2^31−1):** rejected at encode (and verify path-derivation errors → permanent-clear). + +## 8. Test plan +- **Rust unit (auto_accept.rs):** key-blob round-trip; URI round-trip; **cross-actor** sign + (loose key, scanner) → verify-with-pubkey (owner's re-derived pubkey) succeeds; wrong + sender/owner/accountRef fails; expiry extraction + now ≤/≥ expiry; truncated/oversize/bad + key-type rejected; structural pre-check. +- **Rust flow (contact_requests.rs):** parser reads `autoAcceptProof`; ingest-with-proof → + `AutoAccept` enqueued (and bounded — Nth+1 dropped); drain valid+unexpired → reciprocal + sent + cleared; expired → cleared, not accepted; invalid → cleared; signerless/transient → + stays queued; **signed `accountReference` == document's** (scope M3). +- **FFI:** null/oversize/bad-URI input validation; build-QR → parse round-trip; drain with + the new signer handle. +- **Swift build:** `build_ios.sh` green. +- **On-device (two sims):** A "Show my QR" → B scans → sends; A unlock/drain → contact + auto-accepts (established, no tap on A); expired-QR path rejected. + +## 9. Decisions (resolved 2026-06-24) +1. **TTL = 1 hour, fixed** (named constant `AUTO_ACCEPT_TTL_SECS = 3600`). DIP-15 is silent + on the value (only mandates the timestamp *is* the expiry); 1h is the safe default given + auto-accept is always-on (no off-switch). No picker in v1. +2. **Auto-accept = always automatic.** No opt-in toggle. Valid + unexpired proofs + auto-accept in the drain. +3. **Scope = whole feature in one pass** (Rust + FFI + Swift + on-device), committed in + logical layers on the branch. +4. **`du`-only** (no `di=` fallback); require a DPNS name to build a QR. + +## 10. Review resolutions (4-lens, 2026-06-24) +- **DIP-fidelity:** wire-faithful, no byte fixes; pinned BE + SHA256/LE as normative (§3). +- **Security:** 4 must-fixes folded (§6); private-key-in-QR accepted as DIP-intrinsic, + mitigated by the 1h TTL. +- **Feasibility (§4 rewrite):** verify via `provider.receiving_xpub(path).public_key` (no + `&Wallet`); drain gains the identity signer + FFI `signer_handle`; the sweep parser must + read `autoAcceptProof`; use `resolve_name`. Queue variant change-list = 9 sites. +- **Scope:** `du`-only + fixed TTL (cut `di=`/picker); cross-actor + `accountReference` + ordering tests; banner counts `AutoAccept`; My-QR + Scan are net-new UI; clean stale + `auto_accept.rs` docstrings. diff --git a/docs/dashpay/SEED_ELIMINATION_HANDOFF.md b/docs/dashpay/SEED_ELIMINATION_HANDOFF.md new file mode 100644 index 00000000000..410b22b3d81 --- /dev/null +++ b/docs/dashpay/SEED_ELIMINATION_HANDOFF.md @@ -0,0 +1,278 @@ +# Seed-Elimination — Implementation Handoff / Task Tracker + +Companion to `SIGNER_SEED_ELIMINATION_SPEC.md` (design + rationale). This file +is the **actionable remaining-work list**: the Rust cores still to implement +(verifiable in this repo) and the **environment-blocked FFI + Swift + on-device** +tasks that need Xcode + an iOS simulator runtime / device target (absent in the +headless dev env — same wall Phase 1 hit). + +Keep this current as cores land. + +## SCOPE CORRECTION (2026-06-23, after re-reading the spec §2 inventory) + +Earlier entries below over-scoped discovery as a "deep storage/signing rewrite +that drops verified_scalar." **That was wrong.** The spec (§1, §2, §7) is +explicit: the **carry-scalar (`verified_scalar`) is the ACCEPTED, KEPT fix** for +the imported-identity zero-keys bug (RESOLVED, on-device 23/23). Seed elimination +removes the **resident** seed (`attach_wallet_seed`'s `mem::swap` graft), NOT the +stored per-key scalars. Per the spec's exhaustive §2 inventory, sites #1–#6 are +**done** (the contact-request flow); the only remaining seed-dependent path is +**#7 contactInfo**. Discovery is NOT a §4.9 blocker as a "rewrite" — it just has a +`ResidentWallet` derivation fallback to eliminate (route through `Master(transient +xpriv)`; carry-scalar stays). + +**Corrected remaining work for §4.9 (delete `attach_wallet_seed`):** +1. **#7 contactInfo** — publish (write) via a `ContactCryptoProvider` + `contact_info_seal`/`open` seam (signer primitives exist + parity-tested, + `45f903dc38`); sync (read) via the `ContactInfoDecrypt` deferred op (testnet). +2. **Discovery/loading `ResidentWallet` fallback** — `discover()` (discovery.rs:189) + + `load_identity_by_index()` (loading.rs:123) pass `ResidentWallet`; route the + transient master xpriv through (the `Master` variants exist: + `discover_from_master` 216) so import/unlock derive from the transient mnemonic, + then remove the `ResidentWallet` variants. Carry-scalar unchanged. +3. **Swift** — drain-on-unlock replacing `unlockWalletFromKeychain`'s re-attach + + test-helper rework. +4. **Delete** `attach_wallet_seed` + FFI export + impl + dual-gate/`mem::swap` + + the `KeychainSigner.sign(...)->Data?` nil-swallow. + +Environment (verified 2026-06-23): Xcode 26.5 + iOS-sim SDK present (BUILDS work); +NO sim runtime installed (`simctl` 0 disk images — user installing via Xcode); +testnet acceptance feasible (user has a funded testnet seed). Plan: implement + +build-verify (cargo + build_ios.sh + SwiftExampleApp) here; run testnet acceptance +once the runtime lands. + +## SUPERSEDED — earlier HOLD decision (2026-06-23, now reversed by the scope correction above) + +The seedless **contact-request flow** (send/accept/drain/always-enqueue sweep) +and the resident-ECDH-path deletion (C3) are **DONE + verified** (292/292). +The user **decided to HOLD** the remaining seed-elimination — deleting +`attach_wallet_seed` (§4.9), the discovery key-storage/signing rewrite, and the +Swift Keychain-signer wiring — for an **iOS-capable session**, because their +correctness is only observable against the iOS Keychain signer (env-blocked) and +discovery is the most safety-critical path (a wrong key-storage change locks +users out of signing). This is a deliberate deferral, not an oversight: +`attach_wallet_seed` is intentionally retained while its remaining production +callers (discovery, contactInfo sweep decrypt) still need it. Do NOT delete it +headless. Resume the held work in an environment with Xcode + a live network. + +## Status — landed (branch `feat/dashpay-m1-sync-correctness`) + +| Commit | Spec | Summary | +|---|---|---| +| `d309a4b4` | Phase 1 | `send_payment` + `extended_public_key` via Keychain signer; dead read-APIs deleted; `WipingXprv` zeroize guard | +| `c79f95e9` | — | import-bug confirmed already-fixed (carry-scalar), de-blocked | +| `301fc436` | §4.7 | 3-state error classification (`Unavailable` ≠ `Permanent`); `has_seed()` readiness gate — no channel-kill/churn | +| `2e7eae1a` | §4.6 | deferred-crypto queue types (`PendingContactCrypto*`) + changeset add/clear deltas + `upsert_pending_contact_crypto` | +| `508b3edd` | §4.6 | in-memory enqueue on the seedless sweep (`PlatformWalletInfo.pending_contact_crypto`) | +| `d944245204` | §4.5 | `MnemonicResolverCoreSigner::ecdh_shared_secret` (parity-pinned); design = inherent methods + closures (no trait/crate) | +| `93fe4eac12` | §4.6 | `register_external_contact_account` takes `precomputed_shared_key: Option<[u8;32]>` (drain's decrypt core) + Some-path test | +| `6832a52c31` | §4.6 | `register_contact_account` takes `precomputed_account_xpub: Option` (drain's RegisterReceiving core) + Some-path test | +| `4b6a6f7934` | §4.6 | deferred-crypto drain framework + `DrainCryptoProvider` trait + RegisterReceiving op + test | +| `ecd288c735` | §4.6 | drain RegisterExternal op (ECDH path + contact fetch + provider + register) + deferral-safety test | +| `45f903dc38` | §4.5 | contactInfo seal/open host primitive (2 hardened-child AES keys, reuses platform_encryption) + round-trip/parity test | +| `65f0c5cceb` | §4.8 | wrong-seed self-check `verify_binds_to_xpub` (+ `WrongSeed` error) + accept/reject test | +| `ea6a1ea753` | §4.6 | enqueue persists the `pending_contact_crypto_added` delta (symmetric with the drain's clear-delta) | +| `97a9a99f22` | §4.6 | drain FFI `platform_wallet_drain_pending_contact_crypto` + `ResolverDrainProvider` glue (iOS-cross-compiled, in the regenerated header) | +| `79ca6a1c2c` | §4.6 | SQLite storage for the queue: migration + writer + reader + store dispatch + round-trip test | +| `3bce45939b` | §4.5 | move `calculate/unmask_account_reference` (+ `extract_ask28` + 5 tests) into `platform-encryption`; `dip14` re-exports — single-sources the HMAC+masking for the Keychain signer | +| `7a23fa114e` | §4.5 | signer `account_reference` / `unmask_account_reference` methods on `MnemonicResolverCoreSigner` (Zeroizing scalar, parity+round-trip test) — the in-signer accountReference for seedless send | +| `9d532c2aba` | §4.6 | send via `EcdhProvider::ClientSide` — SDK no longer receives a private key; `SendContactRequestParams` carries the precomputed `shared_secret` + `expected_recipient_pubkey` guard (still resident-derived; seedless swap next) | +| `88b7fb7671` | §4.6 | generalize `DrainCryptoProvider` → `ContactCryptoProvider` + `account_reference`/`unmask_account_reference`; glue impl wires the signer methods (serves drain AND send) | +| `07e1821526` | §4.6 | **seedless send + accept** — xpub/ECDH/accountReference all via the provider; send/accept gain a `crypto` param; both FFI gain `core_signer_handle` + build the resolver provider. Verified host + `aarch64-apple-ios-sim` | +| `5f1f75a9f9` | test | seedless `SeedCryptoProvider` test harness (derives from a test seed via `key_wallet`) — the deletion-rework prerequisite | +| `9082d35aad` | §4.7 | drain `RegisterExternal` runs `validate_contact_request` (closes the deferred-path validation gap; makes always-enqueue validation-safe) | +| `1b88d5a6ca` | §4.6 | **sweep always-enqueues** — removed `build_contact_accounts`' resident fast-path; the signerless sweep defers everything to the drain (−205 lines) | +| `14566d96bd` | §4.9 | **C3** — delete the resident ECDH path: `register_external` non-`Option` (dead key-index params removed), `derive_encryption_private_key` + tests gone, `RegisterExternalError::Unavailable` removed | +| `8ed2605a91`,`3b2b418e66` | docs | 4-lens Q2 plan review folded into spec/TODO; Q2 banner + completion criteria | +| `0da8ca5ddb` | §4.5/#7 | contactInfo `seal`/`open` on `ContactCryptoProvider` + glue + real-auth-path parity test (security MUST-FIX) | +| `413229f048` | #7 | **seedless contactInfo publish** — find-existing via `open` + encrypt via `seal`; shared fetch split into key-free scan + resident wrapper (de-dup); FFI gains `core_signer_handle`; 4 provider constructions collapsed to one helper | +| `15ca790aad` | #7 | **seedless contactInfo sweep+drain** — sweep enqueues `ContactInfoDecrypt` (seedless) / decrypts resident (resident-key types); drain op implemented (re-fetch + `open` + apply + confused-deputy guard). Network-validated end-to-end | +| `db18688545` | §4.4 | delete the dead `dash_sdk_dashpay_*` rs-sdk-ffi surface (−743; de-dup, last `SdkSide` ABI) | +| `74f15496ef` | #7 | keep-warnings-green: `RawContactInfoDoc` made `pub(super)` to match its `pub(super)` fetch | +| `feb266fd1b` | §4.2 | port `WipingXprv` RAII guard to the sibling FFI (`sign_with_mnemonic_resolver.rs`) — scrubs derived scalars on the error/unwind paths the Ok-only `non_secure_erase` missed | +| `fe3ab74e19` | §4.9 | **delete `attach_wallet_seed`** (lib + FFI + dual-gate/`mem::swap` + all refs) and wire its replacement atomically: `PlatformWallet::verify_seed_binds` + `platform_wallet_verify_seed_binds_to_wallet` FFI (signer-derived BIP44-0 xpub vs persisted, wrong-seed → `SeedMismatch`). Red→green verified. `git grep attach_wallet_seed` empty (Rust) | +| `70aaf32f9f` | §4.9 | **Swift seedless unlock** — `unlockWalletFromKeychain(_:)` verifies-binds + background-drains (no seed graft); send/accept/contactInfo thread the resolver `core_signer_handle`; `KeychainSigner.sign(...)->Data?` nil-swallow + its `Signer` protocol requirement deleted. Header regenerated; arm64-sim SwiftExampleApp **BUILD SUCCEEDED** | + +**Locked design decisions** +- Raw-secret ops are **inherent methods on `MnemonicResolverCoreSigner`** (in + `rs-sdk-ffi`, which already binds the wallet signer); platform-wallet consumes + them via **closures** (`EcdhProvider::ClientSide` + per-op closure params). No + `WalletKeyProvider` trait, no new crate (rs-sdk-ffi and platform-wallet don't + depend on each other; the closure seam already exists). +- Seedless-capable register fns take `Option` — `None` = resident + path (unchanged), `Some` = drain (signer-derived, scalar never in this crate). +- Queue = persisted add/clear **deltas** on the changeset; in-memory mirror on + `PlatformWalletInfo`; dedup by `(owner, contact, kind)`, latest payload wins. +- `register_external` resident path stays gated by §4.7 `has_seed()` → + `Unavailable` defers (enqueue), never kill/churn. + +## Remaining — Rust cores (verifiable here; do in this order) + +1. ~~`register_contact_account` precomputed-xpub~~ **DONE** (`6832a52c31`). Both + drain ops now have their seedless core (RegisterExternal=`93fe4eac12`, + RegisterReceiving=`6832a52c31`). +2. ~~The drain method~~ **DONE** (`4b6a6f7934` framework + RegisterReceiving; + `ecd288c735` RegisterExternal). `drain_pending_contact_crypto(provider)` on + `IdentityWallet`, generic over the platform-wallet-local `DrainCryptoProvider` + trait (`receiving_xpub` + `ecdh_shared_secret`; the glue crate impls it over + the resolver signer). Snapshots the queue, runs each op, removes completed + entries (in-memory + persisted clear delta), marks-broken on permanent + faults, leaves transient/unavailable for next drain. **Remaining:** the + `ContactInfoDecrypt` op (needs the §4.5 contactInfo primitive). End-to-end + RegisterExternal success path (real fetch + provider) verifies on-device. +3. **§4.6 persistence — emit + SQLite storage DONE; restore BLOCKED upstream.** + Enqueue (`ea6a1ea753`) and drain (`ecd288c735`) emit the deltas; the SQLite + persister now writes + reads them (`79ca6a1c2c`: migration + writer + reader + + dispatch + round-trip test). **Remaining:** the read-back into + `PlatformWalletInfo.pending_contact_crypto` is blocked on the upstream + per-wallet state restore (`persister.rs` `LOAD_UNIMPLEMENTED: + ClientStartState::wallets`); the reader is `cfg(test)`-gated until then. + The app's **FFI/Swift persister twin** (carry the deltas through the FFI + persister + Swift callback) is env-blocked. +4. ~~**§4.5 accountReference**~~ **DONE.** The move landed (`3bce45939b`) and the + signer methods landed (`7a23fa114e`): `MnemonicResolverCoreSigner::account_reference` + / `unmask_account_reference` derive the scalar in-signer (Zeroizing) and call + `platform-encryption`, so the raw scalar never returns to platform-wallet. The + "speculative" hold was wrong — the consumer is the send-flow FFI (Rust, builds + here), not Swift. Wired into `ContactCryptoProvider` (`88b7fb7671`); the send + path consumes them in C2 below. +5. ~~§4.5 contactInfo~~ **DONE** (`45f903dc38`). The drain's `ContactInfoDecrypt` + op (calls `contact_info_open` via an extended `DrainCryptoProvider` + + re-fetches owned docs — network-dependent) is the remaining drain piece. +6. ~~§4.8 wrong-seed self-check~~ **DONE** (`65f0c5cceb`): `verify_binds_to_xpub` + + `WrongSeed`. The glue crate calls it at first use with the wallet's + persisted account-xpub (env-blocked wiring). + +## Remaining — Rust + FFI (verifiable here; do in this order) + +These compile + cross-compile in this repo (the glue crate is Rust; the +ClientSide seam + provider already landed). Earlier handoffs wrongly filed these under +"environment-blocked" by conflating FFI (Rust) with Swift (`.swift`). + +**C2 — seedless send + accept — DONE** (`07e1821526`). Send + accept source the +friendship xpub, ECDH secret, and accountReference through `ContactCryptoProvider`; +both FFI take `core_signer_handle`. (The new C ABI param breaks the `.swift` +callers — expected; that's the Swift task below.) Verified host + iOS-sim. + +**Sweep always-enqueue + C3 — DONE** (`1b88d5a6ca` sweep, `9082d35aad` drain +validation, `5f1f75a9f9` seedless test harness, `14566d96bd` C3). The signerless +sweep now defers everything to the drain; the resident `register_external` branch, +`derive_encryption_private_key`, the 3 resident classification tests, and the +`RegisterExternalError::Unavailable` machinery are all deleted. No DashPay +**send/accept/sweep** path derives from a resident seed. + +**§4.6 persistence over FFI** — `IdentityKeyEntryFFI`-style carry of the queue +deltas through the FFI persister + the Swift persister callback (the SQLite +path landed in `79ca6a1c2c`; the FFI persister twin is Rust-doable, the Swift +callback is below). + +### §4.9 (delete `attach_wallet_seed`) is blocked on 3 remaining raw-secret paths + +Grounded in `git grep "wallet.derive_extended_private_key"` (production, non-test): +deleting `attach_wallet_seed` makes `has_seed()` always false, which would break +these paths that still derive from the **resident seed** and have no provider seam +yet. Each needs the same treatment send/accept got (route through a signer method +via a provider closure/seam) BEFORE `attach_wallet_seed` can go: + +1. **contactInfo** (`crypto/contact_info.rs` `derive_contact_info_keys`) — the + resident twin of the signer's `contact_info_seal`/`contact_info_open` (which + exist + are parity-tested, `45f903dc38`). **Not cleanly separable:** the + derivation is reached through a shared helper `fetch_decrypted_contact_infos` + used by BOTH the signer-present publish (`set_contact_info_with_external_signer`) + AND the **signerless sweep** (`sync_contact_infos`). The sweep side can't + decrypt without a signer → it needs the `ContactInfoDecrypt` deferred op, which + re-fetches the owner's docs (**network-blocked here**). So contactInfo can't go + fully seedless until the sweep decrypt is deferred over a live network. (The + publish-only encrypt could be threaded through a provider, but it shares the + helper, so a clean conversion does both at once.) +2. **auto-accept proof** (`crypto/auto_accept.rs`) — **production-dead:** + `generate_auto_accept_proof` / `derive_auto_accept_private_key` have no + production caller (the send flow always passes `auto_accept_proof: None`); only + their own `#[cfg(test)]` tests exercise the seed. So this is NOT a production + seed dependency — when `attach_wallet_seed` goes, only these tests need rework + (drop them or derive via a test seed directly). +3. **`derive_identity_auth_keypair`** (`network/identity_handle.rs`) — returns the + raw `ExtendedPrivKey`; used by the identity-discovery scan (`discovery.rs`) + + the FFI key preview + registration. This is the **documented deep blocker** + (memory `dashpay-imported-identity-zero-candidate-discovery`). Root cause, + confirmed in `discovery.rs::breadcrumb_decisions`: discovery doesn't just + *match* keys — it carries the **verified private scalar** (`KeyWithBreadcrumb. + verified_scalar`) to the client so it "stores the bytes directly instead of + re-deriving from a mnemonic." **That stored scalar IS the resident-seed + posture.** Seedless discovery requires an architectural change: + - verify ownership via **public-key** derivation (signer `extended_public_key` + at the hardened candidate path, compare to the on-chain compressed pubkey) — + no scalar needed for the match; + - stop carrying/storing `verified_scalar` (remove the field; changes the + changeset + the `identity_keys` persistence + the FFI/Swift that stores it); + - route signing through the Keychain signer using only the breadcrumb + `(wallet_id, identity_index, key_id)` — the env-blocked Swift signing path. + This spans storage + FFI + Swift, not a localized Rust conversion. Resolve the + storage/signing model (and wire the Swift Keychain signing) before deleting the + resident derive. + + **Decisive fact (traced):** `verified_scalar` → `IdentityKeyEntry.private_key` + is a **Rust→FFI→Swift hand-off** — NO Rust signing path reads it (Rust signs + via the external `VTableSigner`; `git grep` finds no Rust consumer of the stored + scalar for signing). Its terminal consumer is the Swift Keychain persister / + signer (14 `rs-platform-wallet-ffi` files handle identity-key private material). + So the Rust-side change (verify-via-pubkey, drop the scalar) cannot be + **validated** here — only the Swift signer that consumes the breadcrumb proves + it works, and that's env-blocked. Combined with this being the single most + safety-critical path (a wrong key-storage change = users locked out of signing), + this is the one conversion that must NOT be rushed headless: do it in an + iOS-capable session where the Swift signing can be exercised end-to-end. + +**Net:** `attach_wallet_seed` still has live production callers — the contactInfo +sweep decrypt (network-blocked) and discovery (deep storage/signing change + +env-blocked Swift). Deleting it now regresses background sync + identity +discovery, which the spec's own §4.9 ordering forbids ("only after the sweep is +seedless-safe"). The contact-request flow is fully seedless; the wallet-wide +posture needs the above before the resident-seed API can go. + +Only after those are seedless does `attach_wallet_seed` have no production caller. +Then §4.9 deletes: `manager/attach_seed.rs` (+ its tests), the +`platform_wallet_manager_attach_wallet_seed_from_mnemonic` FFI (+ 4 tests in +`manager.rs`), and the `make_wallet` test helpers' attach calls (→ seedless, +using `SeedCryptoProvider`). The `.swift` `unlockWalletFromKeychain` re-attach → +`drain` swap is env-blocked. + +## Remaining — environment-blocked (Swift + on-device) + +> **STATUS (2026-06-23, superseded):** The Swift work below is **DONE** — see +> `70aaf32f9f` and the spec banner (authoritative). `unlockWalletFromKeychain(_:)` +> now verifies-binds + background-drains (no re-attach); send/accept/contactInfo +> thread `core_signer_handle`; the `KeychainSigner.sign(...)->Data?` nil-swallow is +> deleted. Verified: header regenerated (`build_ios.sh --target sim`), arm64-sim +> SwiftExampleApp **BUILD SUCCEEDED**, and an **on-device smoke test** (booted +> sim, fresh install + launch) reached the Sync Status screen with **DashPay sync +> running** against real persisted state — the new seedless binary launches/runs +> with no crash. The bullets below are kept for history. The ONLY open item is the +> **funded testnet acceptance** (happy + wrong-seed-rejected + cross-device +> contactInfo) — interactive manual QA: it needs the funded wallet activated, a +> deliberately mis-mapped slot, a second device, and on-chain testnet writes. + +Need Xcode + iOS simulator runtime (absent here). After the Rust/FFI lands, +regenerate the cbindgen header (`build_ios.sh` — the header lives inside the +xcframework build artifact, so it can't be hand-regenerated meaningfully without +the cross-compile + packaging) and update the `.swift` callers. The two contact +FFI now take an extra `core_signer_handle` the Swift side already holds (the same +handle it passes to the drain); Swift passes it and, on Keychain unlock, calls +`platform_wallet_drain_pending_contact_crypto` instead of the deleted re-attach. +- **Drain FFI — Rust side DONE** (`97a9a99f22`, iOS-cross-compiled + in header). + **Remaining (Swift):** call `platform_wallet_drain_pending_contact_crypto(wallet, + core_signer)` from the Keychain-unlock path that previously called + `unlockWalletFromKeychain` (replacing the deleted re-attach), + opportunistic + drain on signer-present actions + a UI "needs unlock" marker. +- **contactInfo FFI + Swift** — seal/open entry points; the sync path enqueues + `ContactInfoDecrypt` instead of the silent `SkippedWatchOnly`. +- **Delete the dead `dash_sdk_dashpay_*` ClientSide FFI surface** + regen header. +- **§4.9** — delete `attach_wallet_seed` + the FFI export + + `unlockWalletFromKeychain` re-attach + the legacy `KeychainSigner.sign(...)->Data?` + nil-swallow; rework test helpers to inject a test signer. **Only after** the + sweep is seedless-safe (queue + drain landed). +- **On-device acceptance** — clean wipe → import → send/accept contact request, + send payment, publish profile + contactInfo; **background-discover an inbound + contact then unlock → it becomes payable**; `git grep attach_wallet_seed` + empty; no surviving `derive_extended_private_key` / `build_signed(wallet)` on + DashPay paths. diff --git a/docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md b/docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md new file mode 100644 index 00000000000..7e1da362c1b --- /dev/null +++ b/docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md @@ -0,0 +1,668 @@ +# DashPay Signer-Based Seed Elimination — Spec + +Status: DRAFT v3 (revised after a 3-agent deep design review: seedless +background-sync architecture, signer/host-primitive model, security & +failure-mode audit) +Branch: `feat/dashpay-m1-sync-correctness` (PR #3841) +Cross-repo: required key-wallet method (`extended_public_key`) has LANDED; +pinned `rust-dashcore` rev already bumped. + +## 1. Problem + +PR #3639 ("external signable wallets", v3.1-dev) set this codebase's +posture: a registered/restored wallet holds **no resident seed** +(`WalletType::ExternalSignable`). Private-key work is done by passing a +**Keychain-backed `Signer`** per operation; the seed lives only in the iOS +Keychain. + +The DashPay paths added in PR #3841 did not follow that model — they reach +for the resident seed (`send_payment` passes the `Wallet` to `build_signed`; +`derive_contact_xpub` calls `wallet.derive_extended_public_key`; contact +encrypt/decrypt + `accountReference` + contactInfo derive raw secrets off +the `Wallet`). To make them work, `manager/attach_seed.rs::attach_wallet_seed` +re-derives a signing `Wallet` from the Keychain seed and grafts it onto the +loaded wallet via `std::mem::swap`. **That defeats the external-signable +posture** (the seed becomes resident for the whole session) and is a +workaround. + +The resolved **import-wallet bug** was the same disease for identity keys +(Swift re-derived identity scalars from the mnemonic during discovery); +fixed by the carry-scalar change. See `IMPORTED_IDENTITY_KEY_MATERIALIZATION_SPEC.md`. + +### Goal + +Every DashPay private-key operation runs through a Keychain-backed host +primitive; the wallet seed is **never made persistently resident**; +`attach_wallet_seed` (+ `unlockWalletFromKeychain`'s re-attach + the FFI +export + the dual-gate/`mem::swap`) is **deleted** — but only after the +background sync sweep is seedless-safe (§4.9 ordering constraint). + +### Honest scope of the security win (corrected after the security audit) + +This does **not** make the seed "never resident." Verified facts, to be +stated plainly so the win is not over-credited: + +- **The full BIP-39 64-byte seed + master xprv are reconstructed in one + contiguous buffer per operation** (`MnemonicResolverCoreSigner::resolve_derived_xprv` + — `seed: Zeroizing<[u8;64]>`, master xprv on the next lines; sibling + `sign_with_mnemonic_resolver.rs`). The whole wallet is derivable from that + buffer for the duration of the op. +- **The read is unlock-gated, not biometric-gated.** The Keychain mnemonic is + `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` with **no `LAContext` / + `SecAccessControl`** on the read path (`WalletStorage.swift`). The + `.biometryCurrentSet` stash exists but is **unused**. So "user present" + really means "device unlocked" — any in-process code can drive the resolver + while unlocked. +- **The wipe is best-effort.** Byte buffers use `Zeroizing` (volatile + fence, + runs on unwind). The two `ExtendedPrivKey` scalars use secp256k1's + `non_secure_erase` (fills `[1u8;32]`, self-disclaimed as non-secure; + `ExtendedPrivKey` has no `Drop`/`Zeroize` upstream). There is an **error- + /unwind-path residue gap** (§4.2 hardening). + +The real, defensible benefit is a **smaller in-memory time-window** for the +root secret — per-operation-and-wiped vs `attach_wallet_seed`'s session-long +resident `Wallet` — plus consistency with the #3639 posture. It is a modest, +honest improvement, not "the seed is never in RAM." (The dashj / +dash-shared-core reference clients hold the decrypted seed for the whole +session — see §8 — so there is no off-the-shelf signer-based DashPay to copy.) + +### Non-goals + +- Changing `downgrade_to_external_signable` (`wallet_lifecycle.rs:251`) — it + is what makes the seed absent; it stays. +- Re-introducing an in-memory-seed wallet (the dashj model). +- Wiring QR-based auto-accept — tracked in `TODO.md` (helpers KEPT, not + deleted; §2 note). + +### Q2 scope, status & completion criteria (2026-06-23) + +**Q2** (the PR reviewer ask) = *remove the assign-seed workaround* = delete +`attach_wallet_seed` (this §; §4.9). Live status tracker: +`SEED_ELIMINATION_HANDOFF.md`. + +**Done + verified** (branch `feat/dashpay-m1-sync-correctness`; platform-wallet +292/292; glue builds host + `aarch64-apple-ios-sim`): §2 inventory sites **#1–#6** +— the entire seedless contact-request flow (send/accept/drain/always-enqueue +sweep), the `ContactCryptoProvider` seam + signer host primitives (ECDH, +accountReference, contactInfo seal/open, wrong-seed check), the deferred-crypto +queue + SQLite persistence, and C3 (resident ECDH path deleted). Discovery is +NOT a rewrite target — the **carry-scalar fix is kept** (§1). + +**Remaining for Q2 (do in this order). Folds in the 4-lens plan review +(feasibility / security / scope-dedup / adversarial), which corrected two +over-optimistic items in an earlier draft of this banner — see the MUST-FIX +notes.** + +1. **#7 contactInfo (the load-bearing item).** + - Add `contact_info_seal` AND **`contact_info_open`** to `ContactCryptoProvider` + (+ glue impl over the signer primitives + `SeedCryptoProvider` test impl). + **MUST-FIX (review):** publish is NOT seal-only — it must DECRYPT existing + owned docs to decide update-vs-create (the doc↔contact binding lives inside + `encToUserId` ciphertext; `contact_info.rs:435-447`), so it needs `open`. + - Refactor the shared helper `fetch_decrypted_contact_infos` (resident-hardcoded + at `:206`/`:450`, used by BOTH publish and the signerless sweep) into a public + high-water scan (no keys) + a provider-`open` decrypt step. Thread `crypto` + into `set_contact_info_with_external_signer` (publish) and the FFI + (`platform_wallet_set_dashpay_contact_info_with_signer` gains a + `core_signer_handle` — ABI break, like send/accept). + - **MUST-FIX (security): root-path provenance.** Build the contactInfo root + path in **Rust** via `identity_auth_derivation_path_for_type(net, ECDSA, + identity_index, root_key_id)` (never Swift); pin the parity test against that + REAL path, not `test_path()` — a wrong root silently writes undecryptable + contactInfo with no on-chain oracle. + - **MUST-FIX (security): high-water.** Publish is signer-present, so derive the + high-water `derivationIndex` from a FRESH full decrypt, or refuse to publish — + NEVER fall back to `0`/stale (collides the unique `($ownerId, rootIndex, + derivationIndex)` index / reuses a key). + - Implement the `ContactInfoDecrypt` **drain op** (currently a no-op stub, + `contact_requests.rs:1440`): re-fetch owned docs + `contact_info_open` via the + provider + re-run `validate_contact_request`-equivalent. Re-fetch = **testnet + validated**. `derive_contact_info_keys` (resident twin) is deleted ONLY after + both publish AND this sweep/drain path no longer call it. + - **MUST-FIX (security): confused-deputy.** The drain (and opportunistic drains) + must re-validate the queue entry's `owner_identity_id` is owned by THIS wallet + before decrypting/registering. + +2. **Discovery/loading — NO library change (corrected; was wrong in the earlier + draft).** The FFI already routes external-signable wallets through + `discover_from_master` / `load_identity_by_index_from_master` (via + `resolve_master_from_resolver`), and the `ResidentWallet` variants are the + **live path for genuine resident-key wallet TYPES** (`WalletType::Mnemonic`/ + `Seed`, e.g. raw-seed imports with no persisted mnemonic) — NOT dead duplicates. + So **keep** them; do **NOT** attempt the deep `verified_scalar`-drop rewrite + (that's the env-blocked architectural change, and unnecessary — carry-scalar is + kept). The only Q2 work here: (a) confirm every discovery/loading caller passes + a non-null resolver after the deletion makes external-signable the universal + posture (the FFI already errors on null — `identity_discovery.rs:194`); (b) the + test-helper rework in step 3. NOTE: the §2 "exhaustive" table is exhaustive over + *DashPay-contact* paths; the discovery resident derive `derive_identity_auth_keypair` + (`identity_handle.rs:191`) is a resident-key-TYPE path that legitimately stays. + +3. **Test-helper rework + Swift.** ✓ DONE. The test helpers were made seedless + earlier (`c42d2e413e`). Swift (`70aaf32f9f`): `unlockWalletFromKeychain(_:)` + now verifies-binds + drains-on-unlock (no seed graft); send/accept/contactInfo + thread the resolver `core_signer_handle`. Verified by regenerating the + xcframework header (`build_ios.sh --target sim`) + an arm64-sim SwiftExampleApp + build (BUILD SUCCEEDED). + +4. **Delete** `attach_wallet_seed` + FFI export + dual-gate/`mem::swap` + the dead + `dash_sdk_dashpay_*` rs-sdk-ffi surface (4 fns, zero Swift callers — confirmed) + + the legacy `KeychainSigner.sign(...)->Data?` nil-swallow. + - **MUST-FIX (security): atomic wrong-seed wiring.** ✓ DONE (Rust, `fe3ab74e19`): + `attach_wallet_seed` (lib + FFI + dual gate) deleted and `verify_seed_binds` + (`PlatformWallet` method + `platform_wallet_verify_seed_binds_to_wallet` FFI) + landed in the same commit — signer-derived BIP44-0 xpub vs the persisted one, + mismatch → `SeedMismatch`. The comparison lives in `verify_seed_binds`, which + derives the xpub through the `ContactCryptoProvider::receiving_xpub` seam (a + generic derive-at-path) — no redundant trait method. (The signer's + `verify_binds_to_xpub` primitive was NOT used and was deleted post-review as + dead code; the live path reimplements the equality in `verify_seed_binds`.) + The dead `dash_sdk_dashpay_*` surface was removed earlier (`db18688545`). + ✓ Swift wiring DONE (`70aaf32f9f`): the verify FFI is called at unlock and the + `KeychainSigner.sign(...)->Data?` nil-swallow is deleted (see step 3 Swift). + - **SHOULD-FIX (security): §4.2 sibling-FFI leak.** ✓ DONE (`feb266fd1b`): the + `WipingXprv` RAII guard now wraps `master`/`derived` in + `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`, scrubbing on the + error/unwind paths the Ok-only `non_secure_erase` missed. + +**Done when:** +- ✓ `git grep attach_wallet_seed` empty (outside docs/regenerated headers). +- ✓ `cargo test -p platform-wallet` green (292) + glue (`platform-wallet-ffi`) green. +- ✓ `build_ios.sh --target sim` regenerates the header (verify FFI present, attach + gone, send/accept/contactInfo carry `core_signer_handle`) + SwiftExampleApp + builds clean (arm64 sim, BUILD SUCCEEDED). +- **On-device acceptance — VALIDATED on devnet `paloma` (2026-06-23)** via idb UI + automation across **two simulators** (sim A = funded `Test_devnet`, 9 DASH, 3 + identities; sim B = freshly created `SimB` wallet + new identity "Eve"). Every + signing op below ran through the Keychain resolver with **no resident seed**: + - ✓ App builds, installs, launches, runs full (SDK init, network switch, wallet + list/detail, DashPay sync) on the new seedless binary — no crash. + - ✓ **Seedless Core payment + IS-lock** — sim A sent 1 DASH to sim B's address; + tx signed via the resolver, broadcast, **InstantLock validated** (this is the + real wrong-seed-would-fail path: the seedless wallet signed a real Core tx). + - ✓ **Seedless identity registration** — sim B: asset lock → InstantSend proof → + ChainLock proof → Platform registration, all via the resolver ("Identity + created" `BfWHEg…`). + - ✓ **Seedless DPNS name registration** (`eveqaseed1ess2026`) + **profile publish** + ("Eve") — both Platform writes via the resolver. + - ✓ **Seedless contactInfo publish** — `setDashPayContactInfo` + (`core_signer_handle`) published an encrypted contactInfo doc, **create** + (`updated=false`) **and update** (`updated=true`). + - ✓ **Seedless contact request SEND** (Alice→Eve) — `send_contact_request_with_signer` + (`core_signer_handle`): registered the DashpayReceivingFunds account. + - ✓ **Seedless contact request ACCEPT** (Eve→Alice) — + `accept_contact_request_with_signer` (`core_signer_handle`): reciprocal request + + registered DashpayReceivingFunds **and** DashpayExternalAccount (the ECDH + + accountReference path). Contact established cross-device (sim A shows Eve as + contact #3). + - ⏳ NOT exercised on-chain (covered elsewhere): (a) **wrong-seed rejected loud** + via `verify_seed_binds` at unlock — gated behind the `loadFromPersistor` + restorable path; cleanly forced only by a destructive wipe+reimport. Covered by + the high-fidelity unit test (real `key_wallet` wallet, same BIP44-0 path, + accept/reject) — and the live Core/Platform signing above proves the resolver + derives correct keys. (b) **cross-device contactInfo decrypt** (same owner on a + 2nd device) — publish validated; decrypt-drain unit-tested. + + **Unrelated finding (not a seed defect):** the DashPay contact-profile chunk fetch + fails with a DAPI error `missing order by for range error: query must have an + orderBy field for each range element` ("Failed to fetch a contact-profile chunk; + will retry next sweep"). Contact establishment still succeeds; contact *profiles* + may not render. Track + fix separately. + +**Out of Q2 scope** (tracked in `TODO.md`): §6b queue restore (upstream +`ClientStartState::wallets` — note the security review's caveat that until restore +works, a contact discovered-then-app-killed-before-unlock never finishes setup); +the §4.8 present-but-zero-keys import caveat; QR auto-accept wiring. + +## 2. Inventory of seed-dependent paths (revised; exhaustive over **DashPay-contact** paths) + +Verified by grepping every `derive_extended_p*_key` / `build_signed(wallet` / +`.has_seed()` reader, and by tracing reachability from the background sweep. +NOTE (plan review): this table enumerates the DashPay-contact seed paths (#1–#7). +The **identity-discovery** resident derive `derive_identity_auth_keypair` +(`identity_handle.rs:191`) is a separate resident-key-TYPE path — it legitimately +stays for `WalletType::Mnemonic`/`Seed` wallets and is NOT a Q2 deletion target +(external-signable wallets already route discovery through the resolver). See the +Q2 banner step 2. + +`bg?` = reachable from the **signerless** recurring sweep +(`DashPaySyncManager` → `dashpay_sync` → `build_contact_accounts` / +`sync_contact_infos`). The sweep FFI takes **no signer handle** and runs with +no user present — so any `bg?`+secret op is a deferral problem (§4.6). + +| # | Call site | Capability | bg? | Phase | +|---|---|---|---|---| +| 1 | `send_payment` (`payments.rs`, build site) | ECDSA sign at path | no | **1 — DONE** | +| 2 | `derive_contact_xpub` (`dip14.rs:98`), caller = send-contact-request flow (signer present) | xpub at hardened path | no | **2** (bundled with #4 — same function) | +| 2b | `register_contact_account` → `wallet.derive_extended_public_key` (`contacts.rs:186`) | xpub at hardened path | **yes** | **2** (steady-state no-op — see note) | +| 3 | contact-request / profile / contactInfo **doc signing** | `Signer` | — | already done | +| 4 | contact-request xpub **encrypt** — `derive_encryption_private_key` (`identity_handle.rs:476`) → `EcdhProvider::SdkSide` (`sdk_writer.rs:240`) | ECDH | no | **2** | +| 5 | contact xpub **decrypt** — `register_external_contact_account` (`contacts.rs:472/510/514`) | ECDH | **yes** | **2 — DEFERRED via queue** | +| 6 | `accountReference` (`contact_requests.rs:204`; `dip14.rs:262`) | HMAC keyed by the **same** ECDH key | partial | **2** | +| 7 | contactInfo AES keys — `derive_contact_info_keys` (`contact_info.rs:80`) via sync (read) + publish (write) | raw hardened-child bytes as AES-256 key | **yes (read)** | **2 — DEFERRED via queue** | + +Reclassification vs v2 (from the review): + +- **Sites 2 and 2b moved Phase 1 → Phase 2.** Both live in functions that + *also* perform Phase-2 ECDH (#4 in `send_contact_request_with_external_signer`; + #5 in the `register_external` path that the sweep drives). Converting their + xpub piecemeal in Phase 1 would double-touch the same functions. Bundle each + xpub conversion with the ECDH conversion of its host function (one + wallet-HD closure threading per function). Phase 1 stays exactly the + already-shipped, green slice (#1 + the `extended_public_key` foundation + + dead-API deletion). +- **2b is a steady-state no-op.** `register_contact_account` has an early-exit + (`contacts.rs:153–174`): once the receiving account exists it returns before + the derive at `:186`. The account **is persisted** — + `AccountRegistrationEntry.account_xpub: ExtendedPubKey` (`changeset.rs:967`) + bincode round-trips (`persistence.rs:2387`/`2869`) and restores via + `Account::from_xpub` into a watch-only `new_external_signable` wallet + (`persistence.rs:2874/2889`), with the address pool + `used` flags + (`AccountAddressPoolEntry`). Gap-limit refill is pure public `ckd_pub` + (`KeySource::Public`, key-wallet `managed_account_trait.rs:295`). So the + derive at `:186` fires **only** on a contact's first-ever registration in a + session where it was never persisted — the deferral edge case (§4.6). +- **Only #5 (ECDH decrypt) and #7-read (contactInfo decrypt) are genuine + background blockers.** Everything else the sweep does (request ingest, + profile sync, address matching, gap-limit refill, payment reconcile) is + pure public derivation or no derivation at all. + +Notes (unchanged from v2): +- **#6 is not a separate key** — `calculate_account_reference` is HMAC-keyed by + the same ECDH scalar as #4/#5; the ECDH handling covers it. +- **auto-accept (`auto_accept.rs:80`) is KEPT** — real but unwired DIP-15 + feature; converts cleanly to a sign path when wired. Tracked in `TODO.md`. +- **Dead read APIs** `contact_xpub` / `contact_payment_addresses` had zero + callers → **DELETED in Phase 1**. + +`attach_wallet_seed` consumers to remove (Phase 2, §4.9): FFI +`platform_wallet_manager_attach_wallet_seed_from_mnemonic` (`manager.rs:430`); +Swift `unlockWalletFromKeychain` (`PlatformWalletManager.swift:468`); test +helpers (`payments.rs`). + +## 3. Capabilities the Keychain signer must expose + +Two distinct, correctly-separate signer notions exist and stay separate +across the FFI seam (§4.4): + +- **Doc-signer** — `dpp::identity::signer::Signer` + (state-transition signing). Impl = `VTableSigner`; FFI handle = + `SignerHandle`/`VTableSigner` (seed never enters Rust). Already wired. +- **Wallet-HD signer** — `key_wallet::signer::Signer` (ECDSA-at-path, + `public_key`, `extended_public_key`). Impl = `MnemonicResolverCoreSigner`; + FFI handle = `MnemonicResolverHandle` (mnemonic transiently enters Rust; + all crypto runs in FFI-crate Rust and wipes). + +Wallet-HD capabilities: + +1. **ECDSA sign at path** — EXISTS (`sign_ecdsa`). +2. **public_key at path** — EXISTS. +3. **extended_public_key at path** — DONE (key-wallet method + host primitive). +4. **ECDH** `(path, peer_pubkey) -> shared_secret` — NEW host primitive (§4.5). +5. **contactInfo seal/open** — NEW host primitive (§4.5). + +Capabilities 4–5 are added as **inherent methods on `MnemonicResolverCoreSigner`** +(in `rs-sdk-ffi`, where it already lives) and consumed by platform-wallet via +**closures** (the existing `EcdhProvider::ClientSide` seam) — no new trait, no +new crate (§4.4). + +## 4. Design + +### Phase 1 — sign + xpub (low-risk, SHIPPED green) + +#### 4.1 key-wallet change — LANDED + +`key_wallet::signer::Signer::extended_public_key` added as a **provided +default that errors** (not a breaking required method); `InMemorySigner` test +impls override it; pinned rev bumped. `TransactionSigner`/`build_signed` +unchanged. + +#### 4.2 host primitive: extended_public_key (FFI + Swift) — DONE + 1 hardening + +`MnemonicResolverCoreSigner::extended_public_key` reuses the shared +`resolve_derived_xprv` helper, computes `ExtendedPubKey::from_priv`, and wipes +both scalars. Interop-guard test pins signer-xpub == `Wallet::derive_extended_public_key`. + +**Phase-1 hardening (from the security audit) — TODO before merge:** wipe the +`master` scalar on the **error/unwind path** of `resolve_derived_xprv`. Today +the explicit `non_secure_erase` runs only in the `Ok` arms of `derive_priv` +and `extended_public_key`; if `master.derive_priv(path)` returns `Err`, or a +panic unwinds between materialization and the wipe, the `master` scalar leaks +(no `Drop`/`Zeroize`). Same gap in `sign_with_mnemonic_resolver.rs`. Preferred +fix: a small RAII wipe-guard around the two `ExtendedPrivKey`s (so all exit +paths wipe), rather than more hand-placed calls — there will be **five** such +sites once §4.5 lands. + +**Path provenance (security requirement).** Paths are built in Rust +(`AccountType::…derivation_path()`, `identity_auth_derivation_path_for_type`, +the `dip14`/`contact_info` path builders) and passed **opaquely** through the +FFI; Swift never assembles a path. + +#### 4.3 call-site conversions (Phase 1) — DONE + +- `send_payment` takes `` and signs via + `build_signed(signer, …)`; FFI `platform_wallet_send_dashpay_payment` takes + a `MnemonicResolverHandle`; Swift threads the resolver under + `withExtendedLifetime`. +- Dead `contact_xpub` / `contact_payment_addresses` deleted. +- Sites 2 and 2b are **not** converted here — moved to Phase 2 (§2). + +### Phase 2 — raw-secret paths + seedless sweep + delete the workaround + +#### 4.4 Signer & host-primitive model (no duplicate logic) + +**Decision: two FFI handles; raw-secret ops as inherent methods on the +existing wallet signer, consumed via closures — NO new trait, NO new crate.** + +- Keep `VTableSigner` (doc-signer) and `MnemonicResolverHandle` (wallet-HD) as + **two** FFI handles. Merging them would either regress the doc-signer (seed + currently never enters Rust) or force DIP-15 crypto into Swift — both + rejected. +- `MnemonicResolverCoreSigner` (in `rs-sdk-ffi`) already **is** the wallet-HD + binding (impls `key_wallet::signer::Signer`) and is constructed only by the + `rs-platform-wallet-ffi` glue crate. `rs-sdk-ffi` and `platform-wallet` do not + depend on each other, so a shared `WalletKeyProvider` *trait* would need a new + crate or a layering inversion (the external `key-wallet` is ruled out — keep + the cross-repo PR to one method, and ECDH must not become a `Signer` method). + Avoid all of that: add the raw-secret capabilities as **inherent methods** on + `MnemonicResolverCoreSigner` (it gains a `platform-encryption` dep — a leaf + crypto crate, no cycle), and let platform-wallet consume them via **closures** + — the `EcdhProvider::ClientSide { get_shared_secret }` seam it already uses, + plus a closure param on the decrypt/drain path. The glue crate wires the + closures from the signer's methods (it already owns the signer's construction + + lifetime). *(An earlier draft proposed a `WalletKeyProvider: Signer` + extension trait; dropped — no shared home given the crate graph, and the + closure seam already exists.)* + +Inherent methods on `MnemonicResolverCoreSigner` (sync — derivation is +CPU-bound + the resolver call is synchronous; the consuming `ClientSide` closure +wraps each in a future at the FFI seam): + +```text +ecdh_shared_secret(path, peer_pubkey) -> Zeroizing<[u8;32]> // #4/#5 DONE +ecdh_shared_secret_and_account_reference(path, peer, compact_xpub, account_index, version) + -> (Zeroizing<[u8;32]>, u32) // #6 +unmask_account_reference(path, prior_reference, compact_xpub) -> (u32, u32) // #6 +contact_info_seal(root_path, derivation_index, contact_id, plaintext, iv) -> ContactInfoSealed // #7 +contact_info_open(root_path, derivation_index, enc_to_user_id, blob) -> ContactInfoOpened // #7 +``` + +- Document-ops conversion: send/accept contact-request already thread a + `doc_signer` for the state transition; for the xpub/ECDH they additionally + receive the wallet-HD closure(s) the glue crate builds from the signer. + `send_payment` keeps only its `key_wallet::Signer`. Swift passes the two + handles it already holds — **no new Swift class, no new Swift crypto**. +- **Delete the dead `dash_sdk_dashpay_*` ClientSide FFI surface** + (`rs-sdk-ffi/src/dashpay/contact_request.rs`: the two entry points, + params/results, `DashSDKEcdhMode`, the four `*_with_{shared_secret,private_key}` + helpers) and regenerate the cbindgen header. Zero non-Rust callers; it is a + divergence-prone parallel orchestration of the same `rs-sdk` core, and its + `SdkSide` raw-scalar ABI contradicts the new posture. The `rs-sdk` + contact-request core + `EcdhProvider` stay (single source). + +**No-duplicate-logic trace:** every host method body is "derive scalar at a +Rust-built path (the shared `resolve_derived_xprv`, scrubbed by the `WipingXprv` +guard on every exit path) → call the existing `platform_encryption` / `dip14` +fn → return the result." Zero new crypto. Single sources stay: DIP-15 ECDH/AES → +`platform-encryption`; accountReference HMAC + masking → `dip14`; contact-request +orchestration → `rs-sdk platform::dashpay::contact_request`; contactInfo wire +codec → `crypto/contact_info.rs` (plaintext-only, runs outside the primitive). + +#### 4.5 raw-secret host primitives (option iii) + EcdhProvider collapse + +For #4–#7 the host primitive derives the key **and runs the crypto in +FFI-crate Rust**, returning only the result; the raw scalar never reaches +`rs-platform-wallet` or Swift. + +- **ECDH (#4/#5):** switch `sdk_writer.rs:240` from + `EcdhProvider::SdkSide { get_private_key }` to + `ClientSide { get_shared_secret }` backed by a closure calling + `MnemonicResolverCoreSigner::ecdh_shared_secret` (DONE; parity-pinned). + For all DashPay paths the model **collapses to `ClientSide` only**; delete + `derive_encryption_private_key` (`identity_handle.rs:476`) and + `SendContactRequestParams.ecdh_private_key` — the two places that + materialize the raw scalar in `rs-platform-wallet`. Shared secret MUST be + byte-identical to `platform_encryption::derive_shared_key_ecdh` + (`SHA256((0x02|y_parity)‖x)`); peer pubkey validated on-curve before ECDH. +- **accountReference (#6):** folded into `ecdh_shared_secret_and_account_reference` + / `unmask_account_reference` so the encryption scalar is used for both ECDH + and the `dip14` HMAC in one derivation and never returns raw. +- **contactInfo (#7):** `contact_info_seal` / `contact_info_open` derive the + two hardened-child AES keys and run encrypt/decrypt via `platform_encryption`, + returning only ciphertext/plaintext. The DIP-15 wire codec stays in + `crypto/contact_info.rs` (no key material). + +**Interop parity (required):** host-path accountReference, contactInfo blob, +and ECDH secret MUST equal the in-process results for the same seed+path — +each pinned by a test (DIP-15 interop vs dashj/dash-shared-core). + +#### 4.6 Seedless background-sync & the deferred-crypto queue (NEW — the core) + +The recurring sweep has no signer and no user present (verified: +`DashPaySyncManager` holds no signer; FFI `platform_wallet_sync_contact_requests` +/ `…_dashpay_sync_start` / `…_sync_now` take none). Design rule: every sweep +op is **public-derivable** or **deferred** — never resident-seed. + +**Persisted pending-crypto queue.** Add `pending_contact_crypto: +Vec` to `PlatformWalletChangeSet`, keyed per +`(owner_identity_id, contact_id)` with an op discriminant: + +``` +PendingContactCrypto { owner_identity_id, contact_id, + op: RegisterReceiving // our friendship xpub (2b, first-time only) + | RegisterExternal { encrypted_public_key, our_decryption_key_index, + contact_encryption_key_index } // #5 ECDH decrypt + | ContactInfoDecrypt, // #7 (idempotent re-fetch+decrypt) + enqueued_at_ms } +``` + +- Stores **only ciphertext + public key indices** (safe to persist). Rides the + existing `persister.store(...)` changeset pipeline (no side channel); + restored through `build_wallet_start_state`; a missing/old column restores as + an empty queue (skip-and-continue convention). +- **Persisted, not in-memory**, because restore-from-Keychain is exactly when + it's needed and the app may be killed between background discovery and the + next foreground unlock. + +**Enqueue (background, seedless).** In `build_contact_accounts` and +`sync_contact_infos`, when key material is unavailable (the `Unavailable` +classification, §4.7), **enqueue** instead of the current silent skip-and-log +/ retry-forever / channel-kill. Idempotent per `(owner, contact, op-kind)`. + +**Drain (foreground, signer present)** — no new signer plumbing into the +background manager: + +1. **On-unlock (primary):** a **new FFI** `platform_wallet_drain_pending_contact_crypto(wallet, core_signer)` + wired into the same Swift code path that previously called + `unlockWalletFromKeychain` — so deleting the re-attach and adding the drain + are one change. It runs each entry through the §4.5 host primitives, then + the public registration, and clears the entry. +2. **Opportunistic:** `send_payment` / `send_contact_request_with_external_signer` + / `accept_…` / `set_contact_info_with_external_signer` each drain queue + entries **for the identity they operate on** before their own work — so the + first user action on a contact resolves its deferred crypto (e.g. tapping + "Pay" on an inbound-only contact builds its `DashpayExternalAccount`). + +Drain is idempotent (each op re-checks its early-exit guard). While queued, the +sweep still does all PUBLIC work for the contact (ingest, profile, address +match, reconcile); the contact is visible but "needs unlock to finish setup." + +#### 4.7 Error classification: Transient / Unavailable / Permanent (must-fix) + +The live bug behind "is deferral safe": `build_contact_accounts` today maps an +ECDH/derive failure to `Permanent` → `mark_contact_channel_broken` +(irreversible, `warn!`-only). A **locked Keychain is transient** but would +permanently disable payments to a contact. Fix before any Phase-2 coding: + +- Introduce a **third** arm, `Unavailable` (signer/key material absent), in + `RegisterExternalError` (and the contactInfo path), **distinct** from + `Transient` (retry soon) and `Permanent` (malformed data → mark broken). +- `Unavailable` → **enqueue (§4.6) and pause this contact's build; never kill, + never churn-retry every 15s.** Only genuinely malformed inputs (bad + ciphertext, off-curve pubkey) are `Permanent`. +- **Fix the `is_seedless` gate** (`contact_requests.rs:904–921`): it currently + keys on `identity_index.is_none()`, which a seedless-but-indexed identity + passes — falling through to the channel-kill. Gate on "can I derive **right + now**?" (key material available), not "is this a wallet-owned identity?". +- Add a **needs-rebuild / needs-unlock marker** surfaced to the UI for + deferred contacts. + +#### 4.8 wrong-seed safety check (replaces the deleted dual gate) + +`attach_wallet_seed`'s dual id/xpub gate also verified the Keychain mnemonic +binds to the loaded wallet. Replace it with a **one-time xpub self-check** at +signer construction / first use: derive BIP44 account-0 xpub from the resolved +mnemonic and compare to the wallet's persisted account-0 xpub; mismatch fails +loud. **Caveat (security audit):** this catches a *wrong* seed but **not** a +*present-but-zero-keys* import (the open imported-identity bug, §7 +prerequisite). + +#### 4.9 delete the workaround — ORDERING CONSTRAINT + +Remove `attach_wallet_seed`, the FFI export, `unlockWalletFromKeychain`'s +re-attach (replaced by the §4.6 drain), the dual gate + `mem::swap`; rework +the test helpers to inject a test `Signer` / pre-seed the queue. Also delete +or make-throwing the legacy `KeychainSigner.sign(identityPublicKey:data:) +-> Data?` swallow (returns reasonless `nil` on any failure). + +**Do NOT execute this deletion until the sweep is seedless-safe** (§4.6 queue ++ §4.7 classification landed and tested). Until then the resident seed is what +keeps the signerless sweep working; removing it first turns every background +tick on an unbuilt contact into an irreversible channel-kill. + +## 5. Failure modes + +- **Signer unavailable mid-sweep (ECDH/xpub build):** classified `Unavailable` + → enqueued + paused, **not** channel-killed, **not** retried every 15s + (§4.7). Resolves on next drain. +- **Keychain locked while the tokio sweep ticks:** the loop has no + scenePhase/BG gating; it MUST hit the `Unavailable`+enqueue path, never the + kill path. +- **Pending queue never drains:** mitigated by the on-unlock drain wired to the + old re-attach trigger + opportunistic drain on any signer-present action + + a UI "needs unlock" marker. Pinned by an on-device test. +- **Poison entry (permanently malformed ciphertext):** `Permanent` → mark + broken + clear entry (no requeue). Transient → keep for next drain. +- **Partial registration** (persist-ok / in-memory-insert-fail): unchanged + (store-before-insert; relaunch rebuilds) — but the rebuild must classify a + locked Keychain as `Unavailable`, not escalate. +- **Restore-from-Keychain / app-reinstall → zero signing keys:** the + imported-identity bug — **RESOLVED** by carry-scalar (the materialization path no + longer re-derives from a not-yet-stored mnemonic). Phase 2 only confirms imported + wallets reach the resolver for xpub/ECDH (mnemonic stored by `createWallet`). +- **Wrong/mis-mapped mnemonic:** §4.8 xpub self-check, fails loud. +- **Read-path:** converted readers propagate signer errors; never return + empty/zero/stale addresses. + +## 6. Test plan + +- **key-wallet:** `extended_public_key` on `InMemorySigner` matches + `derive_extended_public_key` (DONE). +- **platform-wallet:** test `Signer` replaces `attach_wallet_seed`; + signer-based tests for `send_payment` (DONE: interop-guard) + contact-request + create/accept (ECDH), contactInfo round-trip; **interop parity** tests + (accountReference, contactInfo, ECDH byte-equal to in-process). +- **Seedless sweep:** watch-only wallet + persisted xpubs → sweep does all + PUBLIC ops with no signer; `register_contact_account` early-exit no-op once + persisted. +- **Deferral queue:** background-discover inbound contact → `Unavailable` → + enqueue (not kill); drain on unlock → contact payable. A `Permanent` error + clears the entry AND sets `payment_channel_broken`. A locked-Keychain + (transient) does **not** mark broken. +- **Per-primitive no-residue:** each inherent host-primitive method wipes scalars + on Ok **and error/unwind** paths. +- **FFI:** input-validation (null/oversize/bad-path) incl. contactInfo depth-6 + paths (hardened 65536/65537). +- **Acceptance grep:** `git grep attach_wallet_seed` empty; no surviving + `derive_extended_private_key` / `build_signed(wallet` on DashPay paths. +- **On-device:** clean wipe → import → discover → send/accept contact request, + send payment, publish profile + contactInfo, **background-discover an + inbound contact then unlock → it becomes payable** — all with **no** + re-attach. + +## 7. Rollout order (revised) + +1. **Phase 1 (SHIPPED, green):** key-wallet method → host `extended_public_key` + primitive → `send_payment` conversion → delete dead read APIs. **Remaining + before merge:** the §4.2 error-path wipe hardening + iOS `build_ios.sh` + verification. +2. **Phase 2 prerequisites (design + fix BEFORE feature code):** + - §4.7 three-state error classification + `is_seedless` gate fix. + - §4.6 persisted pending-crypto queue + drain FFI. + - ~~Resolve the imported-identity zero-signing-keys bug~~ **RESOLVED** by the + carry-scalar change (commit `c567981c46`, + `IMPORTED_IDENTITY_KEY_MATERIALIZATION_SPEC.md` §10; on-device 23/23 signable, + regression tests green). No longer a blocker. Phase 2 need only confirm an + imported wallet reaches the resolver for xpub/ECDH (its mnemonic is stored in + the Keychain by `createWallet`, so the resolver-backed signer works for it). +3. **Phase 2 feature code:** inherent host primitives on + `MnemonicResolverCoreSigner` (ECDH + accountReference + contactInfo) → + `EcdhProvider` collapse to `ClientSide` → + convert #2/#2b/#4–#7 (each xpub bundled with its function's ECDH) → delete + the dead `dash_sdk_dashpay_*` surface → §4.8 self-check. +4. **Only then §4.9:** delete `attach_wallet_seed` + re-attach + legacy + `KeychainSigner.sign(...)->Data?`. +5. Build + clippy + tests + on-device acceptance after each phase. + +Cross-repo: the key-wallet edit (already landed) needed Claude Code run from +`/Users/ivanshumkov/Projects/dashpay/` (sibling-repo writes). FFI/Swift work +goes through the **swift-rust-ffi-engineer** agent. + +## 8. Alternatives rejected + +- **In-memory seed (dashj / dash-shared-core).** Reference clients hold the + decrypted seed in-session — no signer-based DashPay to copy. Rejected: + abandons the #3639 posture. +- **Phase-1-only (xpub/sign):** does not delete `attach_wallet_seed`. Adopted + *as Phase 1*, not the end state. +- **Keep the workaround:** rejected by product decision. +- **Unified single signer handle (doc + wallet + ECDH):** rejected — regresses + the doc-signer (seed never in Rust today) or pushes DIP-15 crypto into Swift. + Add the wallet-side raw-secret ops as inherent methods on the existing + signer, consumed via closures, instead (§4.4). +- **§4.5 option (i) (return raw scalar):** rejected for option (iii) — exposes + the ECDH key raw, defeating the hashing; the carry-scalar precedent + (write-once Rust→Swift) does not sanction the read-many reverse flow. +- **Skip-and-log deferral (current code):** rejected — silently strands + contacts and (worse) the `Permanent` path irreversibly kills channels. + Replaced by the §4.6 persisted queue + §4.7 classification. + +## 9. Security must-fixes from the multi-agent review (priority order) + +1. **[CRITICAL]** Three-state classification (`Unavailable` ≠ `Permanent`); + never auto-`mark_contact_channel_broken` on unavailable key material (§4.7). +2. **[CRITICAL]** Give the sweep a seedless-safe path: enqueue+defer, never + derive-or-die; do not delete `attach_wallet_seed` until this lands (§4.6, + §4.9 ordering). +3. **[CRITICAL]** Fix the `is_seedless` gate predicate — key-availability, not + `identity_index.is_none()` (§4.7). +4. **[RESOLVED]** Imported-identity zero-signing-keys bug — fixed by carry-scalar + (commit `c567981c46`), on-device-verified, regression tests green. No longer a + Phase-2 blocker; only confirm imported wallets reach the resolver for xpub/ECDH. + (Note: §4.8's xpub self-check still does not cover a present-but-zero-keys import, + but the carry-scalar fix means imports now materialize keys, so this is moot.) +5. **[HIGH]** Tighten §1 honest-scope wording (done) + fix the + `resolve_derived_xprv` error-/unwind-path scalar leak; prefer one RAII + wipe-guard over five hand-placed `non_secure_erase` calls (§4.2). +6. **[MEDIUM]** Delete / make-throwing the legacy `KeychainSigner.sign(...)->Data?` + nil-swallow (§4.9). +7. **[MEDIUM]** UI marker for contacts pending an unlock-drain (§4.6/§4.7). + +## 10. Resolved review questions + +- **key-wallet method:** provided-default-that-errors — §4.1. +- **raw-secret:** option (iii) via inherent host primitives on the wallet + signer, consumed by closures — §4.4/§4.5. +- **signer surface:** two FFI handles; raw-secret ops as inherent methods + + `EcdhProvider::ClientSide` closures (no new trait/crate) — §4.4. +- **dead `dash_sdk_dashpay_*` surface:** delete — §4.4. +- **read-API ripple:** dead → deleted — §2. +- **dual-gate deletion:** safe for grafting; wrong-seed detection preserved via + the §4.8 self-check (but not zero-keys — §7). +- **ECDH placement:** FFI-layer host primitive, not a key-wallet trait method — + §3/§4.5. +- **"defer site 2b":** safe **only** with §4.6 queue + §4.7 classification + + §4.9 ordering. Without them it is silent, irreversible channel corruption. +- **pre-check:** Swift uses `platform_wallet_send_contact_request_with_signer`; + the rs-sdk-ffi ClientSide surface is the reference template for the ECDH + switch and is deleted afterward. diff --git a/docs/dashpay/SPEC.md b/docs/dashpay/SPEC.md new file mode 100644 index 00000000000..4fc7e9291fa --- /dev/null +++ b/docs/dashpay/SPEC.md @@ -0,0 +1,1250 @@ +# DashPay — Implementation Spec & Gap Analysis + +> **Purpose.** A single working spec for getting the **full DashPay flow** — sync, +> create/update profile, send contact request, approve/reject contact requests, +> send money to a contact — *done and tested* in the **platform wallet** +> (`rs-platform-wallet` + FFI) and surfaced as a **nice UI** in the +> **SwiftExampleApp**. +> +> **Status (2026-06-10).** DashPay is **already ~80% implemented end-to-end.** This +> document maps the protocol, inventories what exists, isolates the gaps & bugs, +> and lays out the remaining work + test plan. It is *not* a greenfield design — +> it is a finish-and-polish plan. +> +> **Status update (2026-06-18) — the finish-and-polish work is essentially done +> on `feat/dashpay-m1-sync-correctness` (PR #3841).** Resolution of the Part-0 gap +> table: **G1, G2, G12, G13, G14, G15** (the P0 sync/wire/key-purpose blockers) and +> **G3, G6, G7, G8, G9, G10** — all **DONE** (M1–M4). **G5** reworked and shipped as +> a per-sender, reversible, **local-only Ignore** (Spec 2) across every layer incl. +> the SQLite persister; cross-device sync deferred to a future encrypted `profile` +> field (contract track — the `contactInfo` route was rejected for the R1 leak). +> **G11**: the `network/` layer now has unit coverage; the live cross-client e2e +> ride PR #3549 and stay blocked on devnet funding. **G4** (watch-only ECDH) is +> **deferred** with an amended design (needs xpub hooks, not just an ECDH hook). +> Three follow-on specs were written and **implemented** this pass: +> **`SYNC_CORRECTNESS_SPEC.md`** (Spec 0 — paginated/high-water sync + contact-profile +> cache + durable persistence), **`CONTACTINFO_FORMAT_SPEC.md`** (Spec 1 — privateData +> CBOR→DIP-15 varint), and Spec 2 (Ignore). Also resolved: `accountReference` +> byte-order (**keep ours** — recipient-ignored one-time pad, no interop break) and +> the friendship-path `account'` hardcode (fixed upstream in **rust-dashcore#813**, +> pulled in via the dashcore bump **PR #3936**). Remaining is all blocked on external +> resources (devnet funding for e2e/UAT; contract governance for cross-device ignore +> + DoS filter; an upstream rust-dashcore change for multi-account). See +> [`TODO.md`](./TODO.md) for the authoritative item-by-item status. +> +> **How to read.** Part 0 is the TL;DR. Parts 1–2 are reference (protocol + +> architecture). Part 3 is the current-state inventory. Part 4 is the prioritized +> gap/bug list. Part 5 is the work plan. Part 6 is the Swift UI design. Part 7 is +> the test plan. Detailed source-cited research backing every claim lives in +> [`research/01..05`](./research/). + +--- + +## Part 0 — Executive summary + +### What works today (end-to-end, real broadcast) + +- **Profile**: create / update / fetch / sync — `rs-platform-wallet` + (`network/profile.rs`), FFI, and Swift `DashPayProfileEditorView` are all wired + and broadcast real document state transitions. +- **Send contact request**: builds the `contactRequest` doc, ECDH + AES-256-CBC + encrypts the receiving xpub (via `dash-sdk` → `platform-encryption`), signs, + broadcasts. Wrapped in Swift (`AddFriendView`). ⚠ **Correction (2026-06-10): + "works" was overstated — the G2 entropy bug meant every broadcast through this + path was rejected by consensus until the M1 task-4 fix. The code path existed; + it did not function. (Exactly what G11's zero-network-tests predicted.)** +- **Accept contact request**: sends the reciprocal request, decrypts the + contact's xpub, registers a watch-only sending account. Wired in Swift + (`ContactRequestRow` Accept). +- **Send money to a contact**: derives the next contact address, builds + signs + + broadcasts an L1 tx, records the payment. Wired in Swift + (`SendDashPayPaymentSheet`). +- **DIP-14 / DIP-15 derivation**: 256-bit non-hardened child derivation, the + `m/9'/5'/15'/0'//` friendship path, the two account + types (`DashpayReceivingFunds`, `DashpayExternalAccount`), gap limit 20 — all + implemented and test-vector-pinned in `rust-dashcore/key-wallet`. +- **Persistence**: contacts / profiles / payments round-trip through the + changeset → SQLite pipeline. SwiftData mirror models exist. +- **Swift FFI coverage**: all ~14 DashPay/DPNS FFI functions are already wrapped + on `ManagedPlatformWallet`. + +### The gaps that block a *complete, correct* flow (detail in Part 4) + +| # | Gap | Severity | Layer | +|---|-----|----------|-------| +| G1 | **Sync never builds sending accounts** — a contact who accepts you *while you're offline* has no spendable account after sync; `send_payment` fails until `register_external_contact_account` is manually called | **P0** | `rs-platform-wallet` | +| G2 | **`send_contact_request` entropy mismatch** — document-ID entropy diverges from the broadcast entropy (`rs-sdk` admits the "simplification"); severity needs verification against `PutDocument` | **P0** | `rs-sdk` | +| G3 | **`accountReference` hardcoded to 0** — DIP-15 masking unused; the unique index `(ownerId,toUserId,accountReference)` makes key rotation / re-send impossible | **P1** | `rs-platform-wallet` | +| G4 | **Watch-only wallets can't send/accept** — ECDH is derived from the in-process seed; only `EcdhProvider::SdkSide` is used, the `ClientSide` push-across-FFI path is unbuilt | **P1** | wallet + FFI | +| G5 | **Reject is local-only** — no tombstone at all (recurring sync would resurrect rejects — stage-1 fix in **M1**) and no `contactInfo` `displayHidden` doc (cross-device — stage 2 in **M3**) | **P1** | wallet + SDK | +| G6 | **Wrong fallback contract ID** — `rs-sdk` `#[cfg(not(feature="dashpay-contract"))]` path hardcodes the **DPNS** id (dead code in default builds, latent bug) | **P2** | `rs-sdk` | +| G7 | **Dead code**: `calculate_account_reference`, `validate_contact_request`, auto-accept proof gen/verify — implemented + tested but never called by live paths | **P2** | `rs-platform-wallet` | +| G8 | **Local sent-request placeholder** — stores `vec![0u8;96]` for `encrypted_public_key` instead of the real ciphertext | **P2** | `rs-platform-wallet` | +| G9 | **No contract cache** — the bundled system contract is re-loaded on every op | **P2** | `rs-platform-wallet` | +| G10 | **No `contactInfo` support** — alias/note/hidden private metadata never syncs across devices | **P2** | wallet + SDK | +| G11 | **Network layer is untested.** Primitives/state/persistence are well covered, but the *whole* `network/` layer (send/sync/accept/pay/profile-broadcast) has **0 tests**; no full send→sync→accept→pay integration test; Swift has **0** DashPay tests | **P0** | both | +| G12 | **DashPay sync is not in the recurring sync loop.** The background `IdentitySyncManager` syncs **token balances only**; `dashpay_sync()` (contact requests + profiles) runs **only on-demand via FFI** — it must be folded into the recurring loop alongside the other syncs | **P0** | `rs-platform-wallet` | +| G13 | **Sync never reconciles own sent requests** — after restore-from-seed or on a second device an established contact renders as a mere incoming request; Accept re-broadcasts a duplicate reciprocal and is **rejected forever** by the unique index | **P1** | `rs-platform-wallet` | +| G14 | **Wrong encrypted-xpub wire format** (desk-check 2026-06-10, `research/06`): we encrypt the 107-byte DIP-14 `ExtendedPubKey::encode()` instead of DIP-15's **69-byte compact** (`fingerprint‖chaincode‖pubkey`) used by iOS+Android → our send fails its own 96-byte check; our receive can't parse mobile payloads | **P0** | `platform-encryption` + `rs-sdk` + wallet | +| G15 | **Key-purpose convention mismatch**: mobile clients use key 0 (AUTHENTICATION) for both key indices; our send/validation require ENCRYPTION/DECRYPTION-purpose keys → cross-client requests blocked both directions. Verify against a real testnet mobile contactRequest, then align | **P1** | wallet + `rs-sdk` | + +### UI verdict + +The Swift DashPay UI exists but is **buried** (Identities → IdentityDetail → +`Section("DashPay")`) and **utilitarian**. The plan (Part 6) **promotes it to a +first-class `DashPay` tab**, renders the missing outgoing-requests section, moves +lists onto reactive `@Query`, and polishes styling (AsyncImage avatars, empty +states, toasts). No new happy-path FFI is required. + +### Recommended sequencing + +**Milestone 1 (correctness)**: G11-seam, G12, G1+G13 (+G5 tombstone), G2, interop +desk-check, G11-Rust. → the offline-accept→pay path works, is integration-tested, +and the background sync is wired. +**Milestone 2 (UI)**: first-class DashPay tab + polish + Swift tests (Part 6/7). +**Milestone 3 (spec-completeness)**: G3, G5, G10 (accountReference + contactInfo +for rotation/hide/alias sync). +**Milestone 4 (hardening)**: G4 (watch-only ECDH), G6–G9 cleanup. +**Milestone 5 (invitations)**: asset-lock voucher + claim + auto-accept wiring +(new scope 2026-06-10; design pass first). + +--- + +## Part 1 — What DashPay is, and the layered stack + +**DashPay** (DIP-0015) is a Dash Platform application that creates *bidirectional +direct settlement payment channels* between two Dash **identities**. User-facing +model: + +- **Username** → a **DPNS** name (DIP-0012) resolving to an identity. DashPay + itself never stores usernames; it references identities by their 32-byte id. +- **Identity** (DIP-0011) → the cryptographic actor; holds keys + credit balance; + signs all state transitions. +- **Profile** → public presentation (`displayName`, `publicMessage`, avatar). +- **Contact / friend** → an identity you have exchanged `contactRequest` + documents with **in both directions**. +- **Pay a contact** → decrypt the xpub from *their* contactRequest addressed to + you, derive the next L1 address, and pay it with an ordinary Dash transaction. + DashPay is the *key-sharing / coordination* layer; value transfer is plain L1. + +### The implementation stack (bottom → top) + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ SwiftExampleApp Views: FriendsView, IdentityDetailView (profile), │ +│ (packages/swift-sdk/ SendDashPayPaymentSheet, AddFriendView [Part 6] │ +│ SwiftExampleApp) State: PlatformWalletManager, AppState, SwiftData │ +├──────────────────────────────────────────────────────────────────────────┤ +│ swift-sdk wrappers ManagedPlatformWallet.{sync,send,accept,reject,pay, │ +│ (Sources/SwiftDashSDK) profile…}, ContactRequest, EstablishedContact, │ +│ DashPayProfile, KeychainSigner (thin, marshal-only)│ +├──────────────────────────────────────────────────────────────────────────┤ +│ rs-platform-wallet-ffi C ABI: platform_wallet_{sync_contact_requests, │ +│ send_contact_request_with_signer, accept…, reject…, │ +│ send_dashpay_payment, *_dashpay_profile_with_signer} │ +├──────────────────────────────────────────────────────────────────────────┤ +│ rs-platform-wallet IdentityWallet (network façade): contact_requests.rs,│ +│ (the brains) contacts.rs, payments.rs, profile.rs, dashpay_sync.rs│ +│ ManagedIdentity state; crypto/{dip14,validation,…} │ +├───────────────────────────────┬──────────────────────────────────────────┤ +│ rs-sdk (dash-sdk) │ platform-encryption │ +│ dashpay/contact_request.rs: │ derive_shared_key_ecdh (libsecp256k1 ECDH),│ +│ create/send_contact_request, │ encrypt/decrypt_extended_public_key │ +│ EcdhProvider, queries │ (AES-256-CBC + PKCS7), account-label crypto │ +├───────────────────────────────┴──────────────────────────────────────────┤ +│ rust-dashcore/key-wallet DIP-9 paths, DIP-14 256-bit CKD, AccountType │ +│ (HD wallet primitives) ::Dashpay{ReceivingFunds,ExternalAccount}, │ +│ managed accounts, gap limit 20, tx checking │ +├──────────────────────────────────────────────────────────────────────────┤ +│ dashpay-contract v1 schema: profile / contactRequest / │ +│ contactInfo; id Bwr4WHCP…NS1C7 │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Key architectural facts: +- **ECDH + AES live in `platform-encryption`**, *not* in `key-wallet` + (rust-dashcore has **zero** ECDH code). The wallet calls `dash-sdk`'s + `send_contact_request`, which calls `platform-encryption` internally; the + receive/decrypt path calls `platform-encryption` directly. +- **`key-wallet` only consumes an already-decrypted friend xpub** + (`wallet_add_dashpay_external_account_with_xpub_bytes`). +- **The DashPay system contract is bundled** (`load_system_data_contract`), no + network fetch needed. +- **Swift never orchestrates** — every multi-step DashPay op is *one* + `platform-wallet` FFI call (per `swift-sdk/CLAUDE.md`). + +--- + +## Part 2 — Protocol reference (authoritative numbers) + +Condensed from [`research/01-dip-spec.md`](./research/01-dip-spec.md) (DIP-9/11/13/14/15, +cross-checked against the deployed v1 contract). **Where DIP prose and the v1 +schema disagree, the schema wins.** + +### 2.1 Friendship lifecycle + +- A `contactRequest{ $ownerId: sender, toUserId: recipient }` is **one-directional**. +- **Established contact (DIP-15: "friendship") = both directions exist** (A→B *and* + B→A). One request = "pending"; the reciprocal = "accept". +- **To pay X**, read **X's** request addressed to you (`$ownerId==X`, + `toUserId==you`), decrypt its `encryptedPublicKey`, derive addresses. +- Contact requests are **immutable & non-deletable** (`documentsMutable:false`, + `canBeDeleted:false`). Key rotation = a **new** request with a bumped + `accountReference` version. + +### 2.2 Friendship derivation path (DIP-15 + DIP-14) + +``` +m / 9' / 5' / 15' / 0' / / / index + └─────── hardened ──────┘ └── non-hardened 256-bit (DIP-14) ──┘ └ non-hardened u32 +``` + +- `9'`=feature purpose, `5'`=Dash (`1'` testnet), `15'`=DashPay, `0'`=account. +- The two 256-bit levels are the raw 32-byte identity ids (owner first). **Must** + stay non-hardened (so a watch-only xpub at `…/0'` covers all contacts) and full + 256-bit (truncating to 31 bits is a DIP-14 security violation). +- Auto-accept proof keys use a **separate** path `m/9'/5'/16'/'`. + +### 2.3 ECDH shared secret + +libsecp256k1 ECDH (**not** raw X-coord): `sharedKey = SHA256( ((y[31]&1)|2) || x )` +of the shared point `d_self · Q_other`. The participating identity keys are +selected by `senderKeyIndex` / `recipientKeyIndex` (identity public-key `id`s, +encryption/decryption purpose). Both parties derive the identical 32-byte key → +the AES-256 key. + +### 2.4 Encryption layout + +- Plaintext = **compact** xpub `parentFingerprint(4) || chainCode(32) || + pubKey(33)` = **69 bytes** (not the 78-byte BIP32 xpub). *(Implementation note — + corrected by the 2026-06-10 desk-check: our stack actually fed + `ExtendedPubKey::encode()` in, which for the DashPay path is the **107-byte** + DIP-14 form → 128-byte ciphertext → our own send path failed. See **G14**; + reference clients confirm the 69-byte compact form.)* +- `encryptedPublicKey` = `IV(16) || AES-256-CBC-PKCS7(80)` = **exactly 96 bytes**. +- `encryptedAccountLabel` = `IV(16) || ciphertext(32–64)` = **48–80 bytes**. +- `contactInfo.privateData` uses **BIP32-derived** symmetric keys (self-encrypt), + not ECDH; `encToUserId` uses AES-ECB. + +### 2.5 `accountReference` + +``` +ASK = HMAC-SHA256(senderSecretKey, extendedPublicKey) +AccountRef = (Version << 28) | (ASK[28 msb] XOR (Account & 0x0FFFFFFF)) +``` +Top 4 bits = version (rotation signal), low 28 bits = account number masked by a +PRF of the xpub. Uniqueness not required. The recipient un-masks the account and +reads the version. + +### 2.6 DashPay v1 contract document types + +Contract id **`Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7`** +(hex `a2a1…71bc`), owner all-zero. Full field/index tables in +[`research/04-sdk-and-contract.md`](./research/04-sdk-and-contract.md). Summary: + +- **`profile`**: `avatarUrl`(uri,≤2048), `avatarHash`(32B), `avatarFingerprint`(8B), + `publicMessage`(1–140), `displayName`(1–25). Avatar trio is `dependentRequired`. + Unique index `$ownerId`; non-unique `$ownerId+$updatedAt`. Mutable. +- **`contactRequest`**: `toUserId`(32B id), `encryptedPublicKey`(**exactly 96B**), + `senderKeyIndex`, `recipientKeyIndex`, `accountReference`, optional + `encryptedAccountLabel`(48–80B), optional `autoAcceptProof`(38–102B, unencrypted). + Required system fields incl. `$createdAtCoreBlockHeight`. Unique index + `$ownerId+toUserId+accountReference`; timelines `toUserId+$createdAt` (received) + and `$ownerId+$createdAt` (sent). Immutable. +- **`contactInfo`**: `encToUserId`(32B), `rootEncryptionKeyIndex`, + `derivationEncryptionKeyIndex`, `privateData`(48–2048B encrypted; **DIP-15 + varint** `version`/`aliasName`/`note`/`displayHidden`/`acceptedAccounts` — + contract enforces length only, see `CONTACTINFO_FORMAT_SPEC.md`). Unique index + `$ownerId+root+derivation`. Privacy rule: don't publish until ≥2 established + contacts. + +--- + +## Part 3 — Current implementation state (inventory) + +Master status matrix. **Legend:** ✅ implemented · 🟡 partial/caveated · ❌ missing. +Citations are abbreviated; full detail in `research/02..05`. + +### 3.1 rust-dashcore `key-wallet` (HD primitives) + +| Capability | Status | Evidence | +|---|---|---| +| DIP-9 DashPay root `m/9'/5'/15'` (`/1'` testnet) | ✅ | `key-wallet/src/dip9.rs:167-198` | +| DIP-14 256-bit non-hardened CKD (priv+pub) | ✅ | `bip32.rs:575-598,1533-1589,1817+`; vectors `:2521-2594` | +| `AccountType::DashpayReceivingFunds` / `DashpayExternalAccount` | ✅ | `account/account_type.rs:76-95,469-514` | +| Managed accounts, single pool, **gap limit 20** | ✅ | `managed_account_type.rs:97-118,706-749` | +| Tx checking routes contact funds | ✅ | `transaction_checking/account_checker.rs:501-518` | +| FFI: add receiving / add external(xpub) / get | ✅ | `key-wallet-ffi/src/wallet.rs:397,451`, `managed_account.rs:436,497` | +| ECDH / shared secret / xpub encryption | ❌ (by design — lives in `platform-encryption`) | repo-wide grep: none | +| Auto-create DashPay accounts at wallet init | ❌ (per-contact, after the fact) | `wallet/initialization.rs` | +| Match result carries identity ids | 🟡 (only `account_index`; reverse-lookup needed) | `account_checker.rs:144-153` | + +### 3.2 `platform-encryption` + `rs-sdk` (crypto + send flow) + +| Capability | Status | Evidence | +|---|---|---| +| `derive_shared_key_ecdh` (libsecp256k1) | ✅ | `rs-platform-encryption/src/lib.rs:24-34` | +| `encrypt/decrypt_extended_public_key` (AES-256-CBC, IV-prepend, 96B) | ✅ | `lib.rs:97-128` | +| `encrypt/decrypt_account_label` (48–80B) | ✅ | `lib.rs:139-171` | +| `Sdk::create_contact_request` / `send_contact_request` | ✅ | `rs-sdk/src/platform/dashpay/contact_request.rs:164,378` | +| `EcdhProvider::{ClientSide, SdkSide}` | ✅ (both defined; only SdkSide used upstream) | `contact_request.rs:31-54` | +| Queries: sent / received / all contact requests | ✅ | `contact_request_queries.rs:33,76` | +| SDK helpers for `profile` / `contactInfo` | ❌ (done via generic `Document`+`PutDocument`) | — | +| **Bug: send entropy ≠ doc-id entropy** | 🟡 **G2** | `contact_request.rs:431-435` (code comment admits it) | +| **Bug: fallback contract id = DPNS id** | 🟡 **G6** (dead in default build) | `dashpay/mod.rs:33` | + +### 3.3 `rs-platform-wallet` (+ FFI, storage) — the brains + +| Flow | Status | Evidence | +|---|---|---| +| Identity ↔ wallet (managed identities) | ✅ | `state/managed_identity/mod.rs:37`, `network/identity_handle.rs:256` | +| Profile fetch / sync | ✅ | `network/profile.rs:64,145` | +| Profile create / update (external signer) | ✅ | `network/profile.rs:240,395` | +| `dashpay_sync` aggregator | ✅ | `network/dashpay_sync.rs:16` | +| Sync received contact requests | 🟡 **G1** (ingest guard drops reciprocals; no xpub-decrypt / no external account built) | `network/contact_requests.rs:322,367-372` | +| Sync own sent requests (restore/multi-device reconcile) | ❌ **G13** | sync calls `fetch_received_contact_requests` only | +| Send contact request (seed-in-process) | ✅ / 🟡 **G4** | `network/contact_requests.rs:91` | +| Accept (reciprocal send + register external account) | ✅ | `network/contact_requests.rs:466` | +| Reject | 🟡 **G5** (local-only) | `network/contact_requests.rs:678` | +| Auto-establish on reciprocal match | ✅ | `state/managed_identity/contact_requests.rs` | +| Register receiving / external account | ✅ (UAT 2026-06-12: now also **persisted** — registrations were in-memory only, so accounts vanished on relaunch and restored friendship UTXOs were dropped `dropped_no_account`) | `network/contacts.rs` | +| Send money to contact | ✅ | `network/payments.rs:93` | +| Record incoming payment | ✅ (UAT 2026-06-12: the old `try_record_incoming_payment` had **zero callers** — receiver history was always empty. Replaced by live recording in the wallet-event adapter + an idempotent `reconcile_incoming_payments` step in the recurring sync) | `network/payments.rs`, `changeset/core_bridge.rs` | +| Crypto: DIP-14 xpub / payment addrs | ✅ | `crypto/dip14.rs` | +| Crypto: `accountReference` | 🟡 **G3/G7** (correct but unused; send hardcodes 0) | `crypto/dip14.rs:147` | +| Crypto: auto-accept proof | 🟡 **G7** (dead code, `// TODO` at `auto_accept.rs:39`) | `crypto/auto_accept.rs` | +| Pre-send validation | 🟡 **G7** (never called) | `crypto/validation.rs:76` | +| Persistence round-trip | ✅ | `wallet/apply.rs`, storage `schema/{contacts,dashpay}.rs` | +| Local placeholder `encrypted_public_key` | 🟡 **G8** (`vec![0u8;96]`) | `network/contact_requests.rs:283` | +| Contract cache | 🟡 **G9** (re-load per call) | `network/profile.rs:83` | +| FFI surface (sync/send/accept/reject/pay/profile) | ✅ | `ffi/src/{dashpay,dashpay_profile,contact_request,established_contact,contact}.rs` | + +> **No `todo!()`/`unimplemented!()`/`unreachable!()` anywhere in DashPay paths** — +> all gaps are caveats, dead helpers, or local-only fallbacks, not panics. + +### 3.4 SwiftExampleApp + swift-sdk + +| Capability | Status | Evidence | +|---|---|---| +| All ~14 DashPay/DPNS FFI functions wrapped | ✅ | `Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift:1452-1779` | +| Wrapper objects: `ContactRequest`, `EstablishedContact`, `DashPayProfile` | ✅ | same dir | +| SwiftData mirrors: `PersistentDashpayProfile`, `PersistentDashpayContactRequest` | ✅ | `Persistence/Models/` | +| Contacts list + incoming requests + accept/reject | ✅ (utilitarian) | `Views/FriendsView.swift` | +| Add friend by DPNS name / identity id | ✅ | `FriendsView.swift` (`AddFriendView`) | +| Send money to contact sheet | ✅ (most polished) | `FriendsView.swift` (`SendDashPayPaymentSheet`) | +| Profile view / editor (DIP-15 avatar hashing) | ✅ | `Views/IdentityDetailView.swift:332,1169` | +| First-class DashPay tab | ❌ **(Part 6)** buried under Identities | `ContentView.swift` | +| Outgoing requests rendered | ❌ (loaded, not shown) | `FriendsView.swift` | +| Lists driven by reactive `@Query` | ❌ (reads live Rust snapshot) | `FriendsView.swift` | +| DashPay tests (unit / XCUITest) | ❌ **G11** | `SwiftTests/`, `SwiftExampleAppUITests/` | + +--- + +## Part 4 — Gap analysis & bugs (prioritized) + +### P0 — blocks a correct, complete flow + +**G1 — Sync cannot establish contacts, and never builds sending accounts.** +Two compounding defects in `sync_contact_requests` (`network/contact_requests.rs:322`): +(1) **the ingest guard drops reciprocal requests** — any received doc whose sender is +already in `sent_contact_requests` is skipped (`:367-372`), so in the offline-accept +scenario the reciprocal request never reaches `add_incoming_contact_request` (the +only auto-establish trigger) and the contact stays pending-sent forever; (2) even +for contacts that do establish, sync stores the *encrypted* `encryptedPublicKey` and +never decrypts it or registers a `DashpayExternalAccount` — only the explicit +**accept** path does. **Consequence:** "they accepted me → I sync → I pay them" +fails twice over: the contact never establishes via sync, and `send_payment` +(`network/payments.rs:135`) has no sending account. **Fix:** (a) relax the ingest +guard so a received doc whose sender matches a `sent_contact_requests` entry flows +into `add_incoming_contact_request` (which auto-establishes and collapses the +pending entries); (b) on every sync pass, for **every established contact missing an +external account** (not only newly-established ones — this also repairs contacts +left unpayable by the accept path's best-effort registration), validate the +request's key indices via `validate_contact_request` (purpose ENCRYPTION/DECRYPTION ++ ECDSA key type — never ECDH against an unvalidated index; an attacker-crafted +index pointing at an AUTHENTICATION key silently derives a wrong shared secret and +poisons the account), then decrypt the xpub and register the account — and likewise +register a missing **`DashpayReceivingFunds`** account (derivable from the wallet's +own seed, no decryption needed): it is what makes *incoming* contact payments +visible to SPV, its only creation point today is the fresh-send path +(`contact_requests.rs:300`), and after restore-from-seed nothing rebuilds it — +incoming payments would land on unwatched addresses; (c) **failure +policy** — distinguish transient failures (network: retry next sweep) from permanent +ones (decrypt/decode failure: mark the contact "payment channel broken", surface to +FFI/UI, skip until the request changes — no unbounded retry). Must be seed-aware +(skip + log for watch-only until G4). + +**G2 — `send_contact_request` entropy mismatch.** +`rs-sdk/.../contact_request.rs:431-435`: `create_contact_request` computes the +document id from entropy E1, but `send_contact_request` generates *fresh* entropy +E2 for `put_to_platform_and_wait_for_response`. The code comment admits the +"simplification". **Action:** verify whether `PutDocument` re-derives the id from +E2 (in which case the returned `ContactRequestResult.id` is merely *stale*, a +correctness wart) or whether the broadcast actually fails / duplicates. Thread the +*same* entropy through both. Pin with a test asserting `result.id == on-platform id`. + +**G11 — Test coverage (precise breakdown).** +What's **well covered** (≈60 unit tests): crypto (`crypto/dip14.rs` ×10, +`validation.rs` ×8, `auto_accept.rs` ×6), the contact state machine +(`state/managed_identity/contact_requests.rs` ×12, `mod.rs` ×8), the DashPay types +(`established_contact`/`profile`/`contact_request`/`payment`), and persistence +(`wallet/apply.rs` ×26). Plus `tests/contact_workflow_tests.rs` (8 tests) — but +those are **pure in-memory handshake** tests using `noop_persister()` and **fake +identities** (`data: vec![1u8;33]`, not real keys), so they exercise the state +machine, *not* real ECDH/derivation/broadcast. + +What's **completely untested**: the entire **`network/` layer** — the actual +broadcast/sync/pay paths. `grep #[test] src/wallet/identity/network/` → only +`registration.rs` has one. **Zero** tests for +`send_contact_request_with_external_signer`, `sync_contact_requests`, +`sync_profiles`, `accept_contact_request_with_external_signer`, +`register_external_contact_account`, `send_payment`, `create/update_profile`. And +**zero** DashPay tests in Swift. This is a quality-P0: the flow cannot be declared +"done and tested" without (a) network-layer tests via a mock SDK/broadcaster seam, +and (b) a real devnet/regtest end-to-end test. (Test plan: Part 7.) + +**G12 — DashPay sync is not in the recurring sync loop.** +The background `IdentitySyncManager` (`manager/identity_sync.rs`, owned by +`PlatformWalletManager` at `manager/mod.rs:54,139`, run as a cancel-token loop with +a configurable interval and a re-entrancy guard) syncs **token balances only**. +`dashpay_sync()` (= `sync_contact_requests()` + `sync_profiles()`) is invoked +**only on demand via FFI** — i.e. the Swift app must poll it. There is **no +recurring DashPay refresh**. **Fix (the constraints matter more than the +placement):** `dashpay_sync()` is a method on `IdentityWallet` (needs the +wallet-manager lock, per-wallet persister, broadcaster), while `IdentitySyncManager` +is deliberately self-contained (constructed with sdk + persister only; documented as +not reaching into PlatformWallet/WalletManager) and its registry **skips identities +with empty token lists** — so the recurring DashPay pass must **NOT** be driven "per +registered identity" off the token registry (a DashPay-only identity with no watched +tokens would never sync). Instead, inject the wallets map (the same +`Arc>>>` that +`PlatformAddressSyncManager` already receives at `manager/mod.rs:135-138`; snapshot +the wallet `Arc`s under a read guard per sweep) and iterate wallets calling +`wallet.identity().dashpay_sync()` per pass, reusing the existing +cadence/cancel/quiesce/re-entrancy machinery. Whether this lives inside +`IdentitySyncManager` or as a sibling `DashPaySyncManager` is an implementation +detail; coupling DashPay sync to the token registry is the failure mode to avoid. +**Error semantics:** log-and-continue per wallet/identity (matching the existing +loop's contract) — never fail-fast across identities. Note: wrapping `dashpay_sync` +alone only delivers per-*wallet* continue — `sync_contact_requests` currently +`?`-aborts its multi-identity loop on the first fetch error and `dashpay_sync` +propagates immediately, so the per-identity policy must be implemented *inside* +those loops. This recurring pass is the +natural home for the **G1** establish/decrypt/register sweep, the **G13** sent-side +reconcile, and the **G5** tombstone check. Keep the on-demand FFI entry points for +pull-to-refresh. + +### P1 — spec-correctness / production-readiness + +**G3 — `accountReference` hardcoded to 0.** The send path uses +`account_reference = 0` instead of `calculate_account_reference(...)` +(`crypto/dip14.rs:147`). Because the unique index is +`(ownerId, toUserId, accountReference)`, a second request to the same recipient +(key rotation, multi-account) **collides** and is rejected by Platform. Today this +"works" only because the full account xpub is shared directly (recipient decrypts +it and doesn't need to un-mask the account). **Fix:** wire +`calculate_account_reference` into the send path; add the version-bump path for +rotation; have the receive path tolerate / surface non-zero versions (DIP-15 §7.3 +"sender rotated their addresses" notification). **Receive-side scope note (budget +into M3):** the in-memory maps, changeset keys, and SQLite contacts schema are +keyed by counterparty id alone, and the sync ingest guard skips requests from +already-established contacts — surfacing rotation requires re-keying +contact-request state + persistence by `(counterparty, accountReference)` and +letting rotation requests from established contacts through the guard. Without +this, `dp_005`'s "receive path surfaces the rotation" assertion is unimplementable. + +**G4 — Watch-only wallets can't send/accept.** ECDH is derived from the +in-process seed (`identity_handle.rs:424`); only `EcdhProvider::SdkSide` is used. +For hardware/watch-only wallets, the `ClientSide` path (host supplies the shared +secret) must be pushed across the FFI. **Fix:** add an FFI/signer hook that returns +the ECDH shared secret for `(senderKeyIndex, recipientPubKey)` from the secure +element, and route `send_contact_request` / `register_external_contact_account` +through `EcdhProvider::ClientSide`. *(The example app holds the seed, so this is +not a demo blocker — but it is the right architecture.)* **FFI-hook design lands in +M3** (design-only task) so the wallet API doesn't churn in M4: the hook accepts +only the 32-byte ECDH **shared secret** from the host — never the sender's identity +private key across the ABI (the existing `rs-sdk-ffi` +`DashSDKContactRequestParams.sender_private_key` field is the antipattern to avoid, +and worth auditing in its own right). + +**G5 — Reject is local-only, and the recurring loop will undo it.** +`reject_contact_request` (`network/contact_requests.rs:678`, `// TODO` at `:703`) +drops the local entry but writes no tombstone of any kind — and the still-on-platform +immutable document is re-ingested as a fresh incoming request on the next sync. +Today this is masked because sync is on-demand only; **the moment G12 lands, every +background sweep resurrects every rejected request on the same device.** **Fix (two +stages):** **M1 (with G12):** a locally-persisted rejected-request tombstone +consulted by the sync ingest path — keyed by **document id (or +`(sender, accountReference)`)**, NOT bare sender id: requests are immutable, so the +only legitimate way a once-rejected sender can ever re-request is a new doc with a +bumped `accountReference` (the rotation mechanism), and a sender-keyed tombstone +would silently block that forever with no un-reject affordance. Pinning test: +"rejected request does not reappear after a recurring re-sync — and a +bumped-`accountReference` request from the same sender *does*". **M3:** the +on-platform `contactInfo` `displayHidden` write (see G10) for cross-device sync. + +**G13 — Sync never reconciles your own sent requests.** Sync only calls +`fetch_received_contact_requests`; the identity's own sent requests are never +fetched into state (the `fetch_sent_contact_requests` query exists but is +read-only). After restore-from-seed or on a second device, a mutually-established +contact renders as a mere incoming request; tapping Accept re-broadcasts a duplicate +reciprocal with the same `(ownerId, toUserId, accountReference)` triple, which +Platform rejects on the unique index — **Accept fails forever with no recovery +path**. **Fix (M1):** sync also fetches the identity's own sent contactRequest +documents and ingests them via `add_sent_contact_request` (auto-establish fires when +both sides are present) — **with a sent-side ingest guard symmetric to the received +side**: skip docs whose recipient is already in `sent_contact_requests` or +`established_contacts` (`add_sent_contact_request` has no such guard today, so an +unguarded recurring re-ingest creates phantom pending-sent rows + a changeset write +per contact per sweep). Any (re-)establish path must **merge into an existing +`EstablishedContact`** — `EstablishedContact::new` resets alias/note/is_hidden/ +accepted_accounts, so naive re-establish wipes user metadata every sweep. Accept +detects an existing on-platform reciprocal and adopts it instead of re-broadcasting; +"adopt" includes the local registrations a fresh send performs (receiving-account +registration, G1(b)) and runs the same `validate_contact_request` gate before any +`register_external_contact_account` call — the gate applies to **all three paths** +(sync sweep, normal Accept, Accept-adopt). *Pin:* "established contact is stable +and metadata-preserving across two recurring sweeps". + +**G14 — Wrong encrypted-xpub wire format (P0; found by the M1 desk-check, +`research/06-interop-desk-check.md`).** DIP-15 and BOTH reference clients +(iOS dash-shared-core `ecdsa_key.rs:333-341`, Android dashj +`serializeContactPub()` with a hard `len == 69` receive check) use the compact +**69-byte** plaintext `parentFingerprint(4) ‖ chainCode(32) ‖ pubKey(33)`. Our +stack fed `ExtendedPubKey::encode()` into `encrypt_extended_public_key` — and the +DashPay account xpub ends in a `Normal256` child, so that's the **107-byte +DIP-14** serialization → 128-byte ciphertext → fails our own `== 96` assertion +and the contract's `maxItems: 96`. **Consequence:** our send path errored before +broadcast (nothing nonconforming reached chain — blast radius ≈ zero), and our +receive (`ExtendedPubKey::decode`, 78/107 only) rejects every mobile payload and +would mark the channel permanently broken. **Fix (M1 task 7):** compact 69-byte +assembly on send + compact parser on receive (path context reconstructs +depth/child-number); byte-exact vectors from the reference clients. + +**G15 — Key-purpose convention mismatch (P1; same desk-check).** Mobile clients +populate `senderKeyIndex`/`recipientKeyIndex` with key id 0 — an +**AUTHENTICATION**-purpose ECDSA key — while we *send* selecting +ENCRYPTION/DECRYPTION-purpose keys and *validate* (G1(b)) requiring those +purposes. Cross-client requests would be blocked in both directions. **Action +(M1 task 8):** verify empirically against a real testnet mobile contactRequest, +then align — liberal-on-receive (accept the purposes mobile actually uses; keep +the ECDSA key-*type* gate), compatible-on-send (fall back to the mobile +convention when the recipient lacks a DECRYPTION-purpose key). + +### P2 — cleanup / completeness + +- **G6 — Wrong fallback contract id** (relabeled P2 — dead code in default builds): + `rs-sdk/.../dashpay/mod.rs:33` hardcodes the **DPNS** id under + `#[cfg(not(feature="dashpay-contract"))]` — a latent foot-gun. **Fix:** correct + the constant to the DashPay id `Bwr4WHCP…NS1C7` or delete the fallback. + +- **G7 — Dead code:** wire `validate_contact_request` into the send path (replace + the ad-hoc `find(...)`) — note the *receive/sync* side of this validator is pulled + forward into **M1** by G1(b); decide whether to ship auto-accept (then call the + proof gen/verify) or delete it and its FFI param. **Acceptance criterion if + shipped:** any handler acting on `autoAcceptProof` MUST call + `verify_auto_accept_proof` before triggering automatic acceptance — sync stores + the blob unverified today, so wiring auto-accept without the gate lets forged + 38–102-byte blobs auto-establish attacker contacts. +- **G8 — Local placeholder:** store the real 96-byte ciphertext on the local sent + `ContactRequest` (`contact_requests.rs:283`) so the persisted/SwiftData row + matches Platform. +- **G9 — Contract cache:** hold one `Arc` on the wallet instead of + re-loading the bundled contract per op. +- **G10 — `contactInfo` support:** add SDK + wallet + FFI for the `contactInfo` + document (self-encrypted alias/note/displayHidden/acceptedAccounts) so contact + metadata and hides sync across a user's devices. Respect the "≥2 contacts before + publishing" privacy rule. +- **Compact-xpub note:** DIP-15 specifies a 69-byte compact xpub plaintext; + `platform-encryption` currently encrypts a 78-byte serialization (still 96 bytes + out). Both sides of *this* implementation agree, so it interoperates with itself; + verify against the reference DashPay clients (iOS/Android) before declaring + cross-client compatibility. **Sequencing:** the desk-check (compare reference + client code or captured vectors) is cheap and lands in **M1** (task 5) — a wrong + wire format must be caught before three milestones of tests harden it; the live + cross-client e2e stays in M4. + +--- + +## Part 5 — Work plan (what to build, per milestone) + +Each task notes the layer and the **test** that proves it (TDD: write the failing +test first — see Part 7 and the repo's TDD discipline). + +### Milestone 1 — Correctness (the flow actually completes) + +Ordered so the test seam exists before the TDD-gated tasks that need it. + +1. **G11-seam: make the network layer testable.** The **fetch half needs no new + seam** — use the SDK's built-in mock (`SdkBuilder::new_mock` + + `expect_fetch`/`expect_fetch_many`, already used by `identity_sync.rs` tests) + for the sync/establish tests below. The **put/broadcast half** cannot hide + behind a dyn trait (`Sdk::send_contact_request` is generic over 7 type params): + define ONE object-safe trait exposing only the concrete operations + `IdentityWallet` performs (send contact request with SdkSide ECDH, put profile + document), held as a new `IdentityWallet` field defaulting to an + `Arc`-backed impl so public construction and FFI are untouched. + (`send_payment` already takes an injected `broadcaster: B`.) + **DONE (2026-06-10):** `DashPaySdkWriter` trait in `network/sdk_writer.rs` — + Send-boxed `#[async_trait]` (NOT `?Send`: the FFI drives the write paths via + `block_on_worker`, which requires `Send` futures; the `!Send` read/sync path + runs on the sync manager's dedicated thread and bypasses the seam). +2. **G12: fold DashPay sync into the recurring loop.** Per G12: inject the wallets + map and iterate wallets calling `dashpay_sync()` — do **not** drive off the + token registry; log-and-continue error semantics; keep the on-demand FFI entry + points for pull-to-refresh. + - *Test:* a recurring pass drives DashPay sync for every wallet **including + identities with zero watched tokens**; re-entrancy + quiesce still hold. + **DONE (2026-06-10):** sibling **`DashPaySyncManager`** (`manager/dashpay_sync.rs`, + modeled on `PlatformAddressSyncManager`) — the in-struct option was rejected + because `IdentitySyncManager` is registry-driven. Red→green pinned by + `recurring_pass_syncs_every_wallet_including_zero_token_identities`; per-identity + continue pushed into `sync_contact_requests`. +3. **G1 + G13 + G5-tombstone: sync establishes, reconciles, and builds accounts.** + Relax the ingest guard (G1a); ingest own sent requests (G13); consult the + persisted rejected-senders tombstone (G5 stage 1); then, for every established + contact missing an external account: validate key indices → decrypt → register + (G1b), with the transient/permanent failure policy (G1c). **Lock ordering:** + collect candidates while the wallet-manager write guard is held, drop the + guard, then call `register_external_contact_account` — it re-acquires read + locks on the same tokio `RwLock`, which is **non-reentrant**; calling it inline + under the write guard deadlocks on first execution (mirror the accept path's + guard-drop ordering — `network/contact_requests.rs:466` drops the write guard + before calling `register_external_contact_account`). + - *Tests:* offline-accept→pay (Part 7, `dp_004`); rejected request does NOT + reappear after a recurring re-sync; restore-from-seed then Accept does not + double-broadcast (G13); permanent decrypt failure marks the contact unpayable + and stops retrying. + **DONE (2026-06-10):** tombstone keyed `(owner, sender, accountReference)` + (new `rejected_contact_requests` SQLite table + `ContactChangeSet.rejected`); + broken channel = `EstablishedContact.payment_channel_broken` (new `contacts` + column + FFI accessor `established_contact_is_payment_channel_broken`); + metadata-preserving `reestablish_preserving_metadata()`; sweep candidates + collected under the write guard, registered after guard drop. 192 tests green. + *Deviations:* (1) tests pin the decision logic + state machine; the full + mock-SDK offline-accept→pay and accept-adopt flows live in `dp_004`/`dp_005` + on #3549 (per Part 7.4) — too heavy to stub as unit tests; (2) the Swift + persister bridge does NOT yet project `rejected` / `payment_channel_broken` — + added to M2 plumbing (task 8). +4. **G2: entropy threading.** Fix `rs-sdk` `send_contact_request` to reuse the + creation entropy; assert returned id == on-platform id. + - *Test:* `rs-sdk` unit/integration pinning id equality. + **DONE (2026-06-10) — severity verdict: REAL BROADCAST BUG.** `put_document` + uses the document as-is (E1-derived id) with the supplied fresh entropy E2; + drive-abci consensus recomputes `generate_document_id_v0(…, E2)`, compares to + `base.id`, and rejects with `InvalidDocumentTransitionIdError` — **every + `send_contact_request` through this path failed at consensus**. Fix: additive + `entropy: Bytes32` on `ContactRequestResult`, reused by send; pinned by + `contact_request_result_entropy_derives_returned_id` (red = inexpressible + pre-fix; green post-fix). 136 lib + all test targets green; FFI ABI unchanged. +5. **Interop desk-check (verify-only).** Compare the compact-xpub plaintext + (69B DIP-15 vs 78B current), ECDH derivation, and accountReference masking + against reference DashPay iOS/Android client code or captured vectors; record + the result. A mismatch found here re-scopes M1 before tests harden the wrong + format; the live cross-client e2e stays in M4. + **DONE (2026-06-10)** — `research/06-interop-desk-check.md`. Verdicts: + xpub plaintext **FAIL** (→ new **G14**, task 7 below); ECDH **PASS**; + accountReference **PASS-for-now** (mobile ignores it on receive; our masking + helper has two latent bugs for M3 — 107-byte HMAC input + ASK28 byte order, + where iOS and Android also disagree with *each other*). Bonus hazard → **G15** + (key-purpose convention). +7. **G14: compact-xpub wire format (re-scoped into M1 by task 5).** Send: assemble + the 69-byte compact plaintext (`parentFingerprint(4) ‖ chainCode(32) ‖ + compressedPubKey(33)`) from the already-derived contact xpub instead of + `ExtendedPubKey::encode()`; receive: parse the 69-byte compact (both sides + already know the derivation path, so depth/child-number are reconstructable). + Pin with byte-exact vectors mirroring the reference clients (iOS + `ecdsa_key.rs:333-341`, dashj `serializeContactPub()` — quoted in + `research/06`). 69 → PKCS7 → 80 ‖ IV 16 = exactly 96 bytes. + **DONE (2026-06-10):** codec in `platform-encryption` + (`compact_xpub_bytes`/`parse_compact_xpub`, `COMPACT_XPUB_LEN=69`); + `ContactXpubData::compact_xpub()` + `reconstruct_contact_xpub` in + `crypto/dip14.rs`; rs-sdk callback contract = "69-byte compact", validated + pre-encryption; receive reconstructs from `chain_code`+`pubkey` (metadata + depth/child synthesized — non-hardened CKD unaffected, pinned by + `reconstructed_xpub_derives_identical_addresses`); legacy 78/107 fallback + branch kept. 194 platform-wallet tests green; FFI ABI unchanged (caller doc + contract tightened). +8. **G15: key-purpose verification (decision gate, cheap).** Fetch a real + mobile-created `contactRequest` + its sender identity from testnet; inspect + `senderKeyIndex`/`recipientKeyIndex` purposes. Then align: likely + liberal-on-receive (accept ECDSA keys of the purposes mobile actually uses) + + compatible-on-send (fall back to the mobile convention when the recipient + has no DECRYPTION-purpose key). Implementation in M1 if the verification + confirms the mismatch; the validation gate from G1(b) stays for key *type*. + **VERIFIED (2026-06-10, all 368 testnet contactRequests — + `research/06` §G15):** the "key 0 AUTHENTICATION" desk-check reading was + stale. Dominant mobile cohort (223 docs): **unbound ENCRYPTION/MEDIUM key + (id 2) for BOTH indices** (recipientKeyIndex → ENCRYPTION — mobile identities + carry no DECRYPTION key); 2026 cohort (68 docs): contract-bound ENC(4)/DEC(5) + — our convention. Consensus enforces neither purpose nor boundedness on these + fields. **Alignment (task 9):** send — prefer recipient DECRYPTION, fall back + to recipient ENCRYPTION; receive — accept ENCRYPTION for sender, + ENC-or-DEC for recipient; keep the ECDSA type gate; purpose mismatch alone + never marks a channel permanently broken. No AUTHENTICATION fallback. +9. **G15 alignment implementation** (per the verified verdict above): relax the + sender/recipient purpose assertions in `rs-sdk` `create_contact_request` + (`:200-239`) and `rs-platform-wallet` key selection + `validate_contact_request` + wiring; tests for the mobile-cohort shape (ENC/ENC, unbound) and our own + (ENC/DEC, bound). + **DONE (2026-06-10):** recipient selection prefers DECRYPTION, falls back to + ENCRYPTION (ECDSA gate kept, no AUTH fallback); validation gained a recipient + purpose gate (AUTH was silently accepted before!) + `purpose_mismatch` flag; + purpose mismatches log-and-skip, never `payment_channel_broken`; ECDH decrypt + path confirmed index-generic (pinned). 204 platform-wallet + 139 dash-sdk + tests green. **M1 complete** (task 6 e2e rides #3549, non-gating). +6. **G11-Rust: full-cycle e2e confirmation** (`dp_003`): `profile → send → sync → + accept → established → pay`, on live testnet via the #3549 bank harness. + **Not M1-exit-gating** (Part 7.4): M1 exits on tasks 1–5; this task is tracked + on #3549 and lands when the framework does. + +### Milestone 2 — Swift UI (first-class, polished) + Swift tests + +See Part 6 for the screen design. Tasks: + +> **STATUS (2026-06-10): tasks 7–10 DONE** (Phases A–D on +> `feat/dashpay-m1-sync-correctness`). Delivered: FFI sync-control surface +> (`platform_wallet_manager_dashpay_sync_{start,stop,is_running,is_syncing, +> last_sync_unix_seconds,set_interval,sync_now}`), persister payload extended +> (callback arity 8→10: `payment_channel_broken` on `ContactRequestFFI`, +> `ContactRequestRejectionFFI` tombstones), payment-history getter +> (`managed_identity_get_dashpay_payments`); Swift SDK wrappers + +> `PersistentDashpayPayment` + `@Published dashPaySyncIsSyncing`; the full +> DashPay tab (`Views/DashPay/` — 7 files) with all §6.4 states and +> `dashpay.*` accessibility ids; simulator BUILD SUCCEEDED. +> **Spec deltas accepted:** (1) alias/note/hide are a UserDefaults-backed +> device-local store until M3's `contactInfo` (no SwiftData model added); +> (2) contact DPNS labels captured as an add-time hint (not persisted +> elsewhere); (3) AddContact ID-mode preview is cache-only (no +> fetch-profile-by-id FFI). Task 11 (tests) = Phase D. +> **Task 11 DONE (2026-06-10):** 15 SDK unit tests (persister bridge: broken-flag +> on both rows, tombstone scoped to `(owner,sender,accountReference)` w/ rotation +> survival, 10-arg C-callback round-trip, payment upserts, FFI marshalling) + 2 +> UI smoke tests (§6.4 picker states; passed on-simulator). Phase D also found & +> fixed a changeset-atomicity defect (`persistDashpayPayments` missing the +> `!inChangeset` guard — red→green). Totals: swift test 29/29; app tests 237 +> passed / 18 pre-existing network-gated skips; UI smoke green; BUILD SUCCEEDED. +> Full add→approve→pay XCUITest = documented TODO gated on funded testnet +> identities (tracks `dp_003`). + +7. Add `RootTab.dashpay` + `DashPayTabView` with an active-identity picker + (`ContentView.swift`, `SwiftExampleAppApp.swift`) — picker states per §6.4. +8. Extract/rebuild `ContactsView`, `ContactRequestsView` (incoming **+ outgoing**), + `AddContactView`, `ContactDetailView`, `ProfileView`/editor, reusing the + already-wrapped `ManagedPlatformWallet` methods; implement the §6.4 interaction + states (DPNS resolution states, send-collision flow — **AddContactView only**, + not the payment sheet — in-flight rows). Payment + history requires the persister mapping + `PersistentDashpayPayment` model (§6 + intro). Additional persister-bridge plumbing from M1: project the + `rejected` tombstones and `payment_channel_broken` flag into SwiftData (the + Rust SQLite pipeline already persists them; the Swift `on_persist_contacts_fn` + bridge does not yet). +9. Move lists onto `@Query [PersistentDashpayContactRequest]` / + `[PersistentDashpayProfile]` with the §6.4 optimistic-overlay policy; refresh + via `syncContactRequests()` + `syncDashPayProfiles()` in `.task` / + pull-to-refresh, coordinated through the §6.4 single sync-in-progress signal + (requires M1 task 2 — the three-caller invariant can't be exercised until the + G12 background loop exists). +10. Polish: AsyncImage avatars w/ initial-circle fallback, empty states, loading & + error states, inline success feedback (§6.4), accessibility identifiers on + every interactive control (for XCUITest). +11. **G11-Swift:** unit tests (wrapper round-trips) + XCUITest (add→approve→pay). + (Part 7.) + +### Milestone 3 — Spec completeness (rotation, hide, alias sync) + +12. **G3:** wire `calculate_account_reference` + version bump into send; + receive-path version handling + "addresses rotated" surfacing — **includes the + receive-side re-keying** of contact-request state/persistence by + `(counterparty, accountReference)` (see G3 scope note). +13. **G10 + G5 stage 2:** `contactInfo` document support (SDK + wallet + FFI) → + cross-device reject/hide + alias/note sync. + + **DONE (2026-06-12), 4 commits:** crypto core (DIP-15 derivation + `root/65536'+65537'/idx'`, AES-256-ECB encToUserId, IV‖CBC privateData; + the `privateData` plaintext was initially a CBOR array but is being + migrated to the **DIP-15 varint** format — the contract enforces length + only, so it's a free convention; see `CONTACTINFO_FORMAT_SPEC.md` / + Spec 1), stateless doc↔contact resolution (decrypt every owned doc's + encToUserId), sync step 3 of the recurring pass, publish with the + DIP-15 ≥2-contacts privacy gate (deferred publishes update local state + only), FFI `platform_wallet_set_dashpay_contact_info_with_signer`, + persister round-trip (alias/note/hidden on the established rows, both + directions), and **contact restore at load** (new contacts array on + `IdentityRestoreEntryFFI`) — without which the re-establish sweep wiped + metadata during the deferred-publish window and contacts were invisible + on offline launches. Verified on-sim: alias save → relaunch → survives + and renders. +14. Swift UI for alias/note edit (reuse `EditAliasView`) now backed by + `contactInfo` — remove the M2 "This device only" labels. + + **DONE (2026-06-12):** ContactDetailView reads alias/note/hidden off + the `@Query` contact rows and writes through + `ManagedPlatformWallet.setDashPayContactInfo`; ContactsView hidden + filter + alias display moved off the UserDefaults meta store (which + now only keeps the add-time DPNS hint); labels updated. +15. **G4 design-only:** specify the FFI ECDH hook (shared-secret-only across the + ABI — never a raw private key; see G4) so M4's implementation doesn't churn + the wallet API. + + **DONE (2026-06-12) — design:** + - **ABI surface (one new callback on the existing host-signer table** — + the same registration path external-signable wallets already use for + transaction signing**):** + ```c + int32_t (*ecdh_shared_secret_fn)( + void *context, + const uint8_t (*wallet_id)[32], + const uint8_t (*identity_id)[32], + uint32_t key_id, // sender's encryption key id + const uint8_t (*counterparty_pubkey)[33], + uint8_t (*out_shared_secret)[32]); // SHA256((y&1|2)||x) — finished secret + ``` + The host derives the identity encryption private key for + `(identity_id, key_id)` from its keychain/secure element and computes + the **finished DIP-15 shared secret host-side**. The private key never + crosses the ABI (the `rs-sdk-ffi` `DashSDKContactRequestParams. + sender_private_key` field is the antipattern this replaces; flagged for + its own audit). Non-zero return = "host cannot produce the secret" + (locked keychain, missing key): the operation fails with a typed + `EcdhUnavailable` error and is NOT treated as a broken payment channel + (our side failed, not the contact's request). + - **Rust routing:** `send_contact_request` / + `register_external_contact_account` branch on wallet key-residency: + seed-resident wallets keep today's in-process derivation + (`EcdhProvider::SdkSide`); external-signable wallets route + `EcdhProvider::ClientSide { get_shared_secret }` where the closure + calls the FFI hook. No public wallet-API signature changes — the + provider choice is internal, which is what de-risks M4. + - **Zeroization:** Rust wipes `out_shared_secret` (`Zeroizing`) after + deriving the AES key; hosts are instructed to do the same with their + intermediate private key (Swift: `withUnsafeTemporaryAllocation` + + explicit reset, mirroring the signer callback's key handling). + - **Same hook serves decrypt-side** (`register_external_contact_account` + needs ECDH with the *contact's* pubkey at OUR key id) — the + `counterparty_pubkey` parameter covers both directions; no second + callback needed. + +### Milestone 4 — Hardening / cleanup + +16. **G4:** watch-only ECDH via `EcdhProvider::ClientSide` pushed across FFI + (implements the M3 design). + + **DEFERRED with design amendment (2026-06-13):** implementation scoping + found the M3 hook (ECDH shared secret only) is **insufficient** for true + watch-only DashPay: the friendship-xpub derivations are hardened and + seed-bound on BOTH flows — send derives the sender↔recipient receiving + xpub (`m/9'/coin'/15'/account'/`, recipient-dependent so not + pre-derivable), and accept derives our receiving xpub for the new + account. A watch-only host therefore needs **three** hooks: ECDH shared + secret (designed in M3), friendship-xpub derivation, and + receiving-account-xpub derivation — or one combined + "derive-DashPay-context" hook returning `(compact_xpub, shared_secret)`. + The contactInfo self-encryption keys (M3 task 13) are seed-bound the + same way and need a fourth surface (or ride the combined hook). + Since the example app attaches the seed at launch (this gap is + explicitly not a demo blocker), shipping the ECDH-only ABI change would + add churn without enabling any watch-only flow. Revisit as its own + design+implementation slice when a hardware/watch-only host exists. +17. **G6:** fix/delete fallback contract id. + + **DONE (2026-06-13):** fallback corrected from the DPNS id to the + deployed DashPay id. +18. **G7:** wire send-path validation; ship-or-delete auto-accept (verify-gate + acceptance criterion applies if shipped — see G7). + + **DONE (send half, 2026-06-13):** the selected key pair gates through + `validate_contact_request` before any ECDH/broadcast. Auto-accept: + decision = **keep dormant** — it activates with M5 invitations behind + the `verify_auto_accept_proof` hard gate (per Part 8.5), not deleted. +19. **G8/G9:** real local ciphertext; contract cache. + + **DONE (2026-06-13):** sent rows store the real 96-byte ciphertext off + the broadcast document; the bundled DashPay contract is cached + process-wide (OnceLock) replacing five per-call re-parses. +20. Live cross-client interop e2e (compact xpub, ECDH, accountReference) vs + reference DashPay clients (the M1 desk-check verified the formats on paper). + + **BLOCKED-EXTERNAL (2026-06-13):** requires driving real DashWallet + iOS/Android builds against a shared network — not runnable in this + environment. The M1 desk-check (research/06) + on-chain census remain + the interop evidence; the contactInfo research (research/07) found no + reference client implements contactInfo at all, shrinking the live-e2e + surface to contactRequest + payment addresses. Run manually when a + mobile test build is available. + +### Milestone 5 — Invitations (new scope, 2026-06-10; needs its own design pass) + +Onboard users who don't have Dash yet: inviter creates an asset-lock-funded +credit voucher + link (DIP-13 invitation subfeature, `m/9'/5'/5'/3'`); invitee +claims it → identity created from the voucher → invitee's contact request to the +inviter carries an `autoAcceptProof` (path `m/9'/5'/16'/timestamp'`, helpers +already implemented in `crypto/auto_accept.rs`) → auto-established contact after +`verify_auto_accept_proof` (hard gate, see G7/Part 8.5). Scope before +implementation: a research+design slice (invitation create/claim wallet flows, +deep-link format, expiry/revocation, UI) — the platform wallet has the asset-lock +and identity-registration machinery to build on but no invitation flows today. + +--- + +## Part 6 — Swift UI design (the "nice UI") + +**Decision: promote DashPay to a first-class tab (Option B).** Lower-risk Option A +(polish in place under Identities) is the fallback if tab real estate is contested, +but a "nice DashPay UI" wants its own home. All screens reuse already-wrapped +`ManagedPlatformWallet` FFI — **no new network FFI**; the one new plumbing item is +**payment history** (map the Rust `dashpay_payments` changeset overlay in the Swift +persister into a new `PersistentDashpayPayment` SwiftData model + `@Query` — today +no FFI exposes `PaymentEntry` and no SwiftData model exists for it). + +### 6.1 Navigation + +Add `case dashpay` to `RootTab` (`ContentView.swift`), between `identities` and +`contracts`. Tab icon `person.2.fill`, title "DashPay". + +``` +DashPayTabView (NavigationStack) +├─ Active-identity picker (top) — most DashPay UIs assume one active identity; +│ menu of the wallet's managed identities (DPNS name → truncated id). +├─ Profile header card → tap → ProfileView / ProfileEditorView +├─ Segmented control: [ Contacts | Requests ] +│ ├─ Contacts → ContactsView (@Query established) +│ │ row tap → ContactDetailView → "Send Dash" / alias / note / hide +│ └─ Requests → ContactRequestsView +│ ├─ Incoming (Accept / Reject) +│ └─ Outgoing (pending — NEW, currently unrendered) +└─ Toolbar: + (AddContactView) · refresh (sync) +``` + +Wire DashPay sync into the `.task` of `DashPayTabView` and/or the existing global +`GlobalSyncIndicator`: run `syncContactRequests()` then `syncDashPayProfiles()`. + +### 6.2 Screens + +**ProfileView / ProfileEditorView** (promote from `IdentityDetailView`) +- View: large avatar (`AsyncImage` w/ initial-circle fallback), `displayName`, + DPNS handle, `publicMessage`. "Edit" button. Empty state → "Set up your DashPay + profile" CTA (opens `ProfileEditorView` as a sheet — same target as "Edit"). +- Editor: `Form` with `displayName` (≤25), `publicMessage` (≤140), `avatarUrl`; + live char counters; on save fetch avatar bytes for DIP-15 hash/fingerprint; call + `createDashPayProfile` / `updateDashPayProfile(…signer:)`. + +**ContactsView** +- `@Query` established contacts (joined to `PersistentDashpayProfile` for + display). Row = avatar + (alias → displayName → DPNS → truncated id) + last + payment hint. Search bar. Pull-to-refresh = sync. Empty state → "Add your first + contact". + +**ContactRequestsView** (the **new outgoing section** is the headline UI gap) +- **Incoming**: row + Accept (`borderedProminent`) / Reject (`.tint(.red)`), + relative timestamp, sender profile. On accept → success toast + move to Contacts. +- **Outgoing**: pending sent requests (`fetchSentContactRequests` / + `getSentContactRequestIds`), "Pending" badge, sent timestamp. Currently loaded + but never shown — render it. + +**AddContactView** (restyle `AddFriendView`) +- Segmented: **Username (DPNS)** | **Identity ID**. DPNS mode: live prefix search + (`searchDpnsNames`) with result rows (avatar + name); ID mode: paste + validate + base58. Resolve → preview the target profile → "Send request" → `sendContactRequest`. + +**ContactDetailView** +- Profile header; **Send Dash** (presents the polished `SendDashPayPaymentSheet`); + payment history (from `PaymentEntry` via the `PersistentDashpayPayment` mapping — + see §6 intro); editable **alias** / **note** and **Hide** toggle, each labeled + **"This device only"** in M2 (until M3's `contactInfo` backing replaces the + label) so users don't assume sync semantics that don't exist yet. + +**SendDashPayPaymentSheet** (already polished — restyle only) +- Amount in DASH→duffs, spendable balance, over-spend block, recipient + profile/avatar/DPNS, result txid. (Memo is local-only; keep the field hidden or + label it "private note" until on-chain memo exists.) +- Zero-balance state: when spendable balance is 0 (after the async load), disable + the amount field + Send and show "Your balance is 0 DASH — top up your wallet + before sending." instead of an always-disabled interactive form. + +### 6.3 Conventions (must match house style) + +From `research/05` §5 / `SwiftExampleApp/CLAUDE.md`: +- `@EnvironmentObject var walletManager: PlatformWalletManager`, + `var appState: AppState`; `@Environment(\.modelContext)`. +- Lists via `@Query` on `Persistent*` (move off the live-snapshot read). +- Async FFI: `Task { @MainActor in … }`, `defer { isLoading = false }`, resolve + wallet via `walletManager.wallet(for:)`, fresh `KeychainSigner` per submit, + `errorMessage` red caption on catch. +- `Form`/`Section` for editors, `List`/`Section` with count headers for lists. +- SF Symbols (`person.2`, `person.badge.plus`, `paperplane`, `pencil`, + `person.crop.circle`), blue accent, red destructive, green success, + `.borderedProminent` primaries. +- Amounts entered in DASH, converted to duffs (`× 100_000_000`). +- Display precedence: alias → DashPay `displayName` → DPNS → truncated hex. +- **`.accessibilityIdentifier(...)` on every interactive control** (needed for + XCUITest) — e.g. `dashpay.tab`, `dashpay.addContact`, `dashpay.request.accept`, + `dashpay.send.amount`, `dashpay.send.confirm`. +- **Never orchestrate in Swift** — one FFI call per DashPay op. + +### 6.4 Interaction states & edge cases (normative for M2) + +- **Identity picker** (tab root) — three states: (1) no wallet loaded → disabled + "No wallet loaded" label + link to the Wallets tab; (2) wallet but zero + identities → "No identities yet" + CTA to the Identities tab; (3) ≥1 identity → + menu. Exactly one identity → auto-select and hide the picker. Selection persists + across launches via `@AppStorage`. +- **AddContactView (DPNS mode)** — four states: typing → searching (inline + `ProgressView`) → not-found (inline message + clear-and-retry affordance, never a + dead end) → found (profile-preview card; "Send request" enabled only from this + state). ID mode: inline base58 validation gates the send button. (The current + `AddFriendView` dead-ends on "DPNS name not found".) +- **Send-collision flow** — if the target already has an incoming request to us, + alert "This person already sent you a request — Accept it instead?" with + Accept / Continue anyway. (Sending anyway is protocol-valid; it just establishes + the contact.) +- **Request rows in flight** — on Accept/Reject tap, replace both buttons with a + `ProgressView` for that row (prevents double-tap → duplicate accepts); on + success remove the row optimistically; on failure restore the buttons + inline + error on the row. +- **Optimistic overlay over `@Query`** — accept/reject/send mutate Rust state and + the persister callback lands later; bridge the latency window with a local + `@State` overlay set of affected ids filtering the `@Query` results, cleared + when the query reflects the change. (The old `loadFriends()` re-read pattern is + incompatible with pure `@Query` reactivity.) +- **Single sync-in-progress signal** — one `@Published` flag on + `PlatformWalletManager` observed by all three sync callers (`.task`, + pull-to-refresh, the G12 background loop); a pull-to-refresh during an in-flight + sync attaches to it instead of double-firing. +- **Success feedback** — reuse the existing inline success pattern + (`SendDashPayPaymentSheet`'s green inline text); no new toast component in M2 + (the app has no shared toast — only a clipboard `CopiedToast`). +- **Broken payment channel** (surfaces G1(c)) — ContactsView row shows a warning + badge; ContactDetailView disables Send Dash with "Payment channel broken — ask + the contact to send a new request" (re-enables when a new request arrives). +- **Profile save flow** — on save: disable Save + inline `ProgressView`; success → + dismiss the editor sheet; failure → re-enable Save + red caption below the form. +- **Payment history list** — empty state "No payments yet"; loading = single + inline `ProgressView`; error = keep last-known list + inline caption. + +--- + +### Multi-reviewer code review (2026-06-14) — 8 findings fixed + +Five specialized reviewers (crypto-security, FFI-memory, sync-correctness, +Swift/iOS, silent-failures) audited the M1–M4 diff. Crypto + FFI-memory +boundaries came back clean. Correctness/silent-failure reviewers found bugs +the live UAT had missed (UAT only hit the pending-sent rotation path, which +the reject-tombstone masked). All fixed with red→green regression tests: + +- **P0** rotation re-send to an ESTABLISHED contact reset the version to 0 + → unique-index rejection → contact unrotatable. Lookup now consults + `established_contacts.outgoing_request` (`prior_sent_account_reference`). +- **P0** multi-doc sweep thrash: immutable docs from a rotated sender both + returned every sweep, flipping state + rebuilding the external account + forever. `newest_received_per_sender` collapses to newest-per-sender + before ingest; `apply_rotated` is idempotent. +- **Critical** swallowed persist errors → memory/disk divergence (reject + resurrection). New `PlatformWalletError::Persistence`; reject + send_payment + propagate, self-healing sweep writes log. +- **H1** Sent payments lost at relaunch (map restored empty) → new + `PaymentRestoreEntryFFI` + `restore_dashpay_payments` fold + Swift builder. +- **H2** deferred-publish lied as "synced" → 3-state `ContactInfoPublishOutcome` + through the FFI; ContactDetailView shows the real state. +- Med: zero-ciphertext fallback → hard Err; contactInfo derivation-index + high-water mark; Swift silent contact-drop now logged; crypto + account_index/accountReference invariant documented. + +230/230 Rust lib + FFI tests green, clippy clean, full iOS build green. +NOTE: on-device re-verify of H1/H2 pending — the sim SwiftData store was +reset environmentally (identities gone), so it needs the devnet identity +setup rebuilt first. + +### Devnet UAT round 2 (2026-06-13) — rotation / reject / DPNS verified live + +On paloma with three identities: **reject + tombstone** (rejected request +suppressed across forced re-sync), **G3 rotation end-to-end** (re-send from +the rejected sender broadcast with a bumped accountReference — accepted by +the unique index — and reappeared through the tombstone on the recipient: +the dp_005 scenario, live), **DPNS register → live search → found preview → +send** and the **not-found state** (inline + retry, no dead end), **accept +of the rotation request** (re-established, accounts rebuilt). Findings +fixed: optimistic pending-sent overlay leaked across identity switches +(now reset on picker change). Open UX item: "SPV client is not running" +dead-ends both Send Dash and identity creation — needs auto-start or a +"Start & retry" affordance (product decision pending). + +## Part 7 — Test plan + +Follow the repo TDD discipline (failing test first; red→green in the commit +message). DashPay's correctness-critical pieces are the crypto and the +state-machine handshake — those get the deepest coverage. + +### 7.1 Rust — `rs-platform-wallet` / `rs-sdk` / `platform-encryption` + +Already covered (keep, don't duplicate): crypto round-trips, the contact +state-machine handshake (`tests/contact_workflow_tests.rs` + inline), and +persistence (`wallet/apply.rs`). See G11 for the inventory. + +Unit — **the missing tier is the `network/` layer** (currently 0 tests). Add behind +a mock SDK/broadcaster seam: +- **Recurring sync (G12):** a recurring pass drives `dashpay_sync` for each wallet + — including identities with zero watched tokens (see G12: do not couple to the + token registry); re-entrancy guard + `quiesce` shutdown still hold; interval + changes are picked up. +- **Sync builds external accounts (G1):** given an established contact with an + encrypted xpub, the sync pass decrypts it and registers a `DashpayExternalAccount` + (and skips gracefully for watch-only). +- **Crypto/derivation wiring:** `calculate_account_reference` is actually used by + the send path (G3) and round-trips (un-mask recovers account + version). +- **State machine** (extend existing): idempotent re-sync; accept when both present; + reject removes incoming. + +Offline crypto/encode tier (rs-sdk, no network) — follow the existing +`packages/rs-sdk/tests/fetch/` harness with `--features mocks,offline-testing` +(`Config` from `tests/.env`, `mock::Mockable` + recorded vectors): +- **G2 (entropy):** after `create_contact_request`, the returned id matches the id + derived from the *broadcast* entropy. Pin id equality. +- contact-request wire-shape: `encryptedPublicKey == 96B`, properties map matches + the v1 schema, `accountReference` round-trips. + +**E2E tier — build on the existing framework (PR #3549, +`packages/rs-platform-wallet/tests/e2e/`).** This is the canonical "how we do e2e" +for this crate (see [Part 7.4](#74-alignment-with-the-existing-e2e-framework)): +gated behind the **`e2e` cargo feature**, funded by the testnet **`bank` wallet** +harness (`framework/bank.rs` — `BankWallet::load`, `fund_address`, +`cross_check_balance`), config via `tests/.env` +(`PLATFORM_WALLET_E2E_BANK_MNEMONIC`), one file per case under +`tests/e2e/cases/_NNN_*.rs` registered in `cases/mod.rs`, run with +`cargo test -p platform-wallet --test e2e --features e2e -- --nocapture`. Add a +**DashPay case family** (proposed prefix `dp_*`), modeled on the shielded `sh_*` +suite (PR #3727) which stacks the same way: + +- **dp_001 (profile):** `create_profile` → fetch from Platform → fields match; + `update_profile` bumps revision. +- **dp_002 (send request):** fund 2 bank-derived identities; A + `send_contact_request(B)`; assert the on-platform `contactRequest` + (`encryptedPublicKey==96B`, key indices, accountReference) + id equality (G2). +- **dp_003 (full cycle — the "done" gate):** A `send_contact_request(B)` → B + recurring-sync sees incoming → B `accept` → **both** established → A + `send_payment(B)` confirms on L1 → B records incoming. +- **dp_004 (offline accept → pay, pins G1+G12):** A sends → B accepts → A offline → + A's **recurring sync** runs → A `send_payment(B)` **succeeds** (external account + built during the sweep). *Must fail before the G1/G12 fix, pass after.* +- **dp_005 (rotation, pins G3):** second `send_contact_request` to the same + recipient with a bumped version is accepted (distinct `accountReference`); the + receive path surfaces the rotation. +- **dp_006 (recurring cadence):** the background recurring sync refreshes + contacts/profiles without an explicit FFI call — including for an identity with + zero watched tokens (assert via the bank harness over a couple of sweeps). + +### 7.2 Swift — `SwiftTests` + `SwiftExampleAppUITests` + +Unit (`SwiftTests/SwiftDashSDKTests/`): +- `ContactRequest(ffi:)`, `EstablishedContact`, `DashPayProfile(ffi:)` / + `DashPayProfileUpdate` round-trips and marshalling (32-byte id in/out, optional + C-strings, `_free` correctness, no leaks). +- `PersistentDashpay*` SwiftData upsert from the persister callback. + +Flow (mirror the existing `PlatformWalletIntegrationTests.swift` harness, testnet): +- send → sync → accept → established → pay, asserting SwiftData rows + balances. + +XCUITest (`SwiftExampleAppUITests/`, keyed on accessibility ids): +- Open DashPay tab → AddContact by DPNS → request appears in Outgoing → (peer + accepts) → appears in Contacts → open contact → Send Dash → confirm txid. +- Use the `simulator-control` skill for SwiftData inspection + screenshots in UAT. + +### 7.3 "Definition of done" per flow + +| Flow | Done when | +|---|---| +| Create/update profile | `dp_001` + Swift editor XCUITest green; profile visible to peer | +| Send contact request | `dp_002` + G2 (entropy) offline test + AddContact XCUITest green | +| Approve request | `dp_003` accept step → both established; Accept XCUITest green | +| Reject request | local reject unit test green; (M3) `contactInfo` hide syncs across devices | +| Send money to contact | `dp_003` pay step + **`dp_004` (offline accept→pay)** + Send XCUITest green | +| Sync (recurring) | `dp_004`/`dp_006` build external account on the recurring sweep; idempotency unit test green | + +### 7.4 Alignment with the existing e2e framework + +The platform-wallet e2e framework **already exists but is unmerged** — PR +**#3549** (`feat/rs-platform-wallet-e2e`, draft). DashPay e2e cases must be authored +**on that branch** (or rebased onto it after it merges); they are not standalone. +Conventions to follow exactly (from `tests/e2e/README.md`): +- Modeled on `dash-evo-tool/tests/backend-e2e/`; runs against **live Dash testnet** + (v3.0) via DAPI, gated behind the `e2e` cargo feature. +- Funding via the **platform-address `bank` wallet** (seed in + `PLATFORM_WALLET_E2E_BANK_MNEMONIC` / `tests/.env`); most DashPay cases never + touch L1 except the `send_payment` step (which spends Core funds → needs the + bank's Core balance, like CR-003/AL-001). +- Test attribute `#[tokio_shared_rt::test(shared, flavor = "multi_thread", + worker_threads = 12)]`; context provider `TrustedHttpContextProvider`. +- New cases: add `tests/e2e/cases/dp_NNN_*.rs`, register in `cases/mod.rs`, document + in `tests/e2e/TEST_SPEC.md` (pin accounting). The shielded suite (PR #3727, + `sh_*`) is the worked example of stacking a feature-area suite on this framework. + +**Sequencing implication:** the DashPay e2e suite rides #3549 — but **M1's exit +criterion is the mock-seam unit/integration tier** (no #3549 dependency), so M1 is +never blocked on the draft PR. `dp_003`/`dp_004` are the e2e *confirmation* of the +same behaviors, tracked on #3549 (authored stacked on it, or added right after it +merges). The offline crypto/encode tier likewise lands immediately. + +--- + +## Part 8 — Risks, decisions, open questions + +1. **UI shape — first-class tab vs polish-in-place.** Recommended: first-class + `DashPay` tab (Part 6). *Decision owner: product.* Fallback documented. +2. **Cross-client interop. RESOLVED (2026-06-10, desk-check + `research/06`):** xpub plaintext FAIL → G14 fix in M1 task 7; ECDH PASS; + accountReference PASS-for-now (+2 latent masking bugs noted for M3); new G15 + key-purpose hazard → verification gate in M1 task 8. Live cross-client e2e + stays M4. ⚠ A side-finding: our stack was **not** self-consistent either — + the 107-byte plaintext broke our own send path (see G14). +3. **Watch-only / hardware wallets (G4).** Out of scope for the demo app (it holds + the seed) but required for production. **FFI-hook design lands in M3 (task 15)** + — shared secret only across the ABI, never a raw private key (see G4); + implementation in M4. +4. **`accountReference` semantics (G3).** Decide whether to keep "share full + account xpub, ignore masking" (simpler, but breaks rotation via the unique + index) or implement the DIP-15 masking + version flow. Recommended: implement it + (M3) — rotation is a real user need and the unique-index collision is a latent + bug. +5. **Auto-accept (G7). DECIDED (2026-06-10): keep.** Invitations are now in scope + (Milestone 5) and are built on `autoAcceptProof`, so the helpers + FFI param + stay (dormant until M5 wires them). **Hard requirement when wired:** the + `verify_auto_accept_proof` gate before any automatic acceptance (see G7). +6. **`send_contact_request` entropy (G2). RESOLVED (2026-06-10):** real broadcast + bug — consensus rejected every send (`InvalidDocumentTransitionIdError`). + Fixed in M1 task 4; see the DONE note there. +7. **E2E framework dependency.** The DashPay e2e suite rides PR **#3549** (draft, + unmerged). **M1's exit criterion is the mock-seam tier** (Part 7.4), so M1 never + blocks on it; the `dp_*` cases are authored stacked on #3549 or right after it + merges. *Open: name the owner who decides stack-vs-wait before M1 starts.* + +--- + +## Part 9 — Related in-flight work (open PRs) + +Surfaced from the live PR list — these intersect this plan and should be tracked / +coordinated rather than duplicated: + +| PR | Branch | Relevance | +|----|--------|-----------| +| **#3549** (draft) | `feat/rs-platform-wallet-e2e` | **The e2e framework** the DashPay suite must build on (Part 7.4). | +| **#3727** (draft) | `test/rs-platform-wallet-shielded-e2e` | Shielded `sh_*` e2e suite — the **worked template** for a feature-area suite on #3549. | +| **#3787** | `codex/dashpay-dip15-contact-request-docs` | "DashPay contact request encryption guide" — cross-check against Part 2; avoid doc drift. | +| **#3639** | `feat/platform-wallet-external-signable-wallets` | External/signable wallets — the substrate for **G4** (watch-only ECDH via `ClientSide`). Coordinate before building G4. | +| **#3692** | `feat/platform-wallet-rehydration` | Watch-only rehydration from persistor — touches the same watch-only path as G4. | +| **#3817** | `feature/coinjoin-sweep-and-recovery` | DashSync→SDK migration context (the broader effort DashPay sits inside). | +| **#3750** (NO MERGE) | `feat/platform-wallet-consumer-hardening` | FFI/consumer hardening — may move FFI signatures the Swift layer depends on. | + +--- + +### Appendix — research sources (full detail, source-cited) + +- [`research/01-dip-spec.md`](./research/01-dip-spec.md) — DIP-9/11/13/14/15 + protocol reference (derivation paths, ECDH, encryption, contract). +- [`research/02-rust-dashcore-keywallet.md`](./research/02-rust-dashcore-keywallet.md) + — `key-wallet` DashPay primitives + the ECDH-absence finding. +- [`research/03-rs-platform-wallet.md`](./research/03-rs-platform-wallet.md) — + per-flow implemented/stub map + FFI surface. +- [`research/04-sdk-and-contract.md`](./research/04-sdk-and-contract.md) — v1 + contract schema + `rs-sdk`/`rs-sdk-ffi` send flow + the two SDK bugs. +- [`research/05-swift-app.md`](./research/05-swift-app.md) — app architecture, + existing DashPay surface, insertion points, conventions, test-plan seed. diff --git a/docs/dashpay/SYNC_CORRECTNESS_SPEC.md b/docs/dashpay/SYNC_CORRECTNESS_SPEC.md new file mode 100644 index 00000000000..a167133a08d --- /dev/null +++ b/docs/dashpay/SYNC_CORRECTNESS_SPEC.md @@ -0,0 +1,441 @@ +# DashPay sync correctness — contact requests **and** profiles (mirror Android `PlatformSyncService`) + +Status: **IMPLEMENTED (2026-06-18)** — both stages shipped on +`feat/dashpay-m1-sync-correctness` (PR #3841), 5-lens review (§9) folded in first. +Stage 1 = paginated retrieve-all + per-identity high-water cursor + 10-min overlap +at a 15s cadence (`network/contact_requests.rs`); stage 2 = id-keyed +`contact_profiles` cache for established + pending senders (`network/contact_info.rs`, +`accessors.rs`). Both are surfaced in the UI and **durably persisted** through the +changeset pipeline to *both* backends (SQLite persister + SwiftData); the high-water +cursor stays in-memory by design (a cold restore does one safe full re-fetch). +Owner: rs-sdk / platform-wallet +Priority: **FIRST** of the DashPay correctness track (ahead of the contactInfo +format migration and the ignore feature). + +This spec covers **two consecutive stages of the same Android sync loop**: + +| Stage | Android (`PlatformSyncService`) | Us before | Delivered | +|-------|--------------------------------|-----------|-----------| +| 1. Contact-request fetch | `updateContactRequests()` — incremental, paginated, high-water | present but **broken** (truncated at 100, no high-water) | **fixed** — retrieve-all + high-water cursor | +| 2. Contact-profile fetch | `updateContactProfiles(userIds)` — batch `whereIn $ownerId` | **absent** (synced only our *own* profile) | **added** — id-keyed cache, established + pending senders | + +Neither is an optimization: stage 1 is a **correctness bug** (real requests are +permanently buried) and stage 2 is a **missing feature** (contacts have no name +or avatar in the UI). The Android wallet (`dash-wallet`, on `kotlin-platform`) +already does both; this spec mirrors that proven design. Delivered as **two +commits** (stage 1, then stage 2) on one branch. + +--- + +## 1. Problem + +### 1.1 Stage 1 — our contact-request fetch is wrong, not just slow + +`packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs`: + +```rust +where toUserId == me, order_by $createdAt, limit: 100, start: None +``` + +`start: None` + a fixed `limit: 100`, re-run every sweep: + +- **Re-fetches the first page from the beginning every sweep** — pays the full + fetch + GroveDB proof-verify each time for data we already have. +- **Truncates at 100 and never paginates** — with ≥100 requests, newer (or, by + `$createdAt asc`, older) legitimate requests are **never fetched**. A spammer + (or a popular identity) **buries real requests permanently**. +- **No durable high-water / cursor** — no notion of "what's new since last sweep". + +### 1.2 Stage 2 — contact-profile sync is entirely absent + +`packages/rs-platform-wallet/.../network/profile.rs::sync_profiles` runs over +`identity_manager.all_identities()` — only **managed** identities (our own), +never contacts (`manager/accessors.rs:54`). So we publish and refresh **our own** +profile but **never fetch a contact's** displayName / avatar / publicMessage. The +UI shows only a raw identity id (or a local alias). Neither `EstablishedContact` +nor any incoming-request sender has a cached profile anywhere. + +## 2. The reference — Android `PlatformSyncService` + +Verified 2026-06 against `github.com/dashpay/dash-wallet` + +`github.com/dashpay/kotlin-platform` (the current JVM platform lib, +`org.dashj.platform:dash-sdk-*`; **not** the stale `android-dashpay`). One +re-entrancy-guarded ticker (`TickerFlow(15.seconds)`) runs, in order: + +``` +updateContactRequests() // stage 1: incremental, paginated, high-water + → discovers userIds (contacts + pending senders) +updateContactProfiles(userIds) // stage 2: batch whereIn $ownerId, cache by userId +checkDatabaseIntegrity()/FixMissingProfiles() // self-heal missing profiles +``` + +- Stage 1 high-water: `SELECT MAX(timestamp)` per direction; **10-min overlap + rewind**; incremental `$createdAt > afterTime` + `startAfter` cursor + + `limit(-1)` = retrieve-all. +- Stage 2 fetches profiles for the userIds drawn from contact-request rows + (**including pending incoming senders** — that's how the request UI shows a + requester's name/avatar), keyed in a `dashpay_profile` table by `userId`, + independent of relationship state. + +## 3. Goal + +1. Make our **contact-request** sync incremental, fully-paginated, + high-water-tracked, and skew-safe, for **both** directions — no truncation, + each request fetched ~once, a flood can't bury anything. +2. Add **contact-profile** sync: fetch established contacts' **and pending + incoming senders'** profiles in batches, cache them (id-keyed) so the UI shows + name + avatar on both the contacts and the requests screens, refresh, and + self-heal any missing profile without unbounded re-querying. + +## 4. Design + +### 4.1 High-water cursor (stage 1) + +**Storage (resolves Q-a).** Android keeps every contact-request row and derives +`MAX(timestamp)`. Our model collapses requests, so we can't `MAX()` a raw table. +We persist **two scalar fields on `ManagedIdentity`** — `high_water_received_ms: +Option` and `high_water_sent_ms: Option` — riding the existing +`IdentityEntry` snapshot (changeset → both persisters → FFI restore), **not** a +separate table. Two integers per identity need no relational shape. + +**The advance invariant (the heart of stage-1 correctness).** Get this wrong and +we reintroduce the burying bug. The cursor: + +1. **Advances only on a fully-exhausted, error-free paginate** of that direction. + "Exhausted" = a page returned `< limit` docs (possibly empty); a final page of + exactly `limit` requires one more fetch to confirm. **Any** fetch/proof error + mid-loop ⇒ **do not advance that direction's cursor this sweep** (leave it at + the prior value; the overlap re-fetches next sweep). +2. **Advances to `max($createdAt)` over every doc *fetched* this sweep** — + *including* docs that ingest then parse-skips, collapses + (`newest_received_per_sender`), or suppresses (ignore/tombstone). The cursor + records **fetch-completeness, not ingest-success**. Ignore `unwrap_or(0)` + sentinels: advance to the max of *present* (`Some`) timestamps only, and never + below the current value. +3. **Never stamps to wall-clock `now`.** On a zero-doc fetch the cursor is left + unchanged. States: `Absent` ⇒ query `$createdAt > 0` (full); `Present(t)` ⇒ + query `$createdAt > (t − OVERLAP_MS)`. + +**Why cursor-loss is safe (the written contract):** every collapsed / suppressed +doc is, by construction, deterministically reproducible from a full re-fetch of +the immutable on-chain set. So **under-shoot is free** (a lost/low cursor just +triggers one full re-fetch; ingest is a fixpoint) and **over-shoot buries**. +Therefore **restore tolerates only under-shoot**: on any restore-consistency +doubt, clamp the cursor to `min(persisted, max($createdAt) over restored contact +rows)`, or reset to `0`. A restored-too-high cursor is a correctness bug. + +**`OVERLAP_MS` is correctness-load-bearing, not cosmetic.** The lower bound is +exclusive (`>`) and the `userIdCreatedAt` index is non-unique on `$createdAt`, so +multiple requests can share a `$createdAt` at a page boundary. The overlap is +what re-includes them; **`OVERLAP_MS = 0` is an invalid configuration**, not a +tuning knob. Default `10 * 60_000` (copy Android). + +### 4.2 The request query (rs-sdk, stage 1) + +`fetch_received_contact_requests` / `fetch_sent_contact_requests` gain +`after_created_at: Option` + cursor pagination: + +```rust +where: [ toUserId == me, $createdAt > (high_water − OVERLAP_MS) ] +order_by: $createdAt asc // REQUIRED — binds the userIdCreatedAt index and + // avoids the "verified-absent" proof trap +start: StartAfter(last_doc_id) // ephemeral, per-loop pagination cursor +``` + +Two distinct cursors, do not conflate: within-sweep pagination uses +`Start::StartAfter(last_document_id)` (a 32-byte doc id, per-loop); the **durable +high-water** persists `max($createdAt)` (cross-sweep, §4.1). Loop pages until +exhausted (§4.1 rule 1). **Precondition (Q-c, stage 1):** before replacing the +working `limit:100` query, verify on testnet that the paginated `$createdAt > t` ++ `StartAfter` form returns a known existing doc (not a verified-absent empty +proof) — the current query's `order_by` comment documents this exact trap. + +### 4.3 Request sweep flow (platform-wallet `sync_contact_requests`) + +1. Read `high_water_received` / `high_water_sent` (Absent ⇒ full). +2. Fetch received `> (hw_received − OVERLAP)`, paginated; fetch sent likewise. +3. Ingest via the existing path — `newest_received_per_sender` collapse, ignore + suppression, auto-establish. **Idempotency is load-bearing**: the overlap + re-delivers seen docs every sweep, so ingest MUST be a fixpoint (it is). +4. **Per direction, iff its paginate exhausted without error** (§4.1 rule 1): + advance the cursor to the max `$createdAt` *fetched* this sweep (§4.1 rule 2). + On any error, skip the advance for that direction. + +### 4.4 Contact-profile fetch (rs-sdk + platform-wallet, stage 2) + +**Query (resolves Q-c stage 2 + Q-cap).** New `fetch_profiles_for(owner_ids)`: + +```rust +where: [ $ownerId In [id0, id1, …] ] // ≤ IN_CAP ids per query +order_by: [] // EMPTY — unique ownerId index, no trap +start: None // each owner yields ≤1 profile; no pagination +``` + +The `profile` doctype has a **unique single-property `ownerId` index**, so an +`In $ownerId` set lookup proves presence/absence cleanly with **empty +`order_by`** and **no pagination** (mirrors the working `profile.rs` point query, +Equal→In). `IN_CAP = 100` is a **hard cap** enforced at query-build +(`rs-drive/src/query/conditions.rs:361`); the `In` array **rejects duplicates** +(`:368`) and **rejects empty** (`:355`). So the caller **dedups** the id set and +**skips** the query entirely when a chunk (or the whole target set) is empty. + +**Target set (resolves the §4.3-vs-§4.4 contradiction): iterate the FULL set +every sweep, not "touched ids".** Each sweep, collect: +`{ established_contacts[].contact_identity_id } ∪ { incoming_contact_requests[].sender }` +across managed identities, **dedup**, and **skip ids that are themselves managed +identities on this wallet** (their profile is their own `dashpay_profile`, which +is authoritative — see §4.7). The stage-1 "touched this sweep" set is at most a +*fetch-these-first hint*, never the iteration set — the existing aggregator +discards `sync_contact_requests`'s return value anyway, and a "touched-only" set +would break both self-heal and first-run backfill (every pre-existing contact is +uncached but untouched). + +**Filter, then chunk, then fetch:** + +1. Drop ids that are **cached and fresh**, and ids that are **confirmed-absent + and checked recently** (negative cache, see below). What remains is the fetch + set — on first run after upgrade this is *every* contact (the dominant, + expected first-sweep cost; bounded by contact count). +2. Chunk the remaining ids into groups of `IN_CAP`, run one `In` query per chunk. +3. **Per-chunk log-and-continue isolation:** a chunk's fetch/proof failure logs + and continues to the next chunk; the freshness/checked markers advance **only + for ids in successfully-fetched chunks**, never sweep-wide on partial failure. + A persistently-failing chunk must not starve the others. + +**Self-heal & the no-profile negative cache.** A contact may have **no `profile` +document on-platform** (profiles are optional). The `In` query simply omits them. +Without a guard, "cached? false" stays true forever and they're re-queried every +sweep — the unbounded-retry pathology `payment_channel_broken` (G1c) exists to +avoid. So record a **confirmed-absent marker with a checked-at timestamp**; the +fetch set targets "no cached profile **and** not checked within the backoff +window". Self-heal then *is* the normal path (an uncached/expired contact re-enters +the fetch set) — no separate `FixMissingProfiles` loop. + +### 4.5 Profile storage — **Option B** (id-keyed cache) + +A new map on `ManagedIdentity`: + +```rust +pub contact_profiles: BTreeMap, +// ContactProfileEntry = { profile: Option, checked_at_ms: u64 } +// profile: Some(..) = fetched & present; None = confirmed-absent (negative cache) +// checked_at_ms: last fetch attempt, drives the self-heal backoff +``` + +Chosen over a field on `EstablishedContact` because the cache must serve **every +relationship state** — established contacts, **pending incoming-request senders** +(requests screen), and **ignored senders** (future Ignored list) — none of which +share one struct. This is the product decision (§4.6) and matches Android's +relationship-independent `dashpay_profile` table. Plumbing (the `dashpay_payments` +5-site pattern; **the two most-forgotten are the merge rule and the store-side +apply** — miss either and contacts silently vanish on relaunch): + +1. field on `ManagedIdentity`; +2. `IdentityEntry` field + `from_managed` (`changeset.rs`); +3. **merge rule** in `IdentityChangeSet::merge` — per-key last-write-wins (the + `dashpay_payments` merge at `changeset.rs:489-495` is the template); +4. FFI: a **contact-keyed** accessor (distinct from the existing identity-keyed + own-profile one), e.g. `platform_wallet_get_contact_profile(wallet, + owner_identity_id, contact_identity_id) -> profile?`, + an + `IdentityRestoreEntryFFI` field + `restore_contact_profiles` fn + (mirror `restore_dashpay_payments`, `persistence.rs`); +5. SwiftData `PersistentDashpayProfile` keyed by `(ownerId, contactId)` (mirror + `PersistentDashpayPayment`) + the **store-side write/apply**. + +**Boundary invariant:** `contact_profiles` holds **only the five public profile +fields** parsed from the on-chain `profile` document. It must never receive any +field derived from the encrypted `contactInfo.privateData` path (which carries +private relationship state — alias/note/hidden/ignore). Keep these two stores +distinct so the contactInfo migration (Spec 1) can't accidentally cross them. + +### 4.6 Scope & privacy + +**Scope (product decision): established contacts + pending incoming-request +senders now; ignored senders ride the same cache when the Ignored list lands.** +This matches Android's observable behavior (requester names in the request UI). + +**Privacy posture (resolves Q-scope).** Fetching a *pending* sender's profile is a +public read, but issuing `whereIn $ownerId [sender_ids]` right after their +requests land is a query-pattern an observer could correlate with your inbound +set. We **accept** this because the marginal leak is small: the contact-request +documents are *already public* (indexed by `[toUserId, $createdAt]`), and the +DAPI node serving our `toUserId == me` request query — which we must run — +**already learns the entire inbound set**. Fetching those public profiles adds +little. This is materially weaker than the R1 leak (which *creates a new on-chain +document* about a non-contact). Documented and accepted; the R1 track may later +minimize query-pattern metadata if desired. + +### 4.7 Cache write semantics + +- **Full-REPLACE, not merge.** A fetched profile document is the authoritative + *complete* state for that owner; storing it **overwrites** the cached entry via + `profile_from_properties` (full parse). This is the **opposite** of the + own-profile *update* path (`merge_profile_properties`, read-modify-write) — do + **not** reuse that helper here, or a contact who *removes* `avatarUrl` would + keep showing a stale avatar forever. +- **All-empty parse ⇒ confirmed-absent, not cached-present.** A doc that parses to + an all-`None` profile is treated as a negative-cache hit (§4.4), not a fresh + empty profile, so self-heal keeps it honest. +- **Persist only on change.** Compare the fetched profile to the cached one before + writing; emit no changeset when unchanged. This keeps the deferred-Q-inc + "refetch-all each sweep" first cut a **persistence fixpoint** — no write + amplification, the same discipline stage 1 enforces. +- **`avatarUrl` validation at insert.** Validate before caching: **`https://` + scheme only**, length-capped (state the contract's max). Treat the cached url as + **untrusted** input downstream — it is attacker-controlled and the UI will load + it (an unsanitized `http:`/`file:`/`javascript:` url is an SSRF / tracking-pixel + vector; a tracking url tied to your IP confirms "you have this contact"). +- **own-vs-contact authority.** If a target id is itself a managed identity on this + wallet, skip the contact fetch; that identity's own `dashpay_profile` wins. + +### 4.8 Driver wiring (`dashpay_sync.rs`) + +Add `sync_contact_profiles()` as a **distinct** step **between** the existing +`sync_profiles()` (own identities) and `sync_contact_infos()`. It is +**log-and-continue, not error-returning** (matches `sync_contact_infos` / +`reconcile_incoming_payments`): a contact-profile fetch failure degrades *display* +only and must never change the sweep's pass/fail outcome. **Do not** fold it into +`sync_profiles` — that function is scoped to `all_identities()` (own) and writes a +different store. Ordering: it must run **after** `sync_contact_requests` so a +contact established this sweep is fetched the same tick. + +### 4.9 Interactions (specify, don't discover) + +- **Un-ignore resync (deferred to the ignore refactor, but constrained here):** + un-ignore must re-fetch the un-ignored sender's requests. The + ignore/reject tombstone is keyed by `(sender, accountReference)` and **does not + store `$createdAt`**, so a *precise* "rewind the cursor past their `$createdAt`" + is **not implementable from the tombstone alone**. Therefore: **un-ignore ⇒ + clear (reset to Absent) the received cursor** → one full re-fetch (cheap, safe + per §4.1). If a targeted rewind is ever wanted, add `$createdAt` to the tombstone + first. The ignore work owns the call site; this is the mechanism constraint. +- **contactInfo-before-contactRequests ordering:** DIP-15 says fetch contactInfo + first (so contacts don't flicker on `displayHidden`). Out of scope here; noted. +- **Cursor as at-rest metadata:** the high-water timestamps are derived from public + on-chain `$createdAt`, but they are a session-activity residue at rest — exclude + the cursor (and the whole DashPay store) from iCloud backup, consistent with the + blocklist concern (BLOCK_SPEC R10). + +## 5. Non-goals + +- Changing the sweep cadence. +- The **account** half of `checkDatabaseIntegrity` (we already rebuild contact + accounts) — only the **profile** half is in scope (§4.4 self-heal). +- **Avatar image bytes / rendering** — we cache the fields (`avatarUrl` + + hashes); downloading/showing the image is app-layer (but the url is validated + at cache insert, §4.7). +- **Per-profile `$updatedAt`-incremental refetch (Q-inc).** The composite + `$ownerId In […] AND $updatedAt > marker` is **not provable in one query** (an + `In` on the first index field plus a range on the second isn't a contiguous + index range). The first cut refetches all contact profiles each sweep (bounded + by contact count, and a persistence fixpoint per §4.7); a real incremental would + be per-owner equality (loses the batch) or client-side staleness — a follow-up. + +## 6. Implementation surface + +**Stage 1 (commit 1):** +- `rs-sdk/.../dashpay/contact_request_queries.rs` — `after_created_at` + the + `StartAfter(doc_id)` pagination loop; drop `limit:100, start:None`. +- `platform-wallet/.../network/contact_requests.rs::sync_contact_requests` — + read/advance cursors per §4.1/§4.3 (advance gated on exhaustion + no error). +- `ManagedIdentity` gains `high_water_received_ms` / `high_water_sent_ms` + (`Option`) + `IdentityEntry` + merge + both persisters + FFI restore. + +**Stage 2 (commit 2):** +- `rs-sdk/.../dashpay/` — `fetch_profiles_for(owner_ids)` (empty `order_by`, `In`, + dedup, chunk at `IN_CAP=100`, skip-empty). +- `platform-wallet/.../network/profile.rs` — `sync_contact_profiles` (full-set + target, negative cache, per-chunk isolation, full-replace, persist-on-change, + `avatarUrl` validation); reuse `profile_from_properties`. +- `ManagedIdentity.contact_profiles` + the 5 plumbing sites (§4.5), incl. the + contact-keyed FFI accessor + `PersistentDashpayProfile` SwiftData model. +- `dashpay_sync.rs` — wire `sync_contact_profiles` per §4.8. +- UI bind in the **real** consumers: `Views/DashPay/ContactsView.swift` (list + row name/avatar), `ContactDetailView.swift` (header), `ContactRequestsView.swift` + (requester name/avatar), via the existing `DashPayContactMeta` / `DashPayProfileView`. + (There is **no** `FriendsView`.) + +## 7. Test plan + +**Stage 1:** +- **Incremental:** two sweeps; second issues `$createdAt > hw` and ingests only the + delta (no re-fetch beyond the overlap). +- **No-bury:** 150 requests → all eventually fetched via pagination. +- **Equal-timestamp page boundary:** N>limit requests sharing one `$createdAt` + straddling a page cut → all eventually ingested (pins the overlap as + correctness, not just skew). +- **Partial-page failure:** inject a page-2 error → the cursor does **not** advance + and the next sweep re-fetches from the old high-water. +- **Collapsed-doc reachability:** after a cursor wipe, an older-ref doc that was + collapsed away reappears (proves cursor-loss safety / under-shoot). +- **Restore over-shoot guard:** a restored cursor higher than the restored contact + rows still re-fetches the missing contacts (over-shoot clamped to under-shoot). +- **Idempotency:** overlap re-delivery creates no phantom rows / duplicate writes. + +**Stage 2:** +- **Batch/chunk + dedup:** N>IN_CAP contacts (with a duplicate id) → ⌈N/IN_CAP⌉ + chunked queries, deduped, all cached. +- **First-run backfill:** a wallet restored with M established contacts and zero + cached profiles fetches all M on the first sweep even though stage 1 ingests no + new request. +- **Pending-sender profile:** a pending incoming-request sender's profile is + fetched and reachable via the contact-keyed FFI accessor. +- **No-profile negative cache:** a contact with no on-platform profile is fetched + at most once per backoff window, not every sweep. +- **Chunk isolation:** chunk 2 of 3 fails → chunks 1 & 3 cache, chunk 2's contacts + retried next sweep (not marked done). +- **Shrinking profile (full-replace):** cache a full profile, then ingest a doc + missing `avatarUrl` → cached `avatar_url` becomes `None`. +- **Persist-on-change fixpoint:** a steady-state sweep with unchanged profiles + writes zero changesets. +- **avatarUrl validation:** a profile with a non-`https` url is rejected/sanitized + at cache insert. +- **own-vs-contact:** a contact that is also a managed identity resolves to the + own `dashpay_profile`, not a duplicate contact fetch. +- **Round-trip:** a contact profile survives relaunch (changeset → persister → + restore), like `dashpay_payments`. + +## 8. Open questions (most resolved by the review) + +- **Resolved — Q-a** (cursor storage): two scalar `Option` fields on + `ManagedIdentity` (not a table). +- **Resolved — Q-store:** Option B (id-keyed `contact_profiles`), per the + product decision (§4.5/§4.6). +- **Resolved — Q-scope:** established + pending senders; privacy accepted (§4.6). +- **Resolved — Q-c:** stage-1 keeps `order_by $createdAt`; stage-2 uses empty + `order_by` on the unique `ownerId` index, no pagination. (Stage-1 paginated + form still needs the one-time testnet proof check, §4.2.) +- **Resolved — Q-cap:** `IN_CAP = 100`, dedup, skip-empty. +- **Resolved — Q-inc:** not provable as a single batch query; deferred (§5). +- **Open — Q-b:** `OVERLAP_MS = 10 min` (copy Android) — keep, but confirm it + comfortably exceeds observed platform time-skew; **must stay > 0** (§4.1). +- **Open — Q-backoff:** the no-profile negative-cache recheck interval (§4.4) — + propose "once per N sweeps" or a wall-clock window; pick during impl. +- **Open — Q-checked-clock:** the `checked_at_ms` backoff may use wall-clock + (acceptable — it gates re-query cost, not cursor correctness) vs a sweep + counter; decide during impl. + +## 9. Review resolutions (traceability) + +Folded in from the 5-lens review (feasibility / scope / adversarial / security / +flow). The load-bearing changes vs the first draft: + +- **Cursor advance invariant rewritten** (§4.1) — advance only on error-free + *exhausted* pagination, over docs *fetched* (not *applied*), never wall-clock, + under-shoot-only on restore, overlap mandatory. Closes the two CRITICAL burying + holes (advance-past-failed-page, advance-past-collapsed-doc). +- **Cursor storage simplified** to two scalar fields, not a table (Q-a). +- **Stage-2 query shape resolved from the contract indices** (§4.4) — unique + `ownerId` index ⇒ empty `order_by`, no pagination, `IN_CAP=100`, dedup, + skip-empty (Q-c, Q-cap). Q-inc shown unprovable as a batch. +- **Stage-2 negative cache + per-chunk isolation + full-replace + + persist-on-change** added (§4.4/§4.7) — closes infinite-refetch, partial-failure + starvation, stale-field, and write-amplification holes. +- **Target set = full set (established + pending), every sweep** (§4.4) — closes + the §4.3-vs-§4.4 contradiction and the first-run-backfill gap. +- **Storage = Option B** with the full 5-site plumbing called out (merge rule + + store-apply emphasized), public-data boundary (§4.5). +- **avatarUrl validation** + **privacy posture for pending-sender fetch** (§4.6/4.7). +- **Driver hook pinned** as a distinct log-and-continue step (§4.8); **UI surface + corrected** to the real views (no `FriendsView`). +- **Un-ignore = clear-cursor** because the tombstone lacks `$createdAt` (§4.9). diff --git a/docs/dashpay/TODO.md b/docs/dashpay/TODO.md new file mode 100644 index 00000000000..96b92020cb7 --- /dev/null +++ b/docs/dashpay/TODO.md @@ -0,0 +1,497 @@ +# DashPay — TODO / backlog + +Single source of truth for outstanding DashPay work. Sources: the +kotlin-platform/dashj comparison (`KOTLIN_PLATFORM_COMPARISON.md`), the spec +track, and the multi-agent reviews. Prioritized; check off as done. + +> **STATUS (2026-06-18): the implementable backlog is complete.** Every P0/P1/P2 +> bug, the full sync-correctness spec (Spec 0/1/2 + reject→ignore refactor), the +> R1 privacy resolution, the per-contact accessor, and the comment-cleanup pass +> are done, tested, and pushed on `feat/dashpay-m1-sync-correctness`. The +> remaining `[ ]` items are **blocked on resources outside this codebase**, not +> oversights — **except one new code bug found during UAT (imported-identity +> signing — see Verification & hygiene)**: +> - **On-device UAT — DONE 2026-06-20 (testnet):** ignore-restore ✅ and wallet-wipe +> ✅ PASS; sent-payment Pending→Confirmed 🐛 FAILS. Two code bugs surfaced (both in +> Verification & hygiene): imported-identity signing, and sent-payment stuck on +> Pending. **Devnet integration tests** still need a funded harness. +> - **Encrypted profile ignored-list field** + **query-level DoS filter** — need a +> registered `dashpay` data-contract change (DIP / governance), not wallet code. +> - The struck `[toUserId, $ownerId]` GROUP-BY index is a deliberate **don't-do** +> (privacy guardrail R6), kept unchecked as a do-not-reintroduce marker. + +--- + +## P0 — bugs (functional / data-loss; fix soon) + +- [x] **`update_profile` is destructive — wipes sibling fields.** Fixed + (`0ad99d0282`): read-modify-write — seed the property map from the existing + doc's `properties()`, overlay only provided fields, build the returned profile + from the merged state (the local cache was wiped too). `profile.rs`. +- [x] **Sent payments stuck on `Pending` forever.** Fixed (`245d9da0e3`): wired a + sender-side confirm path — a confirmed `TransactionDetected` re-detection flips + the `Sent` entry `Pending→Confirmed` in place. `payments.rs`, `core_bridge.rs`. +- [x] **Contact-request fetch truncation: DONE** (Spec 0 stage 1, `3f2051e8b3`) — + paginated retrieve-all + incremental high-water cursor; no burying. +- [x] **Contact-profile sync: DONE** (Spec 0 stage 2 + UI + durable persistence, + `1f53897b63`/`b1936a7312`/`87d6cc733d`) — id-keyed `contact_profiles` cache for + established + pending senders, displayed in the UI, survives restart. +- [x] **Contact-profile chunk fetch fails: missing `orderBy` on the `In` range — + FIXED** (`c583c78c81`). Found during the 2026-06-23 two-simulator on-device + acceptance: every contact-profile sweep logged `Failed to fetch a contact-profile + chunk; will retry next sweep` with DAPI error `missing order by for range error: + query must have an orderBy field for each range element`. Root cause: + `fetch_contact_profiles_chunk` built a `$ownerId In [chunk]` query + (`WhereOperator::In`, a range op) with `order_by_clauses: vec![]` — DAPI requires + an `orderBy` on every range field. Fix: extracted `contact_profiles_chunk_query` + and added `OrderClause { field: "$ownerId", ascending: true }` (`$ownerId` is + unique, so ordering can't change the ≤1-per-owner result set). Regression test + asserts every range where-clause carries a matching orderBy, guarded non-vacuous + (✖ before, ✔ after). The single-id `Equal` query (`fetch_profile_document`) was + unaffected (equality is not a range). Unrelated to the seed-elimination work. + +## P1 — interop (cross-client correctness) + +- [x] **`accountReference` ASK28 byte-order — RESOLVED: keep ours** (`c47314a90c`). + An iOS-stack diff found iOS dash-shared-core and our Rust are *algebraically + identical*, and that DIP-15 makes `accountReference` a one-time-pad obfuscation + **recipients MUST ignore** — so the 4-way convention split (ours/iOS, Android + `le[0..4]>>4`, dash-evo-tool/DIP-literal `be[0..4]>>4`) has **no on-chain interop + failure** and no canonical value. Keep ours (matches iOS, the dominant wallet); + documented the split + added an ASK28 KAT. +- [~] **Friendship path hardcodes `account'` = `0'`** — **upstream fix submitted: + [rust-dashcore#813](https://github.com/dashpay/rust-dashcore/pull/813).** + key-wallet's `AccountType::derivation_path()` discarded the `index` field and pushed + a fixed `0'`; #813 honors `*index` (red→green test, backward-compatible for acct 0). + **Framing corrected:** this is NOT a counterparty-interop break — the recipient pays + from the *shared xpub* and ignores `accountReference` (per DIP-15 + our code), so a + multi-account counterparty doesn't affect us. The real limitation is that *we* can't + run multiple DashPay accounts → it's the **same item as multi-account (P2)**. Remaining + after #813 merges: bump the key-wallet rev + thread the real account through our callers + (`register_contact_account(.., account)` etc., currently hardcoded `0`). +- [x] **`encryptedAccountLabel` padded to ≥16 chars: DONE** (`2419159bb3`). Pad with + trailing spaces on encrypt (kotlin `padEnd(16)`) so the ciphertext clears the + 48-byte contract floor; trim on decrypt; always emit. Tests pin it. + +## P2 — parity gaps / hardening + +- [x] **Per-contact tx-history accessor — DONE.** `payments_for_contact(contact)` + filters `dashpay_payments` by `counterparty_id` (covers BOTH sent and received, + since `send_payment` records the counterparty at send time — the "sent reverse + lookup" the old SPV `match_in_collection` lacked is already on `PaymentEntry`). +- [x] ~~Key selection AUTHENTICATION fallback~~ **RESOLVED: deliberately NOT added.** + Reusing a signing (AUTH) key for ECDH is poor key separation, and no live client + population needs it (research/06 §G15: every observed recipient has a DECRYPTION + or ENCRYPTION key). Documented at `select_recipient_key_index`. (We accept not + sending to an identity that has *only* an AUTH key; kotlin's fallback is a + security smell, not a parity gap worth matching.) +- [x] **ECDH known-answer test: DONE** (`4ae8504a2b`). KAT recomputes the shared key + by hand (`SHA256((y&1|2)‖x)`) for fixed keys + pins symmetry — locks the byte + convention. +- [~] **Multi-account contacts** — **DEFERRED (conditional, not a requirement).** The + DIP-15 codec now carries `accepted_accounts` (Spec 1), but nothing populates it; + widen only if simultaneous multi-account contacts become a real requirement. Shares + the upstream derivation-path fix [rust-dashcore#813](https://github.com/dashpay/rust-dashcore/pull/813) + (P1 above) — that PR is the enabling prerequisite for any non-zero DashPay account. +- [x] **rs-sdk-ffi `DashSDKContactRequestResult` entropy: DONE** (`514b32ebd1`). + Added an inline `entropy: [u8;32]` field for generic embedders. +- [x] **contactInfo fetch pagination: DONE** (`e757d9a528`). `send address-reuse` — + **DEFERRED (minor):** only bites if SPV drops our own broadcast; `mark_address_used` + at broadcast is a small hardening with no observed incidence — revisit if it occurs. +- [~] **QR-based auto-accept (DIP-15) — IMPLEMENTED (2026-06-24), iOS-first reference + impl.** Research in `QR_AUTO_ACCEPT_RESEARCH.md`; reviewed spec in + `QR_AUTO_ACCEPT_SPEC.md`. Built faithfully to DIP-15 wire formats (no Android client + verifies `autoAcceptProof`, so it works iOS↔iOS and sets the convention). Commits: + crypto primitives `b8ff05c6f8`, receive/auto-accept flow `a714b4e8de`, owner QR-create + + scoped raw-key export `b39e0cf6f0`, scanner + drain-signer `ff2403d7a1`, Swift UI + `e6b7468d87`. Three roles: owner derives `m/9'/5'/16'/expiry'` + builds + `dash:?du=…&dapk=…`; scanner decodes + signs `$ownerId+toUserId+accountReference` + + sends with the proof; recipient's signer-present drain (`drain_auto_accepts`) verifies + (provider pubkey, no resident seed) + expiry-checks + auto-accepts. Security must-fixes + folded: consensus-authenticated sender binding, no expired-but-valid foot-gun, drain + verdict mapping, queue bound + verify-before-fetch. **Tests:** platform-wallet 299 + + ffi 117 green (cross-actor sign→verify, blob/URI codecs, AutoAccept count); `build_ios.sh` + green. **On-device:** the My-QR UI + DPNS-name guard verified; the full QR-generate→scan→ + auto-accept loop wasn't exercised because both available devnet identities lack a + *locally-cached* DPNS name (names are on-chain but not in `PersistentIdentity.dpnsName`; + imported/edge-case wallets — a create-in-app identity has it set). **Follow-up (P3):** + make `build_auto_accept_qr` resolve the owner's DPNS name on-chain when the local field + is empty, so the QR works regardless of local-name caching (and unblocks the on-device + full-loop test). Keep `auto_accept.rs` (extended, not deleted). +- [ ] **DashPay Invitations (DIP-13) — NEXT (queued 2026-06-24).** The shipped, + interoperable onboarding feature: an existing user funds an AssetLock and shares a + `dashpay://invite?du=…&assetlocktx=…&pk=&islock=…` deep-link (web fallback + `invitations.dashpay.io`); the recipient claims it — rebuild the AssetLockTransaction, + decode the embedded WIF, and register a brand-new identity from the funded invite + (DIP-13 sub-feature `3'` invitation funding keys). Separate, larger feature than + auto-accept (L1 funding + identity registration + deep-link + UI); matches dash-wallet + so invites interop. Research the in-repo asset-lock-funding / identity-registration + building blocks (register_from_addresses, FundFromAssetLock coordinators) for reuse. + +## Spec / design track (in order — sync is FIRST) + +- [~] **Spec 0 — `SYNC_CORRECTNESS_SPEC.md`** (**REVIEWED**; resolutions folded + in §9). **Rust core of both stages implemented, reviewed (2-lens: all 8 + invariants upheld, no prod unwraps), fixed, committed** — stage 1 pagination + + high-water cursor (`3f2051e8b3`), stage 2 id-keyed contact-profile cache + (`1f53897b63`), cadence 60→15s (`a06fdd00a0`), review fixes (`ef35ca55cb`). + Cursor + `contact_profiles` are **in-memory** (survive a session; reset on cold + restart = one safe full re-fetch). + - [x] **Contact-keyed FFI accessor + UI bind** (`b1936a7312`): + `platform_wallet_get_contact_profile(owner, contact)` + `getContactProfile` + Swift wrapper; the five `cachedProfile`/profile reads (ContactsView, + ContactDetailView, ContactRequestsView, AddContactView, SendDashPayPaymentSheet) + now read the contact cache (own-profile fallback for self-contacts). Verified by + a clean `build_ios.sh --target sim` + app build. Stage 2 displays end-to-end. + - [x] **Durable persistence — Rust changeset layer** (`06053bf589`): + `contact_profiles` on IdentityEntry + from_managed + merge + apply (LWW per + contact id); `sync_contact_profiles` emits one changeset/owner on change. + Round-trip test pins survive-snapshot→apply + full-replace overwrite. So + contact profiles already round-trip cross-device / replay. + - [x] **Durable persistence — host-FFI layer** (`87d6cc733d`): `contact_profiles` + now round-trips to SwiftData — `ContactProfileRowFFI`/`ContactProfileRestoreEntryFFI` + arrays on `IdentityEntryFFI`/`IdentityRestoreEntryFFI` (+`restore_contact_profiles`), + `PersistentDashpayContactProfile` model + handler store/restore. Memory-safety + audited (no double-free/leak/UAF) + contact-id length guard on restore. Verified: + 110 FFI tests + `build_ios.sh` BUILD SUCCEEDED. The high-water cursor stays + in-memory by design (reset → one safe full re-fetch). + - [ ] **Devnet integration tests** (need a paginated mock/real harness): >100 + no-bury, partial-page-no-advance, equal-`$createdAt` boundary, In-query proof + binding (Q-c stage-1 testnet check). + > **MODEL DECISION (2026-06-17): collapse reject + block + ignore into ONE + > concept — `ignore` (per-sender mute, = block, reversible). DROP per-request + > reject.** Rationale: reject's only justification (don't suppress a legit + > rotation) is thin — if you ignored the person you ignored them; un-ignore + > covers "changed my mind"; and it matches Android (Accept/Ignore, no reject). + > Keep **established-contact rotation** (re-keying a friendship) separate and + > untouched — that's not suppression. + +- [x] **Spec 1 — contactInfo `privateData` CBOR → DIP-15 varint: DONE.** Rewrote + `crypto/contact_info.rs` to the DIP-15 Dash-message format (`version` + major<<16|minor u32 LE, varstr `aliasName`/`note`, `displayHidden` u8, + `acceptedAccounts` varInt-count+u32[]); tolerant decode (unknown **major** ⇒ + discard, unknown **minor**/trailing ⇒ ignore), padded to the 48-byte ciphertext + floor. Verified against canonical `dip-0015.md` with a **byte-vector** test + + round-trip / forward-compat / major-reject / truncation (8 tests). Struct gains + `accepted_accounts`; dropped the now-dead `ciborium` dep. +- [x] **Spec 2 — Ignore (per-sender mute) — LOCAL-ONLY: DONE** (`62b7ad1875`). + Refactored the per-request reject machinery → per-sender `ignored_senders` + (`ignore_sender`/`is_sender_ignored`/`unignore_sender`); sync suppresses ALL of + an ignored sender's requests incl. rotations; established-contact rotation + untouched; `removed_incoming` still emitted. FFI `restore_dashpay_ignored` + + `platform_wallet_(un)ignore_contact_sender`. No on-chain artifact (R1). Reviewed + (correctness + FFI memory-safety audit); fixed a TOCTOU where a sync sweep could + clobber the un-ignore cursor rewind (`advance_if_unchanged`, unit-pinned). 273 + + 110 tests + iOS build green. Cross-device deferred to the encrypted-profile + contract item below. + - [x] **Ignored list (UI + state)** — `IgnoredContactsView` (`@Query`-driven, + name/avatar via `getContactProfile`, Un-ignore); `PersistentDashpayIgnoredSender`. +- [x] **Refactor: collapse `reject` → per-sender `ignore`: DONE** (folded into + Spec 2 above — `rejected_contact_requests`→`ignored_senders`, renamed across + Rust/FFI/SwiftData/Swift; `apply_rotated_incoming_request` UNCHANGED). +- [x] **R1 privacy investigation — RESOLVED (2026-06-18): non-established ignore is + LEAKY → go local-only.** A `contactInfo` about a non-established sender leaks who + you ignored: its public `$createdAt`/`$updatedAt` (enumerable via the + `ownerIdAndUpdatedAt` index) correlates with the inbound `contactRequest`'s + `$createdAt` (public `userIdCreatedAt` index) → re-identifies the encrypted target, + plus a count leak. DIP-15's ≥2-established-contacts gate doesn't cover a *fresh* + non-established sender (no ambiguity — exactly the "trivial linking" it warns of). + **Decision: ignore is local-only; cross-device sync goes through a future encrypted + field on the `profile` doc (Contract track), whose update timing is conflated with + normal profile edits.** + +- [~] **Seed elimination — Q2 = remove the `attach_wallet_seed` workaround.** + Spec `SIGNER_SEED_ELIMINATION_SPEC.md` (design, REVIEWED v3, + Q2 status banner); + live tracker `SEED_ELIMINATION_HANDOFF.md`. **Done + verified** (platform-wallet + 292/292; glue builds host + iOS-sim): the whole seedless contact-request flow — + send/accept/drain/always-enqueue sweep, `ContactCryptoProvider` + signer host + primitives (ECDH/accountReference/contactInfo seal-open/wrong-seed), deferred-crypto + queue + SQLite, C3 (resident ECDH path deleted). Discovery is resolved via the + KEPT carry-scalar (not a rewrite). + **(4-lens plan review folded in — see the spec's Q2 banner for the MUST-FIX detail.)** + - [x] **#7 contactInfo seedless — DONE** (`0da8ca5ddb`, `413229f048`, `15ca790aad`). + `contact_info_seal`/`open` on the provider (real-auth-path parity test); publish does + find-existing via `open` + encrypt via `seal` (shared fetch split into key-free scan + + resident wrapper); FFI gains `core_signer_handle`; sweep enqueues `ContactInfoDecrypt` + (seedless) / decrypts resident (resident-key types); drain op implemented (re-fetch + + open + apply + confused-deputy guard). `derive_contact_info_keys` KEPT for resident-key + types (per the discovery decision). Full re-fetch+decrypt is testnet-validated. + - [x] **Discovery/loading — confirmed NO library change needed.** External-signable + wallets already route through `discover_from_master` (via `resolve_master_from_resolver`); + the `ResidentWallet` variants stay for resident-key wallet TYPES. carry-scalar kept. + - [x] **De-dup — DONE** (`db18688545`): dead `dash_sdk_dashpay_*` rs-sdk-ffi surface + deleted (−743); the 4 inline provider constructions collapsed to one + `resolver_contact_crypto_provider` helper. + - [x] **§4.9 deletion of `attach_wallet_seed` — DONE** (`fe3ab74e19`): deleted the lib impl + (`manager/attach_seed.rs`) + FFI export + dual-gate/`mem::swap` + 9 tests, and wired the + atomic replacement in the same commit — `PlatformWallet::verify_seed_binds` + + `platform_wallet_verify_seed_binds_to_wallet` FFI (signer-derived BIP44-0 xpub vs the + persisted one; mismatch → `SeedMismatch`). `register_contact_account` C3'd to non-`Option` + (`a4270484d6`); test wallets made seedless (`c42d2e413e`). §4.2 `WipingXprv` ported to the + sibling FFI (`feb266fd1b`). `git grep attach_wallet_seed` empty (outside docs). + - [x] **Swift — DONE** (`70aaf32f9f`): `unlockWalletFromKeychain` verifies-binds + + background-drains (no re-attach); send/accept/contactInfo thread `core_signer_handle`; + `KeychainSigner.sign(...)->Data?` nil-swallow + its `Signer` protocol requirement removed. + - [~] **On-device acceptance — VALIDATED on devnet `paloma` cross-device** (two simulators, + idb): seedless Core payment + IS-lock, identity registration, DPNS name, profile publish, + contactInfo publish (create+update), and contact-request **send + accept** across two + wallets — all via the resolver, no resident seed. **Remaining (manual):** (a) wrong-seed + rejected LOUD on-chain (covered by the `verify_seed_binds` unit test; on-chain trigger + needs a destructive wipe+reimport); (b) same-owner cross-device contactInfo *decrypt* + (publish validated; decrypt unit-tested) — needs the same wallet on 2 devices. + - [ ] **Q2 multi-reviewer follow-ups (deferred; 6-lens review 2026-06-23).** The + blocking/quick items were fixed in the post-review batch (dead `verify_binds_to_xpub` + + `WrongSeed` deleted; `None`-account + verify-FFI marshalling tests added; `WipingSecretKey` + panic-window guard; neutral drain log; SeedMismatch-vs-transient unlock branch; doc/spec + fixes). Deferred: + - [x] **needs-unlock / verify-failed UI signal — DONE** (`9963923e05` Rust+FFI, + `841802c587` Swift). Spec + 4-lens review in `NEEDS_UNLOCK_SIGNAL_SPEC.md`. + Diverged from "through persistence like `paymentChannelBroken`" (the review found + that path isn't buildable today — the per-wallet restore is blocked upstream — and + would persist self-healing state): instead a pollable FFI count getter + a per-wallet + `@Published DashPayUnlockStatus` (one Equatable struct: `pendingAccountBuilds`, + `seedMismatch`, `draining`) + a `DashPayTabView` banner. Critical review catch (M1): + the count tracks **only** account-build ops (`RegisterReceiving`/`RegisterExternal`), + excluding `ContactInfoDecrypt` which re-enqueues every sweep and would make the signal a + permanent ">0". `seedMismatch` set from the verify FFI result (scoped, not a broad catch); + drain gets an in-flight guard + MainActor hop (no more `print()`-only swallow); + `deleteWallet` purges the status (no ghost banner). On-device (devnet paloma, iPhone 17 + Pro sim): all three states verified — "2 contacts waiting" + Unlock → "Finishing…" → + cleared, and the banner does **not** re-trip after a full sweep cadence (the M1 check). + `seedMismatch` red banner is covered by the `verify_seed_binds` unit test + scoped-catch + logic (live wrong-seed import is destructive; not staged). + - [x] **`account_xpub` survives the restore round-trip — DONE** (reframed from the + reviewer's "restored-wallet verify test"). On investigation this is NOT a + `verify_seed_binds` test: `load_from_persistor` receives already-deserialized structs, + so a platform-wallet test would duplicate the create-path coverage — `verify_seed_binds` + reads `account_xpub` identically regardless of how the account was built and cannot + regress from a serde bug. The real round-trip is the FFI persister decoding + `AccountSpecFFI.account_xpub_bytes` → `ExtendedPubKey`; added + `account_xpub_survives_persist_restore_round_trip` in `rs-platform-wallet-ffi/persistence.rs` + driving the exact `encode_to_vec`→`AccountSpecFFI`→`decode_from_slice`→`Account::from_xpub` + chain (red→green verified: a store/restore bincode-config mismatch fails it). + (WipingXprv de-dup intentionally NOT tracked — two documented 6-line guards are fine; + the real fix is upstream `ZeroizeOnDrop`, see §4.2.) + - [x] **`Signer` protocol — KEPT (decided 2026-06-23, do not re-raise).** The + API-narrowing prerequisite is already done (38 `signer: KeychainSigner` call sites, + 0 `any Signer`/`Signer`), so the protocol is a harmless ~6-line vestigial `canSign` + shim with zero type-level consumers besides the `KeychainSigner` conformance. Decided + it isn't worth a churn commit — keep it. + +- [ ] **§6b — restore the deferred-crypto queue into `PlatformWalletInfo` on load.** + Reader `all_pending_contact_crypto` exists (`cfg(test)`-gated); blocked upstream by + `persister.rs` `LOAD_UNIMPLEMENTED: ClientStartState::wallets` (no per-wallet + rehydration yet — nothing to attach the queue to). Wire once that lands. (Not Q2.) +- [x] **§4.2 — error-/unwind-path scalar wipe hardening — DONE** (`feb266fd1b`): `WipingXprv` + ported to the sibling `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`; both + `master`/`derived` scrub on every exit path. The post-review batch (`ad64059ad7`) also added + `WipingSecretKey` for the leaf `SecretKey` copies in `sign_ecdsa`/`public_key`. + RESIDUAL (upstream): `non_secure_erase` is secp256k1's best-effort scrub and `secret_bytes()`/ + `to_seed()` return by-value temporaries — the complete fix is `ZeroizeOnDrop` on + `key_wallet::bip32::ExtendedPrivKey`/`SecretKey` in rust-dashcore (cross-repo, tracked here). +- [ ] **§4.8 caveat — present-but-zero-keys import** isn't covered by the xpub + self-check. The carry-scalar fix means imports now materialize keys (so it's + currently moot), but if a zero-keys import recurs, `verify_seed_binds` won't + catch it. Track as a residual. (Not Q2.) + +## Contract track (DIP / governance — later) + +These need a change to the registered `dashpay` data contract, so they're a +DIP/maintainer-coordination effort separate from the wallet work. + +- [ ] **Add an encrypted ignored-contacts field to the `profile` document + (cross-device ignore sync, privacy-bounded).** Per R1, syncing ignores via a + per-sender `contactInfo` leaks who you ignored (timing-correlation). An encrypted + list field on the **profile** — a single doc that already updates for many reasons + (display name, avatar), so an update doesn't *specifically* signal an ignore — + carries the ignored set cross-device with a bounded leak and no per-sender + existence/count leak. Needs a registered `dashpay` contract change (DIP / + governance). Until then, ignore stays local-only (Spec 2). +- [ ] **Real query-level DoS protection — filter blocked/rejected senders out + *before* fetching.** Incremental fetch (P0 #3) bounds cost but still fetches each + new request once; truly *not fetching* a known-bad sender needs a server-side + filter the current index can't serve (recipient-keyed, no `sender NOT IN`, + + Sybil). Park until there's a contract-level mechanism. *(was deferred explicitly + as "needs contract change")* +- [ ] (struck — see guardrails) the countable `[toUserId, $ownerId]` GROUP-BY index + is NOT pursued (public count proof leaks the inbound social graph, R6). + +## Cross-cutting research + +- [x] **Diff the iOS stack — DONE (2026-06-18).** Read dash-shared-core / + dashsync-iOS / kotlin-platform / dash-evo-tool / DIP-15. Result: iOS and our + Rust `accountReference` are *algebraically identical*; FOUR conventions exist but + the field is a recipient-ignored one-time pad, so there's no interop break. Fed + the P1 #1 resolution (keep ours). No canonical value exists to chase. +- [x] ~~Design question: is our reject/block complexity warranted?~~ **RESOLVED + (2026-06-17)** → collapse to a single minimal per-sender `ignore` (= block, + reversible); drop the per-request reject tombstone machinery (see the Model + Decision callout in the spec track). Android does nothing here, so we're + inventing it — keep it deliberately minimal. Remaining build work lives in + Spec 2 + the collapse-reject refactor. +- [x] ~~Reconcile research/01 vs /07 on the contactInfo format~~ **DONE + (2026-06-18)** → the contract validates `privateData` by **length only** (the + schema's "array in cbor" text is advisory documentation, not an enforced + constraint), so the encrypted plaintext format is a free convention — and **we use + DIP-15 varint** (the authoritative protocol spec; cross-client interop). `research/07 + §C`'s "the schema description wins" over-weighted an advisory note as binding. See + the DIP-15 decision in `CONTACTINFO_FORMAT_SPEC.md` + Spec 1 above. + +## Guardrails (don't do) + +- ✗ Don't write a byte-exact cross-client test on `avatarFingerprint` — it's a + perceptual hash; pixel pipelines differ (greyscale average vs luma, resize + filter). Use Hamming distance if testing interop at all. +- ✗ Don't re-introduce the countable `[toUserId, $ownerId]` GROUP-BY index + (struck — its public count proof leaks the inbound social graph; R6). + +## Verification & hygiene + +- [x] **On-device UAT of the PR #3841 fixes — DONE 2026-06-20 (testnet).** Ran the + three checks end-to-end on the iPhone 17 Pro sim against testnet, with SwiftData as + ground truth. Two passed, one surfaced a bug (below). + - **✅ ignore-restore — PASSES.** Bob → contact request → Alice; Alice taps Ignore → + `ZPERSISTENTDASHPAYIGNOREDSENDER` row (sender=Bob, owner=Alice). After **relaunch + + a DashPay sync** the ignored row persists and Alice's Requests shows "No pending + requests" — Bob never reappears; the contact-request count drops (Spec 2 + `removed_incoming`). Verified on the **SwiftData backend** (the iOS persister; the + SQLite/`rs-platform-wallet-storage` backend is the Rust persister, covered by its + unit tests, not exercised on-device). + - **✅ wallet-wipe — PASSES.** "Delete Wallet" cleared **every** table to 0 — wallet, + identities, public keys, and all 5 DashPay tables (profiles/requests/payments/ + contact-profiles/ignored) + accounts/TXOs/transactions; zero residual plaintext + (no "Alice"/"Bob" display names). Seed-zeroize confirmed: relaunch shows **no + recovery prompt** = the mnemonic was removed from the keychain. (NB: the dev + "Settings → Manage Local Data → Clear All Data" is a platform-cache clear only — it + does NOT touch wallet/identity/DashPay data; the real wipe is "Delete Wallet".) + - **🐛 sent-payment Pending→Confirmed — FAILS (see bug below).** + - Harness notes for re-runs: had to force `defaults write -g AppleKeyboards + "en_US@hw=US;sw=QWERTY"` (the sim inherits the host's Russian HW-keyboard layout, + Cyrillic-izing idb `text`). Imported identities can't sign (separate bug below), so + the run used two **in-app-created** identities A=`uatalice0619.dash` / + B=`uatbob0619.dash`. Asset-lock amount field in the create flow APPENDS (no + select-all) — clear with N×backspace then type. Min lock 0.0025 → ~36M credits is + too little for a DPNS name (needs ~81.7M); fund ≥0.01 DASH. + +- [x] **🐛 BUG (found in UAT 2026-06-20): sent DashPay payment stuck on Pending — FIXED + (code + red→green test; pending on-device re-verify).** **Root cause = event-routing + gap (candidate (a)), NOT the txid split (candidate (b)).** The bridge ran the DashPay + payment hooks only on `WalletEvent::TransactionDetected`, which fires *only* for the + first **mempool** sighting (context `Mempool`/`InstantSend` → `is_confirmed()` is + false → the confirm hook early-returns). A wallet sees its *own* broadcast in the + mempool first, so the tx reaches a confirmed context only when a block mines it — + delivered as `WalletEvent::BlockProcessed` (the tx in `updated` = "previously-known + records that just confirmed"). The bridge consumed `BlockProcessed` for the core + changeset (hence `ZPERSISTENTTRANSACTION` advanced to inBlock) but never ran the + payment hooks on those records, so the `Sent` entry stayed `Pending` forever. The + txid representation split (candidate (b)) is real in SwiftData but **irrelevant to the + Rust match**: both `send_payment` (insert) and the confirm path (lookup) key by + `dashcore::Txid::to_string()` (display hex), and the SwiftData payment-row txid is + restored as the same display hex — Rust never compares the payment-table txid against + the transaction-table (internal-byte) txid. **Fix** (`core_bridge.rs`): route the + records carried by `BlockProcessed` (`inserted` ∪ `updated`; `matured` excluded) to + the same `record_incoming_dashpay_payments` + `confirm_sent_dashpay_payment` hooks via + a new `dashpay_payment_records` / `run_dashpay_payment_hooks` seam. **Tests** (red→green): + `block_processed_confirms_sent_payment` (payments.rs, end-to-end through the real + dispatch) + `dashpay_payment_records_covers_block_processed_inserted_and_updated` + (core_bridge.rs, routing) — both ✖ before the `BlockProcessed` arm, ✔ after. Full + `platform-wallet` lib suite 281/281 green; clippy clean. + **On-device verified (2026-06-20, testnet, iPhone 17 Pro sim):** re-imported the wallet + (rediscovered the on-chain identities + reconstructed the Alice↔Bob established contact + from chain — no identity signing needed, since a DashPay payment is a Core tx signed by + the wallet's BIP-44 keys, so bug #1 does not block it), sent 0.001 DASH Alice→Bob. + `platform_wallet/run.log` shows the confirm hook firing + (`Confirming sent DashPay payment owner=D8VCf8Lo… txid=3938e6b7…`) when block 1499379 was + processed via `BlockProcessed`, and `ZPERSISTENTDASHPAYPAYMENT.ZSTATUSRAW` reached `1` + (Confirmed). **The fix works.** + - **Secondary gap surfaced (follow-up, not the primary bug):** the confirm hook flips the + *in-memory* map and emits a changeset, but the Swift side never persists DashPay payments + from the changeset/store path — `dashpay_payments_overlay` is carried in the Rust + changeset yet has **no FFI persister callback**. Payments reach SwiftData only via the + pull-based `refreshDashPayPayments` (FFI getter → upsert), triggered solely by + `ContactDetailView` (`.task` / `onSent` / manual Refresh) — NOT by the recurring DashPay + sync. So the confirmed status shows after re-opening the contact / tapping Refresh, but + does **not** auto-update while the user watches the screen. Fix options: (A) call + `refreshDashPayPayments` for eligible identities inside the recurring DashPay sync + (Swift-only, pull-based, matches the existing pattern); (B) surface + `dashpay_payments_overlay` via a new FFI persister callback + Swift handler (push-based, + immediate). **FIXED via option A + verified on-device (2026-06-20):** added + `ContactDetailView.onChange(of: walletManager.dashPaySyncIsSyncing)` falling-edge → + `refreshPayments()` (mirrors the sibling `ContactsView`/`ContactRequestsView` idiom; + the 1s poller in `PlatformWalletManager` mirrors the FFI `isDashPaySyncing()` for the + recurring Rust loop too, so the falling edge fires on every recurring pass). Verified: + sent a 2nd payment, stayed on the contact screen with **no manual Refresh** — the + confirm hook fired (`Confirming sent DashPay payment … txid=5fa9e036…`) and the row + auto-flipped to **Confirmed** in SwiftData + the UI (`Payments (2)`, both "Sent … + Confirmed"). No automated test (UI-only SwiftUI modifier; on-device verified per the + CLAUDE.md UI exception). Ignore-restore + wallet-wipe are untouched by these changes + (isolated to `core_bridge.rs` payment routing + `ContactDetailView` payment refresh). + - **Multi-agent review (2026-06-20, 5 lenses: correctness / adversarial / Swift-iOS / + testing / maintainability):** no blocking issues; all rated ship-able. **Applied:** + exhaustive `match` in `dashpay_payment_records` (a new record-bearing `WalletEvent` + variant now fails to compile rather than being silently dropped — same bug class); + cheap non-allocating `carries_payment_records`; `refreshPayments()` re-entrancy guard; + timeless test doc-comment; +idempotency and +`matured`-exclusion assertions in the + integration test (281/281 lib green, clippy clean, sim build green). **Follow-ups — + all three approved + DONE (2026-06-20):** + - [x] **(robustness) Sent-payment reconcile.** `IdentityWallet::reconcile_sent_payments` + (new local-only `dashpay_sync()` step): for each `Pending` `Sent` entry, consult the + persisted core tx record (`get_core_tx_record`) and flip it when the tx is mined or + IS-locked. Recovers a sent payment whose live confirm event was missed (lagged + broadcast, or relaunch after the tx confirmed) — the sent-side equivalent of the + incoming UTXO self-heal. Test `reconcile_sent_payments_confirms_from_persisted_record` + (mined→Confirmed, mempool→Pending, idempotent). + - [x] **(product) InstantSend = Confirmed.** The confirm gate now accepts `InstantSend` + context, and `WalletEvent::TransactionInstantLocked` (txid-only, no record) routes to a + new `confirm_sent_dashpay_payment_by_txid` path, so a sent payment shows `Confirmed` on + the IS lock (seconds after broadcast) rather than waiting for the block. Tests: + `instant_send_lock_confirms_sent_payment`, `instant_send_context_record_confirms_sent_payment`, + `instant_locked_drives_payment_hooks_without_a_record`. + - [x] **(perf) SwiftData write-amplification fixed.** `persistDashpayPayments` now only + mutates a row (and its `lastUpdated`) when a field actually changed, so the recurring + sync-edge refresh no longer re-fires the `@Query` on a quiescent channel. + - [ ] **(test) Incoming-payment recording via `BlockProcessed`** (block-first sighting) + still pinned only at the routing layer, not end-to-end — minor; left as a TODO. + - 285/285 platform-wallet lib tests green, clippy clean, full `build_ios.sh` (xcframework + + app) green. + +- [x] **🐛 BUG (found in UAT 2026-06-19) — RESOLVED 2026-06-21: an IMPORTED identity + could not sign any state transition.** Fixed by the carry-derived-scalar change + (`IMPORTED_IDENTITY_KEY_MATERIALIZATION_SPEC.md`, commit `c567981c46`): discovery + carries the already-verified 32-byte scalar through changeset→FFI→Swift so the client + stores it directly instead of re-deriving from a not-yet-persisted mnemonic. On-device: + clean import → 23/23 keys signable, a discovered identity signed a DashPay profile. + Regression tests green (`breadcrumb_decisions_*`, `add_keys_*`, + `identity_key_entry_debug_redacts_private_key`). Original diagnosis (historical) below. + Every signed op — register DPNS name, set DashPay profile, and by + extension contact requests + payments — failed with + `SDK error: Protocol error: Generic Error: No PersistentPublicKey row matches the + supplied public-key bytes` (`KeychainSigner.swift:137`). Diagnosis at the time: + - The 5 keys ARE persisted correctly — `ZPERSISTENTPUBLICKEY.ZPUBLICKEYDATA` exactly + matches all 5 of the identity's on-chain public keys (verified by SwiftData query). + - `KeychainSigner`'s lookup (`KeychainSigner.swift:308-310`) matches purely by + `row.publicKeyData == publicKey` — it is NOT identity-scoped, so the cosmetic + `PersistentPublicKey.identityId` base58-String-vs-`PersistentIdentity` raw-Data + difference is a red herring. + - Therefore the Rust SDK is handing the signer a public key that is **not among the + 5 derived/persisted keys** — a key-selection / derivation-path issue specific to the + import-an-existing-identity path (create-in-app presumably signs fine). + - **To pin the exact mismatch:** instrument `KeychainSigner.sign(identityPublicKey:data:)` + to log the supplied public-key hex, rebuild, reproduce, diff against the persisted 5. + - Blocks the imported-identity UAT path; does not block create-in-app. Likely lives in + the identity-import/key-derivation in `platform-wallet` (Swift SDK only persists/loads). +- [x] **Comment-cleanup pass — DONE (2026-06-18).** Stripped spec-gate / milestone + / dev-time refs (`G1a`..`G15`, `M3 task 13`, `(P2)`, stage labels) from source + comments + log strings across 18 DashPay files in `rs-platform-wallet` / + `rs-platform-wallet-ffi`; gate IDs replaced with their plain-English meaning + where a bare deletion would dangle (e.g. `G4`→"host-side signing hook", + `G1c`→"transient/permanent failure policy"). Comment/string-only — verified + zero executable lines changed, builds green, zero residual tokens. + +## Done (this session) + +- [x] PR #3841 review feedback (cancel-token, transient→permanent, rejected- + tombstone restore, purpose_mismatch, disabled keys, V001→V002 then squash, + reject `removed_incoming`, wipe PHASE 1, seed zeroize) — all 45 threads resolved. +- [x] Comprehensive kotlin-platform/dashj/dash-wallet comparison + (`KOTLIN_PLATFORM_COMPARISON.md`). diff --git a/docs/dashpay/research/01-dip-spec.md b/docs/dashpay/research/01-dip-spec.md new file mode 100644 index 00000000000..aed4d4a51dd --- /dev/null +++ b/docs/dashpay/research/01-dip-spec.md @@ -0,0 +1,499 @@ +# DashPay Protocol Specification (from DIPs) + +> Protocol-level reference for verifying a Rust + Swift DashPay implementation against the +> official Dash Improvement Proposals. Focuses on the contact / friendship / payment / profile +> flows. Every derivation path, encryption detail, and document field below is sourced from the +> DIPs (and cross-checked against the deployed `dashpay-contract` JSON schema) with citations. + +## Source DIPs read + +| DIP | Title | URL | +|-----|-------|-----| +| DIP-0009 | Feature Derivation Paths | https://raw.githubusercontent.com/dashpay/dips/master/dip-0009.md | +| DIP-0011 | Identities | https://raw.githubusercontent.com/dashpay/dips/master/dip-0011.md | +| DIP-0013 | Identities in Hierarchical Deterministic Wallets | https://raw.githubusercontent.com/dashpay/dips/master/dip-0013.md | +| DIP-0014 | Extended Key Derivation using 256-Bit Unsigned Integers | https://raw.githubusercontent.com/dashpay/dips/master/dip-0014.md | +| DIP-0015 | **DashPay** (core) | https://raw.githubusercontent.com/dashpay/dips/master/dip-0015.md | +| DIP-0017 | Dash Platform Payment Addresses and HD Derivation | https://raw.githubusercontent.com/dashpay/dips/master/dip-0017.md | + +Cross-check source (deployed contract, not a DIP): +- DashPay contract schema: https://raw.githubusercontent.com/dashpay/platform/master/packages/dashpay-contract/schema/v1/dashpay.schema.json + +All DIPs were fetched from the `master` branch successfully (no fallback to `main` needed). DIP-0017 +turned out to be about *standalone* platform payment addresses (`m/9'/5'/17'/...`) and is **not** the +source of the DashPay friendship derivation — that lives in DIP-0015 + DIP-0014. It is included here +only to disambiguate, so an implementer does not confuse the `17'` feature with the `15'` feature. + +> **Verification note.** Where the DIP text and the deployed v1 contract schema disagree on a +> concrete number (e.g. `publicMessage` max length, the `$createdAtCoreBlockHeight` field name), the +> **deployed schema is authoritative for an implementation** and the DIP value is noted as the +> original spec intent. Disagreements are flagged inline with ⚠. + +--- + +## 1. What DashPay is (user model) + +DIP-0015 defines DashPay as: + +> "an application built on Dash Platform that creates bidirectional direct settlement payment +> channels between Dash Identities." + +The product goals (DIP-0015): +- "Payments are easy to perform" +- "A history of payments is readily available" +- "Third parties aren't knowledgeable about the details of the payment" + +The design puts "Contacts front and center. All users have a contact list and can easily pay their +friends." Because a payment channel is always tied to two known identities, "the recipient knows and +will always know the sender, hence contact history will always show who made a payment to the user." + +Concretely, the user-facing model is: +- **Username** → handled by **DPNS** (DIP-0012), not DashPay. A username is a DPNS name that resolves + to an **identity** (DIP-0011). DashPay never stores usernames; it references identities by their + 32-byte unique ID. +- **Identity** (DIP-0011) → the cryptographic actor. Holds keys + a credit balance, signs all state + transitions. +- **Profile** → public presentation (display name, avatar, public message). One `profile` document + per identity. +- **Contact / Friend** → another identity you have exchanged `contactRequest` documents with. +- **Sending money to a contact** → derive the next unused receive address from the contact's shared + extended public key (decrypted from their `contactRequest` to you) and pay it on L1 (Dash Core + chain). DashPay itself is the *coordination/keyshare* layer; the actual value transfer is an + ordinary Dash L1 transaction to a DashPay-derived address. + +The relationship between two mutually-connected identities is called, in the spec, a **"Direct +Settlement Payment Channel (DSPC)"** and the two identities are "friends." + +--- + +## 2. Contact request / friend request lifecycle + +### 2.1 The `contactRequest` document + +A contact request is a **`contactRequest` document** owned (`$ownerId`) by the **sender** and pointing +(`toUserId`) at the **recipient**. Creating it: + +1. `$ownerId` = sender's identity unique ID (set by the platform from the signing identity). +2. `toUserId` = recipient's identity unique ID. +3. Sender derives the **incoming-funds extended public key** for this specific friendship + (Section 4) — i.e. the xpub that generates the addresses **the sender will watch to receive money + *from* the recipient**. +4. Sender derives an **ECDH shared secret** with the recipient (Section 3) and **AES-256-CBC encrypts** + that extended public key into `encryptedPublicKey`. +5. Sender computes `accountReference` (Section 7) and optionally `encryptedAccountLabel` / + `autoAcceptProof`. +6. Sender publishes it as a signed document-create state transition. The signing identity key must be + an **encryption/decryption-capable key at the bounded "High" security level** (DIP-0011 places + contact-request creation at the High security level; the v1 contract requires an encryption key of + key-type bound level 2). + +### 2.2 The bidirectional handshake (what "friendship" means) + +A single `contactRequest` is **one-directional**. Friendship is the *pair*: + +> "When two users have both sent contact requests to each other, then each is considered a fully +> established contact with the other." — DIP-0015 + +So: +- **A → B**: A sends `contactRequest{ $ownerId: A, toUserId: B }`. This is a *pending* / *incoming* + request from B's perspective. A is now watching for payments from B, but B does not yet know how to + pay A back (B has A's xpub-to-receive-from-B, so B *can* pay A; symmetric below). +- **B → A**: B "accepts" by sending `contactRequest{ $ownerId: B, toUserId: A }`. Now both directions + exist. +- **Established friendship / DSPC** = both `contactRequest` documents exist (A→B **and** B→A). + +Direction semantics to be precise about (this is the part implementations get wrong): +- A's `contactRequest` to B carries the xpub for the address space **A uses to receive funds *from* + B**. So **B uses A's request** to know where to pay A. +- Symmetrically, B's `contactRequest` to A carries the xpub for the space **B receives from A**, and + **A uses B's request** to pay B. +- Therefore to *pay someone* you read **their** outgoing contactRequest addressed **to you** + (`toUserId == yourId`, `$ownerId == theirId`), decrypt its `encryptedPublicKey`, and derive + addresses. To *receive/track*, you watch the xpub you yourself encrypted. + +### 2.3 Immutability + +Contact requests are immutable and non-deletable: + +> "they can never be deleted. This means they can never be updated or removed from the platform tree." +> — DIP-0015 + +Rationale: a user must not be able to retroactively alter the extended public key (which would orphan +prior payments / rewrite payment history). To rotate keys, a **new** `contactRequest` is sent with a +new `accountReference` version (Section 7) rather than mutating the old one. The v1 contract enforces +this with `"documentsMutable": false` semantics / no update transition. + +### 2.4 Auto-accept (optional) + +`autoAcceptProof` (optional, 38–102 bytes) lets a recipient pre-authorize automatic acceptance. +DIP-0015 defines a **separate** derivation path for the auto-accept proof keys: + +```text +m / 9' / 5' / 16' / timestamp' +``` + +i.e. feature `16'` (one above DashPay's `15'`), with an expiration `timestamp'` as the hardened leaf. +This is distinct from the payment-address path and is only used to prove auto-accept eligibility. + +--- + +## 3. Encrypted extended public key sharing (ECDH + AES) + +### 3.1 What is encrypted + +The plaintext is a **DashPay incoming-funds extended public key** — the xpub at the account level of +the friendship path (Section 4), i.e. the node `m/9'/5'/15'/0'//` whose +non-hardened `index` children are the actual receive addresses. The spec encrypts a **compacted** +serialization of the extended key (NOT the full 78-byte BIP32 xpub): + +> "The binary format used is as follows: Parent fingerprint (4 bytes), Chain code (32 bytes), +> Public Key (33 bytes)" — DIP-0015 + +So the encrypted plaintext is **69 bytes**: `parentFingerprint(4) || chainCode(32) || compressedPubKey(33)`. +(Version/depth/child-number are omitted because both sides already know the path.) + +### 3.2 The ECDH shared secret + +DIP-0015 uses **libsecp256k1's non-standard ECDH** (NOT plain X-coordinate ECDH): + +> "libsecp256k1_ecdh has one extra step to derive the shared key, which is to calculate +> `SHA256((y[31]&0x1|0x2) || x)` where `|` is bitwise or and `||` is concatenation." + +Algorithm: +1. Compute the EC point `P = d_self * Q_other` (shared point), where `d_self` is one party's identity + private key and `Q_other` is the other party's identity public key. +2. Let `x` be the 32-byte big-endian X coordinate of `P`, and `y` its Y coordinate. +3. Prefix byte = `(y[31] & 0x1) | 0x2` — i.e. the standard compressed-point parity prefix (`0x02` if + Y is even, `0x03` if odd). +4. `sharedKey = SHA256( prefixByte || x )` → 32 bytes → used as the **AES-256 key**. + +Both parties compute the identical shared key, per DIP-0015: + +> "The private key at `senderKeyIndex` of the sender and the public key at `recipientKeyIndex` of the +> recipient" and conversely "The private key at the `recipientKeyIndex` of the recipient and the +> public key at `senderKeyIndex` of the sender" both derive the same ECDH shared key. + +### 3.3 Which identity keys: `senderKeyIndex` / `recipientKeyIndex` + +These are **identity public-key `id`s** (DIP-0011: each identity public key has an integer `id` +"unique for the Identity public keys"). They select *which* of the sender's / recipient's identity +keys participate in the ECDH: +- `senderKeyIndex` → the key `id` in the **sender's** identity `publicKeys` array whose private key + the sender uses (and whose public key the recipient uses). +- `recipientKeyIndex` → the key `id` in the **recipient's** identity `publicKeys` array whose public + key the sender uses (and whose private key the recipient uses to decrypt). + +Both are stored in the `contactRequest` so either party can reconstruct the exact key pair used. They +must reference encryption/decryption-purpose keys (DIP-0011 purposes 1/2/3). + +### 3.4 AES-256-CBC layout of `encryptedPublicKey` (96 bytes total) + +> "Initialization Vector (16 bytes), Encrypted extended public key with padding (80 bytes) that is +> encrypted by CBC-AES-256." — DIP-0015 + +``` +encryptedPublicKey (96 bytes): + [ 0 .. 16 ) IV (16 bytes, random, unique per request) + [ 16 .. 96 ) AES-256-CBC ciphertext (80 bytes) + = CBC-AES256(key = sharedKey, iv, + plaintext = parentFingerprint(4) || chainCode(32) || pubKey(33) = 69 bytes) + 69 bytes plaintext → PKCS#7 pad to 80 (next 16-byte multiple) → 80 ciphertext bytes +``` + +The deployed schema fixes `encryptedPublicKey` at exactly **96** `byteArray` items (16 IV + 80 cipher), +confirming the DIP layout. + +### 3.5 `encryptedAccountLabel` (optional, 48–80 bytes) + +Same scheme as `encryptedPublicKey` but encrypts a human-readable account label: + +> "Initialization Vector (16 bytes), Encrypted account label with padding (32–64 bytes) that is +> encrypted by CBC-AES-256." — DIP-0015 + +So total = 16 IV + (32..64) ciphertext = **48..80 bytes**, matching the schema's `minItems: 48, +maxItems: 80`. + +### 3.6 `contactInfo` private-data encryption (different scheme) + +`contactInfo` uses **BIP32-derived symmetric keys**, not ECDH: +- `rootEncryptionKeyIndex` + `derivationEncryptionKeyIndex` select a BIP32 `CKDpriv` child of the + owner's own key tree (so only the owner can decrypt — this is *self*-encrypted private metadata). +- `encToUserId` (32 bytes): the contact's identity ID, encrypted (AES-256, ECB per the field's fixed + block size) so the network can't trivially link `contactInfo` to a specific contact. +- `privateData` (48–2048 bytes): AES-256-CBC-encrypted CBOR blob. + +Privacy rule (DIP-0015): + +> "A client should not transmit a contact info document for a user to the network until that user has +> at least two established contacts." + +(prevents trivially correlating a `contactInfo` with the single contactRequest it must refer to.) + +--- + +## 4. Payment-address derivation (DIP-0015 friendship path + DIP-0014 256-bit indices) + +### 4.1 The friendship derivation path + +DIP-0015 (incoming funds), verbatim: + +> "The derivation path therefore has the following paths: +> `m(userA)/9'/5'/15'/0'/(userA's unique id)/(userB's unique id)/index`" + +Structured: + +``` +m / 9' / 5' / 15' / 0' / / / index + │ │ │ │ │ │ │ └─ non-hardened, 32-bit : address index (0,1,2,…) + │ │ │ │ │ │ └──────────── non-hardened, 256-bit : OTHER party's identity id (DIP-14) + │ │ │ │ │ └─────────────────────────── non-hardened, 256-bit : THIS party's identity id (DIP-14) + │ │ │ │ └─────────────────────────────────── hardened : account (0') + │ │ │ └──────────────────────────────────────── hardened : feature 15' = DashPay incoming funds (DIP-9) + │ │ └────────────────────────────────────────────── hardened : coin_type 5' = Dash (mainnet; 1' testnet) + │ └─────────────────────────────────────────────────── hardened : purpose 9' (DIP-9 feature paths) + └──────────────────────────────────────────────────────── master from seed +``` + +Hardening summary (load-bearing — verify exactly): +- `9'`, `5'`, `15'`, `0'` → **hardened**. +- ``, ``, `index` → **non-hardened** (this is the whole point). + +DIP-0015 on *why* the last three are non-hardened: + +> "Making the last three fields non-hardened allows for an extended public key that covers all address +> spaces of all our contacts for all our identities." + +i.e. a wallet can hold the xpub at `m/9'/5'/15'/0'` and, with only public derivation, enumerate the +receive space for every contact of every identity — enabling watch-only accounting and contact-request +construction without touching private keys. + +### 4.2 Both parties derive the same chain + +For friendship between A and B, the **incoming-funds path A publishes to B** is rooted at +`/` (A's id first = owner, then the counterparty). A keeps the private side; B +receives A's encrypted xpub (Section 3) and derives the **same** public chain +`…///index` to compute the addresses to **pay A**. Conversely B's request to A +is rooted `/`. The ordering (owner-first) is what disambiguates the two +directions of the channel. + +### 4.3 DIP-0014: 256-bit derivation indices + +Standard BIP32 indices are 32-bit (31 bits + hardening bit). A Dash identity ID is **256 bits**, so +DIP-0014 extends CKD to take full 256-bit indices. Key points: + +- Hardening is moved out of the index into a **separate boolean `h`** (because the MSB is now a real + value bit, not a hardening flag). DIP-0014 allows "2^256 normal child keys and 2^256 hardened child + keys." +- Modified child key derivation (`CKDpriv256`): + + ``` + index < 2^32 (compatibility / BIP32-identical): + hardened : I = HMAC-SHA512(key=c_par, data = 0x00 || ser256(k_par) || ser32(i)) + non-hardened : I = HMAC-SHA512(key=c_par, data = serP(point(k_par)) || ser32(i)) + + index ≥ 2^32 (256-bit mode): + hardened : I = HMAC-SHA512(key=c_par, data = 0x00 || ser256(k_par) || ser256(i)) + non-hardened : I = HMAC-SHA512(key=c_par, data = serP(point(k_par)) || ser256(i)) + + then: I_L = I[0..32], I_R = I[32..64] + k_i = parse256(I_L) + k_par (mod n) + c_i = I_R + ``` + + Public derivation `CKDpub256` (non-hardened only; rejects `h=true`): + `K_i = point(parse256(I_L)) + K_par`, `c_i = I_R`. + +- For DashPay the identity IDs are used as the **256-bit non-hardened** indices. DIP-0014 explicitly + ties this to the security requirement: "any form that reduces the entropy of this relationship to 31 + bits per child would be attackable." The two 256-bit levels are the user IDs; reducing them to 31 + bits (e.g. by hashing down to a BIP32 index) would be insecure. + +- 256-bit extended keys use **new serialization version bytes** (e.g. `0x0EECEFC5` mainnet-public) and + replace the 4-byte child-number field with `ser256(i)` (32 bytes) plus a separate hardening byte. + (Relevant if the implementation serializes/transports these xpubs.) + +--- + +## 5. Profile + +One `profile` document per identity. Fields (deployed v1 schema; DIP value noted where it differs): + +| Field | Type | Constraints | Notes | +|-------|------|-------------|-------| +| `$ownerId` | byteArray(32) | implicit | profile owner identity ID | +| `displayName` | string | 1–25 chars | user-friendly, **not unique** | +| `publicMessage` | string | 1–**140** chars (schema) ⚠ DIP says 0–250 | bio/status | +| `avatarUrl` | string (URI) | 1–2048 chars | public image URL | +| `avatarHash` | byteArray(32) | SHA-256 of avatar image | integrity | +| `avatarFingerprint` | byteArray(8) | dHash perceptual hash | dedup / fuzzy match | +| `$createdAt` | integer | required | ms timestamp | +| `$updatedAt` | integer | required | ms timestamp | + +Constraints: +- DIP-0015: "At least one field between the `avatarUrl`, `publicMessage` and `displayName` must be + set." (The v1 schema additionally makes the three avatar fields **mutually dependent** — if any + avatar field is present, the related ones must be too.) +- Profiles **are** mutable (unlike contactRequest): updating `displayName` etc. bumps `$updatedAt`. + +Indices (v1 schema): +- `profile` unique index on `$ownerId` (one profile per identity). +- `ownerIdAndUpdatedAt`: `$ownerId` asc, `$updatedAt` asc (for sync / "what changed"). + +--- + +## 6. DashPay data contract — document types & indices + +The DashPay contract defines exactly **three** document types: `profile`, `contactRequest`, +`contactInfo`. Below merges DIP-0015 field semantics with the **deployed v1 schema** +(`packages/dashpay-contract/schema/v1/dashpay.schema.json`), which is authoritative for byte +sizes/indices. + +### 6.1 `contactRequest` + +| Field | Type | Constraints | Required | Meaning | +|-------|------|-------------|:--------:|---------| +| `toUserId` | byteArray(32) | contentMediaType `application/x.dash.dpp.identifier` | ✔ | recipient identity ID | +| `encryptedPublicKey` | byteArray(96) | exactly 96 | ✔ | IV(16) + AES-CBC xpub(80) — Section 3.4 | +| `senderKeyIndex` | integer | ≥0 | ✔ | sender identity key `id` for ECDH | +| `recipientKeyIndex` | integer | ≥0 | ✔ | recipient identity key `id` for ECDH | +| `accountReference` | integer | ≥0 | ✔ | masked account ref (Section 7) | +| `encryptedAccountLabel` | byteArray(48–80) | optional | | IV(16) + AES-CBC label(32–64) | +| `autoAcceptProof` | byteArray(38–102) | optional | | optional auto-accept proof (path `m/9'/5'/16'/timestamp'`) | +| `$createdAt` | integer | | ✔ | ms timestamp | +| `$createdAtCoreBlockHeight` | integer | | ✔ | Dash L1 chain height at creation (DIP calls it `$coreHeightCreatedAt`) ⚠ | + +Indices (v1 schema): +- `ownerIdUserIdAndAccountRef` — **unique** on (`$ownerId`, `toUserId`, `accountReference`). This is + the key uniqueness invariant: one request per (sender, recipient, accountReference). A *new* + accountReference version lets the same pair create a fresh request (key rotation). +- `ownerIdUserId` — (`$ownerId`, `toUserId`). +- `userIdCreatedAt` — (`toUserId`, `$createdAt`): **incoming** requests for a user, time-ordered. +- `ownerIdCreatedAt` — (`$ownerId`, `$createdAt`): **outgoing** requests, time-ordered. + +Constraints: immutable, non-deletable; creation requires an identity encryption/decryption key at the +bound security level (High). + +### 6.2 `profile` + +See Section 5. + +### 6.3 `contactInfo` + +| Field | Type | Constraints | Required | Meaning | +|-------|------|-------------|:--------:|---------| +| `encToUserId` | byteArray(32) | | ✔ | contact's identity ID, AES-encrypted | +| `rootEncryptionKeyIndex` | integer | ≥0 | ✔ | owner BIP32 root key index for self-encryption | +| `derivationEncryptionKeyIndex` | integer | ≥0 | ✔ | owner BIP32 derivation index | +| `privateData` | byteArray(48–2048) | encrypted CBOR | ✔ | self-encrypted contact metadata | +| `$createdAt` | integer | | ✔ | | +| `$updatedAt` | integer | | ✔ | | + +`privateData` plaintext (CBOR, per DIP-0015), decrypted only by the owner: +- `version` (uInt32) +- `aliasName` (String) — user-chosen nickname for the contact +- `note` (String) — free-form notes +- `displayHidden` (uInt8) — hidden/ignored flag +- `acceptedAccounts` (array of uInt32) — which account-reference versions have been accepted + +Indices (v1 schema): +- `ownerIdAndKeys` — **unique** on (`$ownerId`, `rootEncryptionKeyIndex`, `derivationEncryptionKeyIndex`). +- `ownerIdAndUpdatedAt` — (`$ownerId`, `$updatedAt`). + +Privacy publication rule: do not publish a `contactInfo` until the owner has ≥2 established contacts +(Section 3.6). + +--- + +## 7. `accountReference` — masked derivation hash + +### 7.1 Purpose + +The friendship path uses account `0'` by default, but a user may use higher account numbers. The +`accountReference` integer tells the recipient **which account** the sender's published xpub belongs to, +**without revealing the raw account number** (which would leak wallet structure). It also carries a +**version** so key rotations are detectable. + +### 7.2 Exact computation (DIP-0015, verbatim) + +``` +ASK = HMAC-SHA256(senderSecretKey, extendedPublicKey) +ASK28 = 28 most significant bits of ASK +ShortenedAccountBits = Account & 0x0FFFFFFF # low 28 bits of the account number +VersionBits = Version << 28 # top 4 bits = version +AccountRef = VersionBits | (ASK28 xor ShortenedAccountBits) +``` + +Where: +- `senderSecretKey` is the sender's secret used as the HMAC key (the private key associated with the + published extended public key / friendship root). +- `extendedPublicKey` is the (compact, 69-byte) extended public key being shared. +- `ASK28` "can be considered the result of a pseudorandom function derived from the account" — it masks + the account so an observer cannot read the raw account number. +- Result layout: **bits 31..28 = version (4 bits)**, **bits 27..0 = masked account (28 bits)**. + +The recipient, knowing the decrypted `extendedPublicKey` and the sender's relevant public key, can +recompute `ASK28` and **un-mask** the account number, and read the version. + +DIP-0015 notes uniqueness is not required: "Using only 28 bits means collision probability of 2^28, +but uniqueness is not a requirement of this system." + +### 7.3 Version semantics (key rotation) + +> "if receiving any number other than zero, and while also having a contact request with the previous +> version, clients should notify the recipient user … that the sender has updated their payment +> addresses." — DIP-0015 + +So a non-zero (or incremented) version on a *new* `contactRequest` for an existing pair signals the +sender rotated their payment xpub; the recipient should start using the new addresses. The +`(ownerId, toUserId, accountReference)` unique index is exactly what allows multiple successive +requests for the same pair (one per version). + +--- + +## 8. Cross-cutting derivation reference (for the implementation) + +``` +# DashPay incoming-funds receive addresses (the money path) — DIP-15 + DIP-14 +m / 9' / 5' / 15' / 0' / / / index + └ hardened ┘ └─ non-hardened 256-bit (DIP-14) ─┘ └ non-hardened 32-bit + +# DashPay auto-accept proof — DIP-15 +m / 9' / 5' / 16' / timestamp' + +# Identity authentication / registration keys — DIP-13 (NOT DashPay payments, but used as the +# ECDH identity keys referenced by senderKeyIndex/recipientKeyIndex) +m / 9' / 5' / 5' / / / / +# first identity, first ECDSA auth key = m/9'/5'/5'/0'/0'/0'/0' +# subfeature 1' = registration funding, 2' = top-up, 3' = invitation + +# Standalone platform payment addresses — DIP-17 (separate feature; do NOT confuse with 15') +m / 9' / 5' / 17' / account' / key_class' / index (mainnet; coin_type 1' on testnet) +``` + +Coin type is `5'` on mainnet, `1'` on testnets (DIP-0009 / SLIP-0044). + +--- + +## 9. Implementation verification checklist (Rust + Swift) + +1. **256-bit CKD** (DIP-14): identity IDs are used as full 256-bit **non-hardened** indices. Verify the + HMAC data is `serP(point(k_par)) || ser256(i)` (non-hardened, 256-bit mode), **not** a truncated + 32-bit index. Reducing to 31 bits is a security bug per DIP-14. +2. **Friendship path ordering**: owner identity first, counterparty second + (`…///index`). The two directions of a channel differ only by this order. +3. **Compact xpub plaintext** = `parentFingerprint(4) || chainCode(32) || pubKey(33)` = 69 bytes — NOT + a 78-byte BIP32 xpub. Pad to 80 with PKCS#7 before AES. +4. **ECDH** is libsecp256k1-style: `SHA256( ((y&1)|2) || x )`, not raw X-coord, not standard + SHA-512-based ECDH. +5. **AES**: `encryptedPublicKey` = IV(16) ‖ CBC-AES-256(80) = 96 bytes fixed. Same scheme for + `encryptedAccountLabel` (48–80). `contactInfo.privateData` uses BIP32-derived keys, `encToUserId` + uses ECB. +6. **accountReference**: top 4 bits = version, low 28 = `HMAC-SHA256(secret, xpub)[28 msb] XOR + (account & 0x0FFFFFFF)`. +7. **Friendship = both contactRequests exist.** Sending one is "pending"; the reverse one is "accept." +8. **Immutability**: never update/delete a `contactRequest`; rotate via a new one with bumped version. +9. **Indices** must match the unique `(ownerId, toUserId, accountReference)` invariant and the + incoming/outgoing `(toUserId,$createdAt)` / `($ownerId,$createdAt)` query indices. +10. **Field-name/size discrepancies** between DIP-0015 prose and the deployed v1 schema (⚠ above): + use the schema — `publicMessage` 1–140 (not 0–250), core-height field is `$createdAtCoreBlockHeight`. + Confirm against the exact contract version your network runs. diff --git a/docs/dashpay/research/02-rust-dashcore-keywallet.md b/docs/dashpay/research/02-rust-dashcore-keywallet.md new file mode 100644 index 00000000000..306a952426e --- /dev/null +++ b/docs/dashpay/research/02-rust-dashcore-keywallet.md @@ -0,0 +1,573 @@ +# DashPay support in the rust-dashcore key-wallet + +Research date: 2026-06-10 +Repo root: `/Users/ivanshumkov/Projects/dashpay/rust-dashcore/` +Crates examined: `key-wallet`, `key-wallet-manager`, `key-wallet-ffi`. + +This maps what the key-wallet stack provides for DashPay (contact-based funds): +derivation paths, account types, 256-bit (DIP14/DIP15) derivation, managed +accounts, transaction routing, and the FFI surface — plus what is **missing** +(notably: no ECDH / shared-secret code anywhere in the repo). + +--- + +## 0. TL;DR + +- **Derivation paths**: DIP9 `m/9'/coin'/15'` DashPay root constants exist + (`DASHPAY_ROOT_PATH_MAINNET/TESTNET`). The per-contact path is + `m/9'/coin'/15'/0'//` where ``/`` are + **non-hardened 256-bit** child numbers (the two identity IDs, 32 bytes each). +- **256-bit derivation (DIP14)**: fully implemented. `ChildNumber` has + `Normal256 { index: [u8;32] }` / `Hardened256 { index: [u8;32] }`; `ckd_priv` + and `ckd_pub_tweak` feed the raw 32 bytes into the BIP32 HMAC. Path strings + parse 256-bit hex (`0x…`). Tested by `test_dashpay_vector_1..4`. +- **Account types**: `AccountType::DashpayReceivingFunds { index, user_identity_id, friend_identity_id }` + and `AccountType::DashpayExternalAccount { … }` (watch-only, reversed id + order). Mirrored by `ManagedAccountType`, keyed in collections by + `DashpayAccountKey { index, user_identity_id, friend_identity_id }`. +- **Managed accounts / addresses**: each DashPay account holds a single + `AddressPool` with gap limit **20**. The 256-bit derivation is done once at + account creation to produce the account xpub; address generation then appends + an ordinary **non-hardened u32 leaf** — standard BIP32 from there on. +- **Transaction checking**: incoming txs are routed to DashPay accounts via + `AccountTypeToCheck::DashpayReceivingFunds` / `DashpayExternalAccount`, which + iterate the `dashpay_*_accounts` maps and address-match each pool. +- **FFI**: `wallet_add_dashpay_receiving_account`, + `wallet_add_dashpay_external_account_with_xpub_bytes`, + `managed_wallet_get_dashpay_receiving_account`, + `managed_wallet_get_dashpay_external_account` — all take + `user_identity_id` + `friend_identity_id` as 32-byte pointers. +- **GAPS**: **No ECDH / shared-secret / xpub-encryption code exists in the whole + repo.** DashPay accounts are **not auto-created** at wallet init. The helper + `extended_public_key_for_account_type` returns `None` for DashPay + ("Currently not retrieved via this helper"). No FFI to compute the per-contact + derivation path or to derive an xpub from two identity IDs directly. + +--- + +## 1. DIP9 feature paths (`key-wallet/src/dip9.rs`) + +### Feature-purpose constants (`dip9.rs:124-140`) + +```rust +pub const BIP44_PURPOSE: u32 = 44; +pub const FEATURE_PURPOSE: u32 = 9; +pub const DASH_COIN_TYPE: u32 = 5; +pub const DASH_TESTNET_COIN_TYPE: u32 = 1; +pub const FEATURE_PURPOSE_COINJOIN: u32 = 4; +pub const FEATURE_PURPOSE_IDENTITIES: u32 = 5; +pub const FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION: u32 = 0; +pub const FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_REGISTRATION: u32 = 1; +pub const FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_TOPUP: u32 = 2; +pub const FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_INVITATIONS: u32 = 3; +pub const FEATURE_PURPOSE_ASSET_LOCK_SUBFEATURE_ADDRESS_TOPUP: u32 = 4; +pub const FEATURE_PURPOSE_ASSET_LOCK_SUBFEATURE_SHIELDED_ADDRESS_TOPUP: u32 = 5; +pub const FEATURE_PURPOSE_DASHPAY: u32 = 15; // <-- DashPay feature index +pub const FEATURE_PURPOSE_PLATFORM_PAYMENT: u32 = 17; // DIP-17 +``` + +So DashPay is feature index **15'** under purpose **9'** (NOT under `purpose 15'` +— note the inline test comment in derivation.rs that says "m/15'/5'/15'" is +wrong; the actual constant builds `m/9'/coin'/15'`). + +### DashPay root path constants (`dip9.rs:167-198`) + +```rust +// DashPay Root Paths +pub const DASHPAY_ROOT_PATH_MAINNET: IndexConstPath<3> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { index: FEATURE_PURPOSE }, // 9' + ChildNumber::Hardened { index: DASH_COIN_TYPE }, // 5' + ChildNumber::Hardened { index: FEATURE_PURPOSE_DASHPAY }, // 15' + ], + reference: DerivationPathReference::ContactBasedFunds, + path_type: DerivationPathType::CLEAR_FUNDS, +}; + +pub const DASHPAY_ROOT_PATH_TESTNET: IndexConstPath<3> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { index: FEATURE_PURPOSE }, // 9' + ChildNumber::Hardened { index: DASH_TESTNET_COIN_TYPE }, // 1' + ChildNumber::Hardened { index: FEATURE_PURPOSE_DASHPAY }, // 15' + ], + reference: DerivationPathReference::ContactBasedFunds, + path_type: DerivationPathType::CLEAR_FUNDS, +}; +``` + +So the DashPay root is `m/9'/5'/15'` (mainnet) / `m/9'/1'/15'` (testnet). + +### DerivationPathReference enum (`dip9.rs:13-34`) + +DashPay-relevant references: + +```rust +ContactBasedFunds = 8, // DashpayReceivingFunds maps here +ContactBasedFundsRoot = 9, +ContactBasedFundsExternal = 10, // DashpayExternalAccount maps here +``` + +### Per-contact path layout + +Built in `AccountType::derivation_path` (`account/account_type.rs:469-514`). The +full per-contact path is: + +- **Receiving** (`DashpayReceivingFunds`): + `m/9'/coin'/15'/0'//` +- **External / watch-only** (`DashpayExternalAccount`): + `m/9'/coin'/15'/0'//` (ids reversed) + +The `0'` is a hardened account-level index. The two trailing components are +**non-hardened `Normal256`** child numbers carrying the raw 32-byte identity IDs: + +```rust +Self::DashpayReceivingFunds { user_identity_id, friend_identity_id, .. } => { + let mut path = /* DASHPAY_ROOT_PATH_{MAINNET,TESTNET} */; + path.push(ChildNumber::from_hardened_idx(0)?); // account 0' + path.push(ChildNumber::Normal256 { index: *user_identity_id }); // non-hardened 256-bit + path.push(ChildNumber::Normal256 { index: *friend_identity_id }); + Ok(path) +} +// DashpayExternalAccount pushes friend_id THEN user_id (reversed) — account_type.rs:507-512 +``` + +Comment in source: *"Base DashPay root + account 0' + user_id/friend_id +(non-hardened per DIP-14/DIP-15)"* (`account_type.rs:474`). + +--- + +## 2. DIP14 256-bit derivation (`key-wallet/src/bip32.rs`) + +### ChildNumber variants (`bip32.rs:575-598`) + +```rust +pub enum ChildNumber { + Normal { index: u32 }, // [0, 2^31-1] + Hardened { index: u32 }, // [0, 2^31-1] + Normal256 { index: [u8; 32] }, // [0, 2^256-1] <-- DIP14 + Hardened256 { index: [u8; 32] }, // [0, 2^256-1] <-- DIP14 +} +``` + +Constructors (`bip32.rs:650-662`): + +```rust +pub fn from_normal_idx_256(index: [u8; 32]) -> ChildNumber { ChildNumber::Normal256 { index } } +pub fn from_hardened_idx_256(index: [u8; 32]) -> ChildNumber { ChildNumber::Hardened256 { index } } +``` + +`is_256_bits()` (`bip32.rs:691`) and `is_hardened()` (`bip32.rs:674`, where +`Normal256 => false`, `Hardened256 => true`) gate the encoding/derivation +branches. + +### Child key derivation — private (`bip32.rs:1533-1589`) + +The 256-bit branches feed the **raw 32-byte index** into the HMAC-SHA512 engine +(no big-endian-u32 conversion), which is exactly DIP14: + +```rust +pub fn ckd_priv(&self, secp: &Secp256k1, i: ChildNumber) + -> Result +{ + let mut hmac_engine = HmacEngine::::new(&self.chain_code[..]); + match i { + ChildNumber::Normal { index } => { + hmac_engine.input(&PublicKey::from_secret_key(secp, &self.private_key).serialize()[..]); + hmac_engine.input(&index.to_be_bytes()); + } + ChildNumber::Hardened { index } => { + hmac_engine.input(&[0u8]); + hmac_engine.input(&self.private_key[..]); + hmac_engine.input(&(index | (1 << 31)).to_be_bytes()); + } + ChildNumber::Normal256 { index } => { // DIP14 non-hardened + hmac_engine.input(&PublicKey::from_secret_key(secp, &self.private_key).serialize()[..]); + hmac_engine.input(&index); // raw 32 bytes + } + ChildNumber::Hardened256 { index } => { // DIP14 hardened + hmac_engine.input(&[0u8]); + hmac_engine.input(&self.private_key[..]); + hmac_engine.input(&index); // raw 32 bytes + } + } + let hmac_result = Hmac::::from_engine(hmac_engine); + let sk = SecretKey::from_slice(&hmac_result[..32])?; + let tweaked = sk.add_tweak(&self.private_key.into())?; + Ok(ExtendedPrivKey { /* depth+1, child_number: i, private_key: tweaked, chain_code: from_hmac */ }) +} +``` + +`ckd_pub_tweak` (`bip32.rs:1817+`) has the symmetric public-key branches so the +**non-hardened** `Normal256` path can be derived from an xpub alone — important +for DashPay external (watch-only) accounts where you only have the friend's +xpub. + +### How identity IDs become derivation indices + +There is **no dedicated "identity-id → index" helper**. The identity ID *is* the +index: it's a raw `[u8; 32]` placed directly into `ChildNumber::Normal256` +(see §1). Two paths to construct one: + +1. Programmatically: `account_type.derivation_path(network)` builds it (§1). +2. From a string: `DerivationPath::from_str` parses `0x<64 hex>` segments into + `Normal256`/`Hardened256` (`bip32.rs:855-905`): + +```rust +if index_str.starts_with("0x") { + // decode 32 bytes; trailing ' => Hardened256 else Normal256 +} +``` + +### Binary encoding + +Extended keys with 256-bit child numbers serialize to **107 bytes** (vs 78 for +32-bit) — `decode` dispatches on length (`bip32.rs:1602-1604`), and there are +dedicated `encode_256`/`decode_256` paths for both xpriv and xpub +(`bip32.rs:1654-2021`). DIP-14 binary format comment at `bip32.rs:1883`. + +### Test vectors (`bip32.rs:2521-2594`) + +`test_dashpay_vector_1..4` derive against real DashPay-shaped paths, e.g.: + +```text +m/9'/5'/15'/0'/0x555d…cfc3a'/0xa137…89b5'/0 +``` + +and assert exact `tprv…`/`tpub…` strings — proving the 256-bit DashPay +derivation is correct and stable. + +--- + +## 3. Account types (`key-wallet/src/account/account_type.rs`) + +### The two DashPay variants (`account_type.rs:76-95`) + +```rust +/// Incoming DashPay funds account using 256-bit derivation +/// The derivation path used is user_identity_id/friend_identity_id +DashpayReceivingFunds { + index: u32, // account-level selection + user_identity_id: [u8; 32], // our identity id + friend_identity_id: [u8; 32], // contact's identity id +}, +/// DashPay external (watch-only) account using 256-bit derivation +/// The derivation path used is friend_identity_id/user_identity_id +DashpayExternalAccount { + index: u32, + user_identity_id: [u8; 32], + friend_identity_id: [u8; 32], +}, +``` + +- **`DashpayReceivingFunds`** = funds *we* receive from a contact; derived from + our own key material; path `…/0'/user/friend`. Maps to + `DerivationPathReference::ContactBasedFunds` (`account_type.rs:300-302`). +- **`DashpayExternalAccount`** = the contact's *external* (watch-only) view of + where *they* will send; path `…/0'/friend/user` (reversed); typically created + from the contact's xpub. Maps to `ContactBasedFundsExternal` + (`account_type.rs:303-305`). + +`index()` returns `Some(index)` for both (`account_type.rs:218-225`). + +### How a friendship/contact maps to an account + +A friendship = the ordered pair `(our identity, contact identity)`. Each +direction is a distinct account: + +- We receive from contact → `DashpayReceivingFunds { user=ours, friend=theirs }`. +- We watch where the contact receives (so we know where to pay them) → + `DashpayExternalAccount`. + +In collections (`account/account_collection.rs:19-29, 75-80`) they're stored in +two `BTreeMap`s keyed by: + +```rust +pub type DashpayOurUserIdentityId = [u8; 32]; +pub type DashpayContactIdentityId = [u8; 32]; + +pub struct DashpayAccountKey { + pub index: u32, + pub user_identity_id: DashpayOurUserIdentityId, + pub friend_identity_id: DashpayContactIdentityId, +} + +// in AccountCollection: +pub dashpay_receival_accounts: BTreeMap, +pub dashpay_external_accounts: BTreeMap, +``` + +`AccountCollection::insert` (`account_collection.rs:162-184`) routes a built +`Account` into the right map based on `AccountType`. + +### Path construction + +`derivation_path(network)` for both variants — see §1 (quoted from +`account_type.rs:469-514`). + +--- + +## 4. Managed accounts (`key-wallet/src/managed_account/`) + +### ManagedAccountType variants (`managed_account_type.rs:97-118`) + +```rust +DashpayReceivingFunds { + index: u32, + user_identity_id: DashpayOurUserIdentityId, + friend_identity_id: DashpayContactIdentityId, + addresses: AddressPool, // single pool +}, +DashpayExternalAccount { + index: u32, + user_identity_id: DashpayOurUserIdentityId, + friend_identity_id: DashpayContactIdentityId, + addresses: AddressPool, // single pool +}, +``` + +Each DashPay account is **single-pool** (one `AddressPool`, no separate +internal/change pool) — `address_pools()` returns `vec![addresses]` +(`managed_account_type.rs:263-274`). + +### Construction & gap limit (`managed_account_type.rs:706-749`) + +`ManagedAccountType::from_account_type` builds the pool from the account's +256-bit derivation path with **gap limit 20**, pool type `Absent`: + +```rust +AccountType::DashpayReceivingFunds { index, user_identity_id, friend_identity_id } => { + let path = account_type.derivation_path(network)...; // the 256-bit contact path + let pool = AddressPool::new(path, AddressPoolType::Absent, 20, network, key_source)?; + Ok(Self::DashpayReceivingFunds { index, user_identity_id, friend_identity_id, addresses: pool }) +} +``` + +(The literal `20` here is the DashPay gap limit; compare `DIP17_GAP_LIMIT = 20`, +`DEFAULT_SPECIAL_GAP_LIMIT = 5`, `DEFAULT_EXTERNAL_GAP_LIMIT = 30` in +`gap_limit.rs`.) + +### Address generation — the 256-bit part is done once + +Important architectural detail: the **256-bit DIP14 derivation happens once at +account-creation time** to produce the account-level xpub. From that xpub, the +`AddressPool` generates addresses by appending an **ordinary non-hardened u32 +leaf** (`address_pool.rs:427-440`): + +```rust +pub(crate) fn generate_address_at_index(&..., index: u32) { + let mut full_path = /* pool base path */; + full_path.push(ChildNumber::from_normal_idx(index)?); // plain 32-bit leaf + // derive_pub via KeySource::derive_at_path (address_pool.rs:128-142) +} +``` + +So once the account xpub exists, address generation/gap-limit/balance tracking +for a contact is identical to any other single-pool account. `KeySource` +(`address_pool.rs:128-142`) derives via `xpub.derive_pub` / `xprv.derive_priv`, +which transparently handle 256-bit segments if present. + +### Managed collection storage (`managed_account_collection.rs:71-73, 244-268`) + +```rust +pub dashpay_receival_accounts: BTreeMap, +pub dashpay_external_accounts: BTreeMap, +``` + +`insert` / `insert_funds_bearing_account` route managed DashPay accounts into +these maps keyed by `DashpayAccountKey`. + +### Balance / UTXO tracking + +DashPay managed accounts are `ManagedCoreFundsAccount` (funds-bearing), so they +get the full `ManagedAccountTrait` (`managed_account_trait.rs:29+`) — balance, +UTXOs, transaction records, chainlock/instantsend finality — exactly like +standard accounts. Nothing DashPay-specific in the balance machinery. + +--- + +## 5. ECDH / shared secret — **ABSENT** + +**There is NO ECDH, shared-secret, Diffie-Hellman, key-agreement, or +extended-public-key-encryption code anywhere in rust-dashcore.** Repo-wide grep +(all crates, excluding `target/`): + +``` +grep -rinE '\becdh\b|shared_secret|diffie.hellman|SharedSecret|key_agreement' --include='*.rs' + -> (no matches) +grep -rinE 'encrypt.*extended|extended.*encrypt' --include='*.rs' + -> (no matches) +``` + +Implications for the platform wallet: + +- The DIP15 "encrypt the contact xpub with an ECDH-derived key, store it in the + DashPay contact-request document" step is **not** provided here. Key-wallet + gives you the *derivation* primitives (256-bit account xpub from your seed + + the two identity IDs) but **not** the ECDH shared key needed to + encrypt/decrypt that xpub for the on-platform contact request. +- The platform wallet must compute the ECDH shared secret itself (e.g. via + `secp256k1` ECDH on the two identities' encryption public keys) and do the + symmetric encryption of the extended public key. key-wallet only consumes the + *already-decrypted* friend xpub (passed into + `wallet_add_dashpay_external_account_with_xpub_bytes`). + +`secp256k1` *is* a dependency, so the building block (raw ECDH) is reachable — +it's just not wired up in key-wallet. + +--- + +## 6. Transaction checking (`key-wallet/src/transaction_checking/`) + +### Routing enum (`transaction_router/mod.rs:183-198`) + +```rust +pub enum AccountTypeToCheck { + // … StandardBIP44, CoinJoin, Identity*, AssetLock*, Provider* … + DashpayReceivingFunds, + DashpayExternalAccount, +} +``` + +`AccountType -> AccountTypeToCheck` conversion (`account_type.rs:190-195`) and +`ManagedAccountType -> AccountTypeToCheck` (`transaction_router/mod.rs:260-265, +325-330`). Note `PlatformPayment` returns `Err(PlatformAccountConversionError)` +because it's Platform-only; DashPay variants convert fine (they're real Core +on-chain funds). + +### Matching txs to the right contact account (`account_checker.rs:501-518`) + +```rust +AccountTypeToCheck::DashpayReceivingFunds => { + let mut matches = Vec::new(); + for (key, account) in &self.dashpay_receival_accounts { + if let Some(m) = account.check_transaction_for_match(tx, Some(key.index)) { + matches.push(m); + } + } + matches +} +AccountTypeToCheck::DashpayExternalAccount => { /* same over dashpay_external_accounts */ } +``` + +So an incoming payment is matched by iterating every DashPay account's address +pool(s) and address-matching the tx outputs — the same address-pool matching as +every other account type, just bucketed by `DashpayAccountKey`. + +### Match result (`account_checker.rs:144-153`) + +```rust +CoreAccountTypeMatch::DashpayReceivingFunds { account_index: u32, involved_addresses: Vec }, +CoreAccountTypeMatch::DashpayExternalAccount { account_index: u32, involved_addresses: Vec }, +``` + +Note: the match carries only `account_index`, **not** the two identity IDs — to +resolve which *contact* matched, the caller maps `account_index` back through +the collection. (The `Display` impl also elides the 32-byte ids for log +readability — `account_type.rs:143-150`.) + +### key-wallet-manager events (`key-wallet-manager/src/events.rs`) + +The block-processing layer is contact-aware only indirectly: event docs +reference the account type "(which carries any account-level indices like the +Dashpay `user_identity_id` / `friend_identity_id`)" (`events.rs:41-42`). There +is **no DashPay-specific event variant** — DashPay funds surface through the +generic per-account match/transaction events. + +--- + +## 7. FFI surface (`key-wallet-ffi/src/`) + +### Create / add DashPay accounts (`wallet.rs`) + +```c +// wallet.rs:397 — derive from wallet seed +FFIAccountResult wallet_add_dashpay_receiving_account( + FFIWallet *wallet, unsigned int account_index, + const uint8_t *user_identity_id /*32*/, const uint8_t *friend_identity_id /*32*/); + +// wallet.rs:451 — watch-only, supply the contact's account xpub bytes +FFIAccountResult wallet_add_dashpay_external_account_with_xpub_bytes( + FFIWallet *wallet, unsigned int account_index, + const uint8_t *user_identity_id /*32*/, const uint8_t *friend_identity_id /*32*/, + const uint8_t *xpub_bytes, size_t xpub_len); +``` + +`wallet_add_dashpay_receiving_account` builds +`AccountType::DashpayReceivingFunds`, calls `add_account(acct, None)` (derives +from seed). The external one decodes the supplied xpub +(`ExtendedPubKey::decode`, which accepts the 107-byte 256-bit format) and calls +`add_account(acct, Some(xpub))`. + +### Fetch managed DashPay accounts (`managed_account.rs`) + +```c +// managed_account.rs:436 +FFIManagedCoreAccountResult managed_wallet_get_dashpay_receiving_account( + /* wallet, */ unsigned int account_index, + const uint8_t *user_identity_id /*32*/, const uint8_t *friend_identity_id /*32*/); + +// managed_account.rs:497 +FFIManagedCoreAccountResult managed_wallet_get_dashpay_external_account(...same args...); +``` + +### Generic derivation FFI (usable but not DashPay-aware) + +- `derivation_derive_private_key_from_seed(seed, path_str)` — + `DerivationPath::from_str(path_str)` accepts `0x<64hex>` 256-bit segments, so a + caller *could* hand-build a DashPay path string and derive + (`derivation.rs:323`). +- `account_derive_extended_private_key_at` / `account_derive_private_key_at` / + `account_derive_*_from_{seed,mnemonic}` (`account_derivation.rs:32-377`) — + generic per-account child derivation. +- `wallet_derive_*` (`keys.rs:122-356`). + +There is **no** FFI that takes two identity IDs and returns the per-contact +derivation path or the per-contact account xpub directly (you go through the +add-account calls), and **no** FFI for ECDH/shared-key. + +--- + +## 8. Gaps / TODOs / incomplete + +1. **No ECDH / shared-secret / xpub-encryption** anywhere (see §5). This is the + biggest gap for DashPay: the DIP15 contact-request xpub encryption must be + built in the platform wallet, not reused from key-wallet. +2. **DashPay accounts are not auto-created at wallet init.** `from_mnemonic` -> + `create_accounts_from_options` does not include DashPay accounts; you add + them per-contact after the fact via `add_account` / + `wallet_add_dashpay_*`. (Confirmed: no DashPay branch in + `wallet/initialization.rs`; the only mention is a doc comment.) +3. **`extended_public_key_for_account_type` returns `None` for DashPay** + (`wallet/helper.rs:582-586`): *"Currently not retrieved via this helper"* — + so you can't pull a DashPay account xpub through that generic accessor; use + the account stored in the collection instead. +4. **Match results drop the identity IDs.** `CoreAccountTypeMatch::Dashpay*` + carries only `account_index` (`account_checker.rs:144-153`); resolving which + contact requires a reverse lookup via `DashpayAccountKey`. With multiple + contacts sharing the same `account_index` (different ids), matching iterates + all of them and the caller must disambiguate. +5. **No DashPay-specific event** in key-wallet-manager (§6); contact funds flow + through generic account events. +6. **External-account xpub must be pre-decrypted.** The FFI takes raw xpub bytes; + acquiring the friend's xpub from the on-platform DashPay contact-request + document (decrypting it) is out of scope for key-wallet. + +No `todo!()` / `unimplemented!()` were found in DashPay code paths — the gaps are +"feature not present" rather than "stubbed". + +--- + +## Key file:line index + +- DashPay path constants: `key-wallet/src/dip9.rs:138, 167-198`; references enum `:13-34`. +- 256-bit ChildNumber + DIP14 derivation: `key-wallet/src/bip32.rs:575-598, 650-662, 1533-1589, 1817+`; 256-hex parse `:855-905`; test vectors `:2521-2594`. +- AccountType DashPay variants + per-contact path: `key-wallet/src/account/account_type.rs:76-95, 218-225, 300-305, 469-514`. +- Collection keys/maps: `key-wallet/src/account/account_collection.rs:19-29, 75-80, 162-184`. +- ManagedAccountType + gap limit 20: `key-wallet/src/managed_account/managed_account_type.rs:97-118, 263-274, 706-749`. +- Address pool leaf derivation: `key-wallet/src/managed_account/address_pool.rs:128-142, 427-440`. +- Managed collection maps: `key-wallet/src/managed_account/managed_account_collection.rs:71-73, 244-268`. +- Tx routing + matching: `key-wallet/src/transaction_checking/transaction_router/mod.rs:183-198, 260-265`; `account_checker.rs:144-153, 501-518`. +- helper gap (xpub None for DashPay): `key-wallet/src/wallet/helper.rs:582-586`. +- add_account flow: `key-wallet/src/wallet/accounts.rs:28-64`. +- FFI: `key-wallet-ffi/src/wallet.rs:397, 451`; `key-wallet-ffi/src/managed_account.rs:436, 497`; generic derive `key-wallet-ffi/src/derivation.rs:323`. +- ECDH absence: repo-wide grep, no matches. diff --git a/docs/dashpay/research/03-rs-platform-wallet.md b/docs/dashpay/research/03-rs-platform-wallet.md new file mode 100644 index 00000000000..de631addc62 --- /dev/null +++ b/docs/dashpay/research/03-rs-platform-wallet.md @@ -0,0 +1,425 @@ +# DashPay implementation map — `rs-platform-wallet` (+ FFI, storage) + +Research date: 2026-06-10. Snapshot of the DashPay flow in the platform +wallet at the start of this work, with file:line citations and an +implemented/stub assessment per flow. + +Scope packages: +- `packages/rs-platform-wallet` — library (logic) +- `packages/rs-platform-wallet-ffi` — C FFI surface (iOS/Swift) +- `packages/rs-platform-wallet-storage` — SQLite persistence + +Key dependency facts (`rs-platform-wallet/Cargo.toml`): +- `dash-sdk` with features `["dashpay-contract", "dpns-contract", "wallet"]` + (line 12). The DashPay system contract is loaded **locally** (no network + round-trip) via `dpp::system_data_contracts::load_system_data_contract`. +- `platform-encryption` (line 13) — ECDH + AES-256-CBC for xpub/label encryption. +- `key-wallet` / `key-wallet-manager` (lines 16-17) — HD derivation, accounts, + address pools, transaction builder. +- All state-transition broadcasting goes through `dash_sdk::platform::transition::*` + (`PutDocument`, `PutContract`) and the SDK's `send_contact_request`. + +--- + +## TL;DR status table + +| Flow | Status | Where | +|------|--------|-------| +| Identity ↔ wallet (managed identities) | ✅ Implemented | `state/managed_identity/mod.rs`, `state/manager/*`, `network/identity_handle.rs` | +| DashPay contract load/cache | 🟡 Loaded-per-call (no cache) | `network/profile.rs:83`, `network/contact_requests.rs` (SDK fetches its own) | +| Profile — fetch/sync | ✅ Implemented | `network/profile.rs:64`, `:145` | +| Profile — create | ✅ Implemented (external signer only) | `network/profile.rs:240` | +| Profile — update | ✅ Implemented (external signer only) | `network/profile.rs:395` | +| Sync aggregator (`dashpay_sync`) | ✅ Implemented | `network/dashpay_sync.rs:16` | +| Sync contact requests (received) | 🟡 Implemented; no xpub decrypt in loop | `network/contact_requests.rs:322` | +| Send contact request | ✅ Implemented (seed-in-process only) | `network/contact_requests.rs:91` | +| Accept contact request | ✅ Implemented (reciprocal send) | `network/contact_requests.rs:466` | +| Reject contact request | 🟡 Local-only (no on-chain tombstone) | `network/contact_requests.rs:678` | +| Establish contact (auto) | ✅ Implemented | `state/managed_identity/contact_requests.rs` | +| Register receiving/external contact account | ✅ Implemented (seed-in-process) | `network/contacts.rs:100`, `:322` | +| Send money to a contact | ✅ Implemented | `network/payments.rs:93` | +| Record incoming payment | ✅ Implemented | `network/payments.rs:26` | +| Crypto: DIP-14 contact xpub / payment addrs | ✅ Implemented | `crypto/dip14.rs` | +| Crypto: account reference (DIP-15) | ✅ Implemented (but **unused** in send path) | `crypto/dip14.rs:147` | +| Crypto: auto-accept proof gen/verify | 🟡 Implemented but **dead code** | `crypto/auto_accept.rs` (`// TODO: Where and how we use these helpers?` :39) | +| Pre-send validation | 🟡 Implemented but **not called** by send path | `crypto/validation.rs:76` | +| Persistence round-trip (contacts/profile/payments) | ✅ Implemented | `wallet/apply.rs`, storage `schema/{contacts,dashpay}.rs` | +| FFI surface | ✅ Implemented | `ffi/src/{dashpay,dashpay_profile,contact_request,established_contact,contact}.rs` | + +Legend: ✅ Implemented · 🟡 Partial/caveated · ❌ Stub/Missing. +**There are zero `todo!()`/`unimplemented!()`/`unreachable!()` in the DashPay +code paths** — the gaps are caveats, dead helpers, and local-only fallbacks, +not panics. + +--- + +## 1. Identity ↔ wallet connection (managed identities) + +### `ManagedIdentity` — the shared state object +`state/managed_identity/mod.rs:37-105`. One `ManagedIdentity` carries BOTH the +Platform `Identity` and ALL DashPay fields: +- `identity: Identity` (:39) +- `identity_index: Option` (:51) — the HD slot + `m/9'/coin'/5'/0'/key_type'/identity_index'/key_id'`. `Some` ⇒ wallet-owned + (can sign / derive ECDH). `None` ⇒ out-of-wallet observed identity (cannot sign). +- `established_contacts: BTreeMap` (:60) +- `sent_contact_requests` / `incoming_contact_requests` (:63, :66) +- `dashpay_profile: Option` (:99) +- `dashpay_payments: BTreeMap` keyed by txid (:104) + +The manager keeps these in two buckets — `wallet_identities[wallet_id][identity_index]` +(signing-capable) and `out_of_wallet_identities[identity_id]` (read-only) — documented +at `mod.rs:27-35` and implemented in `state/manager/{mod,lifecycle,accessors,apply}.rs`. + +### `IdentityWallet` — the network façade +`network/identity_handle.rs:256-276`. A view over the shared +`Arc>>` plus `Arc`, a +`WalletPersister`, an `AssetLockManager`, and a generic broadcaster `B` +(defaults to `SpvBroadcaster`). It owns ALL DashPay operations (the historical +separate `DashPayWallet` was merged — see module doc `network/mod.rs:1-16`, +`identity_handle.rs:1-21`). DashPay ops reuse the same signer / asset-lock plumbing. + +### ECDH key derivation +`IdentityWallet::derive_encryption_private_key` (`identity_handle.rs:424-467`): +derives the sender's ECDH secp256k1 secret from the wallet seed at the DIP-9 +identity-auth path (`m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_id'`). **Requires +the seed in-process** — this is the root of the watch-only caveat throughout DashPay. + +### DashPay contract load/cache — 🟡 +Loaded fresh **per call** from the bundled system contract: +`network/profile.rs:83-93` and `:255` (and the SDK loads its own copy inside +`send_contact_request` / `fetch_*`). There is **no shared cached `Arc`** +on the wallet — each operation re-`load_system_data_contract`s. Cheap (in-memory, +bundled) but redundant. + +--- + +## 2. Profile + +Types: `types/dashpay/profile.rs`. +- `DashPayProfile` (:25-40) — stored/displayed model (no raw avatar bytes; only + `avatar_hash: [u8;32]` and `avatar_fingerprint: [u8;8]` survive). +- `ProfileUpdate` (:46-58) — input; carries raw `avatar_bytes` which the wallet + hashes then drops. +- `calculate_avatar_hash` (SHA-256, :61) and `calculate_dhash_fingerprint` + (perceptual dHash over a decoded image, :80) — both fully implemented. + +### Fetch / sync — ✅ +- `sync_profiles` (`network/profile.rs:64-139`): collect all managed identity ids, + load DashPay contract, fetch each profile doc, cache via + `managed.set_dashpay_profile(...)`. Clears the local cache when none on Platform. +- `fetch_profile_document` (`:145-219`): `Document::fetch_many` with + `DocumentQuery` `profile WHERE $ownerId == id LIMIT 1`; maps + `displayName / publicMessage / avatarUrl / avatarHash / avatarFingerprint` + into `DashPayProfile`. (`bio` is aliased from `publicMessage`.) + +### Create — ✅ (external signer only) +`create_profile_with_external_signer` (`network/profile.rs:240-388`): +1. load contract, 2. compute avatar hashes from bytes, 3. build `BTreeMap` +properties, 4. pick first HIGH/CRITICAL AUTHENTICATION ECDSA key (MASTER excluded +— see :308-314), 5. build a `DocumentV0` ("stub_document", :330 — the name is +benign, not a stub), 6. `put_to_platform_and_wait_for_response` (:356), 7. update +local cache. Real broadcast. + +### Update — ✅ (external signer only) +`update_profile_with_external_signer` (`network/profile.rs:395-592`): fetches the +existing profile doc for its id + revision, bumps `revision + 1`, preserves avatar +fields when no new bytes, broadcasts via `PutDocument`. Real broadcast. + +> Note: there are **no legacy non-signer** `create_profile` / `update_profile` +> variants in the current tree — only the `*_with_external_signer` forms exist. +> The docstrings reference `Self::create_profile` / `Self::update_profile` as the +> "legacy variant", but those methods are gone (grep returns nothing). + +--- + +## 3. Sync (`dashpay_sync.rs`) + +`network/dashpay_sync.rs:16-22` — `dashpay_sync()` is a 2-step aggregator: +`self.sync_contact_requests().await?` then `self.sync_profiles().await?`. +Failures propagate; partial progress not rolled back. ✅ Implemented. + +`sync_contact_requests` (`network/contact_requests.rs:322-451`): for every managed +identity, `sdk.fetch_received_contact_requests(id, None)`; for each received doc, +skip if already tracked, otherwise parse `senderKeyIndex / recipientKeyIndex / +accountReference / encryptedPublicKey` (warn+skip on missing) and call +`managed.add_incoming_contact_request(...)` (which may auto-establish). Returns +the newly-discovered requests. + +🟡 **Caveat**: the sync loop stores the **encrypted** `encryptedPublicKey` bytes +on the incoming `ContactRequest` but does NOT decrypt the contact's xpub or build +the external sending account during sync. Decryption + external-account +construction only happen on the **accept** path +(`register_external_contact_account`, see §5/§6). So a contact that becomes +established purely by sync (both requests arriving via sync) will have its xpub +sitting encrypted until `register_external_contact_account` is invoked. +`identity_sync.rs` (the periodic `IdentitySyncManager`) has **no** DashPay hooks — +DashPay refresh is driven separately through `dashpay_sync()` / the FFI. + +The SDK side is real: `rs-sdk/src/platform/dashpay/contact_request_queries.rs` +(`fetch_sent_contact_requests` :33, `fetch_received_contact_requests` :76). + +--- + +## 4. Send contact request + +`send_contact_request_with_external_signer` (`network/contact_requests.rs:91-304`): +1. look up sender `ManagedIdentity` + `identity_index` (`IdentityIndexNotSet` error + if out-of-wallet, :114). +2. `Identity::fetch` the recipient from Platform (:122). +3. resolve `sender_key_index` = first `Purpose::ENCRYPTION` key on sender (:136), + `recipient_key_index` = first `Purpose::DECRYPTION` key on recipient (:148). +4. derive the DashPay **receiving** account xpub at + `AccountType::DashpayReceivingFunds { index:0, user, friend }` and the sender's + ECDH private key via `derive_encryption_private_key` (:163-198). +5. pick a HIGH/CRITICAL AUTHENTICATION ECDSA key for the document signature (:201). +6. build SDK `ContactRequestInput` + `SendContactRequestInput` wrapping the borrowed + signer in `SignerRef` (:223-237). +7. **ECDH is `EcdhProvider::SdkSide`** (:240-261) — the wallet hands the SDK the + sender's private key; the SDK does ECDH + AES-256-CBC encryption of the xpub + internally (`rs-sdk/.../contact_request.rs:242-273`, exactly 96 bytes = + 16-byte IV + 80-byte ciphertext). `get_extended_public_key` closure (:266) + returns the encoded receiving xpub. +8. `self.sdk.send_contact_request(...)` broadcasts the `contactRequest` document + via `PutDocument` (`rs-sdk/.../contact_request.rs:438-448`). +9. mirror local state: build a `ContactRequest` and + `managed.add_sent_contact_request(...)` (:277-298), then + `register_contact_account(...)` to create the receiving account (:300). + +✅ Implemented end-to-end. 🟡 **Caveat (documented :81-89):** step 4 derives ECDH +from the wallet seed → **watch-only wallets fail here**. Only `EcdhProvider::SdkSide` +is ever used in the wallet (grep confirms no `ClientSide`); a follow-up FFI to push +ECDH across the boundary is noted but not built. + +> Inconsistency worth flagging: the locally-stored sent `ContactRequest` uses a +> placeholder `vec![0u8; 96]` for `encrypted_public_key` (:283) rather than the +> actual ciphertext the SDK produced — the real bytes are only on Platform. + +--- + +## 5. Receive / approve / accept contact request + +### Detect incoming +Via `sync_contact_requests` (§3) → `add_incoming_contact_request` +(`state/managed_identity/contact_requests.rs:87-127`). + +### Auto-establish +`add_sent_contact_request` (:22-62) and `add_incoming_contact_request` (:87-127): +if the reciprocal request already exists, immediately build an `EstablishedContact`, +insert it into `established_contacts`, and emit a `ContactChangeSet.established` +(the matching pending entries are dropped per the changeset contract — no separate +tombstone). `accept_incoming_request` (:153-194) does the same when **both** +requests already exist locally. Heavily unit-tested (`contact_requests.rs` tests + +`managed_identity/mod.rs` tests). + +### Accept (network) +`accept_contact_request_with_external_signer` (`network/contact_requests.rs:466-545`): +1. verify the incoming request is known. +2. capture the contact's encrypted xpub + key indices. +3. send the **reciprocal** request via + `send_contact_request_with_external_signer` (§4) — this is what marks the contact + established (auto-establishment fires when our sent request meets their incoming). +4. **best-effort** `register_external_contact_account(...)` (decrypt their xpub → + build watch-only sending account); failure is logged, not fatal (:511-528). +5. return the auto-established `EstablishedContact`. + +### Reject — 🟡 local only +`reject_contact_request` (`:678-714`): removes the incoming request locally; returns +`ContactRequestNotFound` if absent. Explicit `TODO` (:703) — no on-chain +`contactInfo` `display_hidden` document is written, so a reject does NOT sync across +devices. ("requires SDK support for document creation on arbitrary contracts which +is not yet available here" :672 — note this is slightly stale, since profile/contract +writes DO exist; only the `contactInfo` doc type is unwired.) + +--- + +## 6. Established contact + +`types/dashpay/established_contact.rs:14-35` — `EstablishedContact` holds: +`contact_identity_id`, `outgoing_request: ContactRequest`, `incoming_request: +ContactRequest`, plus local UI metadata `alias`, `note`, `is_hidden`, +`accepted_accounts: Vec`. It does **NOT** store derived shared keys or +derivation paths directly — those are reconstructed on demand from the two embedded +`ContactRequest`s (key indices) + the wallet seed. The actual receiving/sending +**accounts** live in the `key_wallet` `ManagedAccountCollection` +(`dashpay_receival_accounts` / `dashpay_external_accounts`), not on the contact. + +Local mutators: `state/managed_identity/contacts.rs` (`add/remove/get +established_contact`), plus alias/note/hide setters on the type itself. +`network/contacts.rs:26-48` `established_contacts()` flattens contacts across both +identity buckets (with a `TODO` about cloning, :22). + +Account registration: +- `register_contact_account` (`network/contacts.rs:100-156`): derives the + `DashpayReceivingFunds` xpub and inserts a funds-bearing managed account so SPV + watches incoming payments. Needs the seed (not watch-only safe). +- `register_external_contact_account` (`:322-516`): the **receive-from-contact-xpub** + path. Derives our ECDH key, fetches the contact identity, computes the shared key + via `platform_encryption::derive_shared_key_ecdh` (:434), decrypts their xpub via + `platform_encryption::decrypt_extended_public_key` (:438), decodes the + `ExtendedPubKey`, and registers a **watch-only** `DashpayExternalAccount` + (immutable `Account` for the xpub + managed account for the address pool, :455-507). + +Address matching for inbound payments: `match_incoming_dashpay_address` +(+ `_blocking` / `try_*`) and `match_in_collection` (`:181-260`) iterate +`dashpay_receival_accounts` and return a `DashpayAddressMatch`. + +--- + +## 7. Send money to a contact + +`send_payment` (`network/payments.rs:93-245`): ✅ Implemented. +1. resolve the contact's `DashpayExternalAccount` xpub from the immutable + `wallet.accounts.dashpay_external_accounts` (errors if + `register_external_contact_account` wasn't called first, :135). +2. derive the next unused address from the external account's address pool + (`external_account.next_address(...)`, :166). +3. fund from the standard BIP-44 account 0, build a signed tx via + `key_wallet`'s `TransactionBuilder` (`LargestFirst` selection, :192-203). +4. broadcast through the injected `self.broadcaster.broadcast(&tx)` (:209). +5. record a `PaymentEntry::new_sent(...)` on the sender's `ManagedIdentity` + via `record_dashpay_payment` (:225-242). + +Incoming payment recording: `try_record_incoming_payment` (`:26-60`): non-blocking +address match + spawn a task to record `PaymentEntry::new_received(...)`. + +Payment types: `types/dashpay/payment.rs` — `PaymentEntry` (counterparty_id, +amount_duffs, memo, `PaymentDirection`, `PaymentStatus`), `DashpayAddressMatch`. + +--- + +## 8. Crypto (`dip14.rs`, `auto_accept.rs`, `validation.rs`) + +`crypto/dip14.rs` — ✅ all implemented + well-tested: +- `derive_contact_xpub` (:82): path `m/9'/coin'/15'/account'/(sender_id)/(recipient_id)`, + last two segments DIP-14 256-bit non-hardened (`ChildNumber::Normal256` inside + `key_wallet::bip32`). Built via `AccountType::DashpayReceivingFunds.derivation_path`. +- `calculate_account_reference` (:147): DIP-15 HMAC-SHA256 ASK28 ⊕ account-bits with + 4-bit version prefix. ✅ correct — but **not wired into the send path** (the send + path hardcodes `account_reference = account_index = 0`; this helper is only used + in its own tests). 🟡 unused. +- `derive_contact_payment_address` / `_addresses` (:189, :216): BIP-32 non-hardened + derivation off the contact xpub → P2PKH. `DEFAULT_CONTACT_GAP_LIMIT = 10` (:235). + +`crypto/auto_accept.rs` — 🟡 implemented but **dead code**. `generate_auto_accept_proof` +(:116) / `verify_auto_accept_proof` (:158) at path `m/9'/coin'/16'/timestamp'` with a +70-byte proof format, all unit-tested. But an explicit module-level +`// TODO: Where and how we use these helpers?` (:39) — nothing in the wallet calls them. +The send path accepts an `auto_accept_proof: Option>` from the FFI caller but +never **generates** one internally. + +`crypto/validation.rs` — 🟡 implemented but **not invoked** by the live send path. +`validate_contact_request` (:76) checks sender ENCRYPTION key + recipient DECRYPTION +key types/purposes/disabled. Thoroughly tested, but `send_contact_request_with_external_signer` +does its own ad-hoc `find(...Purpose::ENCRYPTION...)` lookup and never calls this +validator. + +`rust-dashcore` / `key_wallet` calls used: `secp256k1` (ECDH, ECDSA sign/verify), +`hashes::{sha256, hmac, Hash}`, `bip32::{ExtendedPubKey, ExtendedPrivKey, ChildNumber, +DerivationPath}`, `Address::p2pkh`, `Wallet::derive_extended_{public,private}_key`, +`AccountType::derivation_path`. ECDH + AES live in the sibling `platform-encryption` +crate (`derive_shared_key_ecdh`, `encrypt/decrypt_extended_public_key`, +`encrypt/decrypt_account_label`). + +--- + +## FFI surface (`rs-platform-wallet-ffi`) + +### Network-broadcasting DashPay (`src/dashpay.rs`) +- `platform_wallet_get_managed_identity(wallet_handle, identity_id, out) -> Result` + — snapshot clone of a `ManagedIdentity` into `MANAGED_IDENTITY_STORAGE`. +- `platform_wallet_sync_contact_requests(wallet_handle, out_array) -> Result` + — wraps `IdentityWallet::sync_contact_requests`; returns `ContactRequestHandleArray`. +- `platform_wallet_send_contact_request_with_signer(wallet_handle, sender_id, + recipient_id, account_label, auto_accept_proof, auto_accept_proof_len, + signer_handle, out_request_handle) -> Result` — routes to + `send_contact_request_with_external_signer`. ECDH caveat documented (:206-212). +- `platform_wallet_accept_contact_request_with_signer(wallet_handle, request_handle, + signer_handle, out_established_handle) -> Result`. +- `platform_wallet_reject_contact_request(wallet_handle, our_identity_id, + contact_identity_id) -> Result` (local-only). +- `platform_wallet_fetch_sent_contact_requests(wallet_handle, identity_id, + out_array) -> Result`. +- `platform_wallet_send_dashpay_payment(wallet_handle, from_identity_id, + to_contact_identity_id, amount_duffs, memo, out_txid) -> Result`. +- `platform_wallet_contact_request_handle_array_free(*mut ContactRequestHandleArray)`. + +### Profile FFI (`src/dashpay_profile.rs`) +- `managed_identity_get_dashpay_profile(identity_handle, out_profile, + out_has_profile) -> Result` (reads cache). +- `platform_wallet_get_dashpay_profile(wallet_handle, identity_id, out_profile, + out_has_profile) -> Result`. +- `platform_wallet_sync_dashpay_profiles(wallet_handle, out_synced_count) -> Result`. +- `platform_wallet_create_or_update_dashpay_profile_with_signer(wallet_handle, + identity_id, display_name, public_message, avatar_url, avatar_bytes, + avatar_bytes_len, do_create, signer_handle, out_profile) -> Result` + — `do_create` toggles create vs update. +- `dashpay_profile_ffi_free(*mut DashPayProfileFFI)`. Flat struct + `DashPayProfileFFI` (:18-27). + +### Contact-request / established-contact field accessors +`src/contact_request.rs`: `contact_request_create`, `..._get_{sender_id, +recipient_id, sender_key_index, recipient_key_index, account_reference, +encrypted_public_key, created_at}`, `..._destroy`, +`managed_identity_get_{sent,incoming}_contact_request`. + +`src/established_contact.rs`: `managed_identity_get_established_contact`, +`established_contact_get_{contact_id, contact_identity_id, outgoing_request, +incoming_request, alias, note}`, `..._set_{alias,note}`, `..._clear_{alias,note}`, +`..._is_hidden`, `..._hide`, `..._unhide`, `..._destroy`. + +### Local-state-only contact ops (`src/contact.rs`) — legacy / in-memory +`managed_identity_get_{sent,incoming}_contact_request_ids`, +`managed_identity_get_established_contact_ids`, +`managed_identity_is_contact_established`, +`managed_identity_{send,accept,reject}_contact_request` — these mutate **local +state only** via a no-op persister (`ffi_noop_persister`), no Platform broadcast. +The module doc (`dashpay.rs:1-32`) says iOS flows should drive from the +`platform_wallet_*_with_signer` family instead; these remain for tests/bootstrap. + +`src/contact_persistence.rs` — `OnPersistContactsFn` callback type for host-driven +contact persistence. + +--- + +## Persistence round-trip (storage) + +DashPay state is fully round-tripped through the changeset → apply → SQLite pipeline: +- Local mutators emit changesets: `add_{sent,incoming}_contact_request`, + `set_dashpay_profile` (`identity_ops.rs:129`), `record_dashpay_payment` + (`identity_ops.rs:152`) — all call `persister.store(cs.into())`. +- Apply (restore): `wallet/apply.rs:173-256` routes `cs.contacts` (sent/incoming/ + established) to the owning `ManagedIdentity` (orphans skipped with a log), then + `dashpay_profiles` and `dashpay_payments_overlay` overlays (:240-256). +- Storage: `storage/src/sqlite/schema/contacts.rs` (single `contacts` table with + `state ∈ {sent, received, established}`, both request blobs + 4 metadata columns; + established upserts collapse/promote in one statement) and `schema/dashpay.rs` + (`dashpay_profiles`, `dashpay_payments_overlay`). + +--- + +## Most important gaps / risks + +1. **Watch-only wallets cannot do DashPay sends/accepts.** Every send/accept derives + the sender's ECDH key from the in-process seed + (`identity_handle.rs:424`, `contact_requests.rs:190`). Only `EcdhProvider::SdkSide` + is used. The planned "push ECDH across the FFI" follow-up is not built. +2. **Sync does not build sending accounts.** `sync_contact_requests` stores encrypted + xpubs but never decrypts them or registers `DashpayExternalAccount`s — only the + explicit accept path does. A contact established via two sync rounds may have no + sending account until `register_external_contact_account` is called manually. +3. **DIP-15 account reference is computed but unused.** `calculate_account_reference` + exists and is correct, but the send path hardcodes `account_reference = 0`. +4. **Auto-accept proofs are dead code** (`auto_accept.rs:39` TODO) — generated/verified + nowhere; the send path takes a caller-supplied proof but never produces one. +5. **Pre-send validation is dead code** — `validate_contact_request` is never called + by the live send path. +6. **Reject is local-only** — no on-chain `contactInfo` tombstone + (`contact_requests.rs:703` TODO); rejections don't sync across devices. +7. **No DashPay contract caching** — re-loaded from the bundled system contract on + every profile/contact-request operation. +8. **Locally-stored sent `ContactRequest` carries placeholder `vec![0u8;96]`** for + `encrypted_public_key` (`contact_requests.rs:283`) instead of the real ciphertext. +9. **No legacy non-signer profile/contact-request methods** — only the + `*_with_external_signer` variants exist, though docstrings still reference the + removed legacy forms. diff --git a/docs/dashpay/research/04-sdk-and-contract.md b/docs/dashpay/research/04-sdk-and-contract.md new file mode 100644 index 00000000000..033dbcaf00d --- /dev/null +++ b/docs/dashpay/research/04-sdk-and-contract.md @@ -0,0 +1,376 @@ +# 04 — DashPay Contract & SDK/FFI Surface + +Research into (A) the DashPay data-contract schema and (B) how `rs-sdk` / `rs-sdk-ffi` / +`rs-unified-sdk-ffi` expose DashPay operations — especially the `send_contact_request` path that does +ECDH + AES-256-CBC encryption internally (DIP-15). All file:line citations are against the worktree +`/Users/ivanshumkov/Projects/dashpay/platform.worktrees/dev`. + +--- + +## PART A — DashPay Data Contract + +### Contract identifiers + +`packages/dashpay-contract/src/lib.rs:9-17` + +```rust +pub const ID_BYTES: [u8; 32] = [ + 162, 161, 180, 172, 111, 239, 34, 234, 42, 26, 104, 232, 18, 54, 68, 179, 87, 135, 95, 107, 65, + 44, 24, 16, 146, 129, 193, 70, 231, 178, 113, 188, +]; +pub const OWNER_ID_BYTES: [u8; 32] = [0; 32]; +pub const ID: Identifier = Identifier(IdentifierBytes32(ID_BYTES)); +pub const OWNER_ID: Identifier = Identifier(IdentifierBytes32(OWNER_ID_BYTES)); +``` + +- **DashPay contract ID (base58)**: `Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7` + (hex `a2a1b4ac6fef22ea2a1a68e8123644b357875f6b412c18109281c146e7b271bc`). +- **Owner ID**: all-zero (`[0u8; 32]`) — system contract. +- Only **schema version 1** exists (`platform_version.system_data_contracts.dashpay == 1` → + `v1::load_documents_schemas()`; any other version is an error). `load_definitions` returns + `Ok(None)` for v1 (no `$defs`). See `lib.rs:19-38`. +- Rust accessors are minimal — `packages/dashpay-contract/src/v1/mod.rs:4-12` only exposes: + `document_types::contact_request::NAME = "contactRequest"` and + `document_types::contact_request::properties::TO_USER_ID = "toUserId"`. There are **no Rust + constants for the `profile` or `contactInfo` document types** — code refers to them by string + literal (`"profile"`, `"contactRequest"`, `"contactInfo"`). + +> NOTE on a second ID seen in code: the SDK fallback constant +> `DASHPAY_CONTRACT_ID = "GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec"` in +> `packages/rs-sdk/src/platform/dashpay/mod.rs:33` is the **DPNS** contract ID (also used in DPNS +> tests). It is only compiled in the `#[cfg(not(feature = "dashpay-contract"))]` path and is almost +> certainly a copy-paste bug — but in normal builds the `dashpay-contract` feature is on, so +> `SystemDataContract::Dashpay.id()` (= `Bwr4WHCP…`) is used and the wrong fallback is dead code. +> Flagged in "Gaps" below. + +### Schema: `packages/dashpay-contract/schema/v1/dashpay.schema.json` + +Three document types: `profile`, `contactInfo`, `contactRequest`. Property `position` numbers are +the binary serialization order. + +#### `profile` (schema lines 2-74) + +System-level: `minProperties: 1`, `additionalProperties: false`, `required: ["$createdAt", +"$updatedAt"]`. + +| Property | pos | Type | Constraints | +|---|---|---|---| +| `avatarUrl` | 0 | string (uri) | minLength 1, maxLength 2048 | +| `avatarHash` | 1 | byteArray | exactly 32 bytes (SHA256 of avatar image) | +| `avatarFingerprint` | 2 | byteArray | exactly 8 bytes (dHash of avatar image) | +| `publicMessage` | 3 | string | minLength 1, maxLength 140 | +| `displayName` | 4 | string | minLength 1, maxLength 25 | + +`dependentRequired`: any of `avatarUrl` / `avatarHash` / `avatarFingerprint` requires the other two. + +**Indices** +- `ownerId` — `[$ownerId asc]`, **unique** (one profile per identity). +- `ownerIdAndUpdatedAt` — `[$ownerId asc, $updatedAt asc]` (non-unique). + +#### `contactInfo` (schema lines 75-141) + +`additionalProperties: false`; `required: ["$createdAt", "$updatedAt", "encToUserId", +"privateData", "rootEncryptionKeyIndex", "derivationEncryptionKeyIndex"]`. + +| Property | pos | Type | Constraints | +|---|---|---|---| +| `encToUserId` | 0 | byteArray | exactly 32 bytes | +| `rootEncryptionKeyIndex` | 1 | integer | minimum 0 | +| `derivationEncryptionKeyIndex` | 2 | integer | minimum 0 | +| `privateData` | 3 | byteArray | 48–2048 bytes (encrypted CBOR of aliasName + note + displayHidden) | + +**Indices** +- `ownerIdAndKeys` — `[$ownerId asc, rootEncryptionKeyIndex asc, derivationEncryptionKeyIndex asc]`, + **unique**. +- `ownerIdAndUpdatedAt` — `[$ownerId asc, $updatedAt asc]` (non-unique). + +#### `contactRequest` (schema lines 142-254) + +Contract-level flags: `documentsMutable: false`, `canBeDeleted: false`, +`requiresIdentityEncryptionBoundedKey: 2`, `requiresIdentityDecryptionBoundedKey: 2`. +`additionalProperties: false`; `required: ["$createdAt", "$createdAtCoreBlockHeight", "toUserId", +"encryptedPublicKey", "senderKeyIndex", "recipientKeyIndex", "accountReference"]`. +(Note: `$createdAtCoreBlockHeight` is a required system field — pins core block height; there is no +`$updatedAt` because the doc is immutable.) + +| Property | pos | Type | Constraints | +|---|---|---|---| +| `toUserId` | 0 | byteArray (identifier) | exactly 32 bytes; `contentMediaType: application/x.dash.dpp.identifier` | +| `encryptedPublicKey` | 1 | byteArray | **exactly 96 bytes** (16-byte IV + 80-byte AES-CBC ciphertext) | +| `senderKeyIndex` | 2 | integer | minimum 0 | +| `recipientKeyIndex` | 3 | integer | minimum 0 | +| `accountReference` | 4 | integer | minimum 0 | +| `encryptedAccountLabel` | 5 | byteArray | 48–80 bytes (16-byte IV + AES-CBC ciphertext), optional | +| `autoAcceptProof` | 6 | byteArray | 38–102 bytes, optional, **not encrypted** | + +**Indices** +- `ownerIdUserIdAndAccountRef` — `[$ownerId asc, toUserId asc, accountReference asc]`, **unique** + (one request per (sender, recipient, account)). +- `ownerIdUserId` — `[$ownerId asc, toUserId asc]` (non-unique). +- `userIdCreatedAt` — `[toUserId asc, $createdAt asc]` (received-requests timeline). +- `ownerIdCreatedAt` — `[$ownerId asc, $createdAt asc]` (sent-requests timeline). + +--- + +## PART B — SDK / FFI DashPay surface + +### B.1 — Crypto primitives: `platform-encryption` crate + +All ECDH + AES-256-CBC lives in **`packages/rs-platform-encryption/src/lib.rs`** (crate +`platform-encryption`). This is what a prior agent meant by "rs-platform-wallet delegates encryption +to dash-sdk": the wallet calls `dash_sdk::platform::dashpay::send_contact_request`, which in turn +calls these `platform_encryption` functions. + +**ECDH shared-secret derivation** (`lib.rs:24-34`) — DIP-15 uses libsecp256k1's ECDH, i.e. +`SHA256((y[31]&0x1|0x2) || x)`: + +```rust +pub fn derive_shared_key_ecdh(private_key: &SecretKey, public_key: &PublicKey) -> [u8; 32] { + use dashcore::secp256k1::ecdh::SharedSecret; + let shared_secret = SharedSecret::new(public_key, private_key); + let mut key = [0u8; 32]; + key.copy_from_slice(shared_secret.as_ref()); + key +} +``` + +**AES-256-CBC core** (`lib.rs:6-11, 45-60`) — `cbc::Encryptor` with PKCS7 padding; 32-byte +key, 16-byte IV: + +```rust +type Aes256CbcEnc = cbc::Encryptor; + +pub fn encrypt_aes_256_cbc(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Vec { + let cipher = Aes256CbcEnc::new(key.into(), iv.into()); + // ... PKCS7-padded into buffer ... + cipher.encrypt_padded_mut::(&mut buffer, data.len())... +} +``` + +**Extended-public-key encryption** (`lib.rs:97-105`) — IV is prepended to the ciphertext; for a +78-byte xpub this yields exactly **96 bytes** (16 IV + 80 ciphertext): + +```rust +pub fn encrypt_extended_public_key(shared_key: &[u8; 32], iv: &[u8; 16], xpub: &[u8]) -> Vec { + let encrypted_data = encrypt_aes_256_cbc(shared_key, iv, xpub); + let mut result = Vec::with_capacity(16 + encrypted_data.len()); + result.extend_from_slice(iv); // IV prepended per DIP-15 + result.extend_from_slice(&encrypted_data); + result +} +``` + +**Account-label encryption** (`lib.rs:139-147`) — same IV-prepend shape, 48–80 bytes. +Decryption counterparts (`decrypt_extended_public_key` `lib.rs:115-128`, `decrypt_account_label` +`lib.rs:157-171`) split the first 16 bytes back off as IV. `CryptoError` enum at `lib.rs:174-184`. + +### B.2 — High-level send flow: `rs-sdk` + +Module: `packages/rs-sdk/src/platform/dashpay/` (declared in `packages/rs-sdk/src/platform.rs`). +`mod.rs` exposes the public types and two private helpers on `Sdk`: +`get_dashpay_contract_id()` (`mod.rs:23-42` — uses `SystemDataContract::Dashpay.id()`) and +`fetch_dashpay_contract()` (`mod.rs:45-63` — checks the context provider first, else fetches from +platform). + +**Public API surface (re-exported at `mod.rs:9-13`):** +- Types: `ContactRequestInput`, `ContactRequestResult`, `EcdhProvider`, `RecipientIdentity`, + `SendContactRequestInput`, `SendContactRequestResult`, `ContactRequestDocuments`. +- `Sdk::create_contact_request(...)` — builds the document locally (no broadcast). +- `Sdk::send_contact_request(...)` — builds + signs + broadcasts. +- Queries on `Sdk`: `fetch_sent_contact_requests`, `fetch_received_contact_requests`, + `fetch_all_contact_requests_for_identity` (in `contact_request_queries.rs`). + +**`EcdhProvider` — two modes** (`contact_request.rs:31-54`): `ClientSide { get_shared_secret }` +(hardware wallet supplies the 32-byte shared secret directly, given the recipient pubkey) and +`SdkSide { get_private_key }` (software wallet supplies the sender private key; the SDK does ECDH). + +**`create_contact_request` signature** (`contact_request.rs:164-177`): + +```rust +pub async fn create_contact_request( + &self, + input: ContactRequestInput, + ecdh_provider: EcdhProvider, + get_extended_public_key: H, // FnOnce(account_reference: u32) -> Future> +) -> Result +``` + +`ContactRequestInput` (`contact_request.rs:88-103`) carries `sender_identity: Identity`, +`recipient: RecipientIdentity` (an `Identifier` to be fetched, or a full `Identity`), +`sender_key_index`, `recipient_key_index`, `account_reference`, optional `account_label: String` +(the SDK encrypts it), optional `auto_accept_proof: Vec`. + +**What `create_contact_request` does, in order:** +1. Validates `auto_accept_proof` is 38–102 bytes if present (`:179-186`). +2. Fetches the recipient `Identity` if only an ID was given (`:189-197`). +3. Looks up the **sender's encryption key** at `sender_key_index`, asserts `purpose() == + ENCRYPTION` (`:200-216`). +4. Looks up the **recipient's decryption key** at `recipient_key_index`, asserts `purpose() == + DECRYPTION`, parses it as a secp256k1 `PublicKey` (`:218-239`). +5. **Derives the shared secret** (`:241-253`): + +```rust +let shared_key = match ecdh_provider { + EcdhProvider::ClientSide { get_shared_secret } => { + get_shared_secret(&recipient_public_key).await? + } + EcdhProvider::SdkSide { get_private_key } => { + let sender_private_key = get_private_key(sender_key, input.sender_key_index).await?; + derive_shared_key_ecdh(&sender_private_key, &recipient_public_key) + } +}; +``` + +6. Fetches the unencrypted extended public key via the caller's `get_extended_public_key` callback + (`:256`). +7. **Encrypts the xpub** with a fresh random 16-byte IV and asserts the result is exactly 96 bytes + (`:258-273`): + +```rust +let mut rng = StdRng::from_entropy(); +let mut xpub_iv = [0u8; 16]; +rng.fill_bytes(&mut xpub_iv); +let encrypted_public_key = + encrypt_extended_public_key(&shared_key, &xpub_iv, &extended_public_key); +if encrypted_public_key.len() != 96 { /* error */ } +``` + +8. If an `account_label` was given, encrypts it with a second fresh IV and asserts 48–80 bytes + (`:276-291`). +9. Fetches the DashPay contract, gets the `contactRequest` document type, generates entropy + + `Document::generate_document_id_v0(contract_id, sender_id, "contactRequest", entropy)` + (`:293-314`). +10. Builds the property `BTreeMap`: `toUserId` (`Value::Identifier`), `encryptedPublicKey` + (`Value::Bytes`, 96B), `senderKeyIndex`/`recipientKeyIndex`/`accountReference` (`Value::U32`), + and optionally `encryptedAccountLabel` / `autoAcceptProof` (`:316-346`). + +**`send_contact_request` signature** (`contact_request.rs:378-391`): + +```rust +pub async fn send_contact_request, F, Fut, G, Gut, H, Hut>( + &self, + input: SendContactRequestInput, // { contact_request, identity_public_key, signer } + ecdh_provider: EcdhProvider, + get_extended_public_key: H, +) -> Result +``` + +It calls `create_contact_request` (encryption happens there), wraps the result in a +`Document::V0 { … }` (`:414-429`), then broadcasts via the `PutDocument` trait +(`packages/rs-sdk/src/platform/transition/put_document.rs:20-47`): + +```rust +let platform_document = document + .put_to_platform_and_wait_for_response( + self, + contact_request_document_type.to_owned_document_type(), + Some(entropy.0), + input.identity_public_key, + None, // token payment info + &input.signer, + None, // settings + ) + .await?; +``` + +**Crypto details summarized:** key = 32-byte ECDH shared secret (`SHA256((y_parity)||x)`); cipher = +AES-256-CBC + PKCS7; IV = 16 random bytes generated per field, **prepended** to the ciphertext; +`encryptedPublicKey` is exactly 96 bytes (16 IV + 80 ct for a 78-byte xpub); `encryptedAccountLabel` +is 48–80 bytes. The 3 unit tests at `contact_request.rs:465-543` pin the 96-byte output, the +48–80-byte label range, the 38–102 proof range, and ECDH symmetry. + +### B.3 — Wallet integration (rs-platform-wallet, brief) + +`rs-platform-wallet` is the platform wallet that the Swift app drives. It **delegates** to the SDK +rather than re-implementing crypto: + +- **Send path** — `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:266` + calls `self.sdk.send_contact_request(send_input, ecdh_provider, …)`. It builds an + `EcdhProvider::SdkSide` whose `get_private_key` returns the wallet's ECDH key (with a key-id + guard, `:240-261`), and signs with a HIGH/CRITICAL `AUTHENTICATION` ECDSA key + (MASTER is rejected for document writes, `:200-218`). +- **Accept/receive path** — `packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs:434` + calls `platform_encryption::derive_shared_key_ecdh(&our_private_key, &contact_public_key)` then + `platform_encryption::decrypt_extended_public_key(...)` directly to recover the contact's xpub. +- `account_labels.rs` also uses `platform_encryption` for label encryption. + +### B.4 — `rs-sdk-ffi` exported DashPay functions + +Module wiring: `packages/rs-sdk-ffi/src/lib.rs:14` (`mod dashpay;`) + `:46` (`pub use dashpay::*;`). +`dashpay/mod.rs` just re-exports `contact_request::*`. **All DashPay FFI lives in +`packages/rs-sdk-ffi/src/dashpay/contact_request.rs`** and is fully implemented (no stubs): + +| `extern "C"` function | file:line | Status | +|---|---|---| +| `dash_sdk_dashpay_create_contact_request(handle, params) -> DashSDKResult` | `contact_request.rs:211` | implemented | +| `dash_sdk_dashpay_send_contact_request(handle, params, identity_public_key, signer) -> DashSDKResult` | `contact_request.rs:454` | implemented | +| `dash_sdk_dashpay_contact_request_result_free(result: *mut DashSDKContactRequestResult)` | `contact_request.rs:690` | implemented | +| `dash_sdk_dashpay_send_contact_request_result_free(result: *mut DashSDKSendContactRequestResult)` | `contact_request.rs:713` | implemented | + +**`#[repr(C)]` types Swift uses:** +- `DashSDKEcdhMode` (`:131`) — `ClientSide = 0`, `SdkSide = 1`. +- `DashSDKContactRequestParams` (`:140-173`) — sender identity handle, recipient_id (32B), + `fetch_recipient` bool, recipient identity handle, sender/recipient key indices, + `account_reference`, NUL-terminated `account_label`, `auto_accept_proof` + len, `ecdh_mode`, + `sender_private_key` (32B, SdkSide), `shared_secret` (32B, ClientSide), `extended_public_key` + len. +- `DashSDKContactRequestResult` (`:177`) — `document_id` (base58 C string), `owner_id` (base58), + `properties_json` (JSON). +- `DashSDKSendContactRequestResult` (`:188`) — `document_json`, `recipient_id` (base58), + `account_reference: u32`. + +The two entry points pick the ECDH mode from `params.ecdh_mode` and route through four internal +async helpers (`create/send_contact_request_with_shared_secret` / `_with_private_key`, +`:19-127`) that use turbofish to fix the SDK's complex generic `EcdhProvider`. The signer is taken +as a **non-owning** `VTableSignerRef` (`:564`). + +### B.5 — `rs-unified-sdk-ffi` + +`packages/rs-unified-sdk-ffi/src/lib.rs` (entire file): + +```rust +pub use dash_network; +pub use key_wallet_ffi; +pub use platform_wallet_ffi; +pub use rs_sdk_ffi; +``` + +It is a thin aggregator that **re-exports `rs_sdk_ffi` wholesale** (and `platform_wallet_ffi`, +`key_wallet_ffi`, `dash_network`). Because it builds as `staticlib`/`cdylib` +(`rs-unified-sdk-ffi/Cargo.toml:8`), all four `dash_sdk_dashpay_*` `#[no_mangle] extern "C"` symbols +from §B.4 are linked into the unified iOS framework and callable from Swift. There are **no +DashPay-specific functions defined in the unified crate itself.** + +--- + +## Gaps, TODOs, stubs + +1. **`send_contact_request` entropy mismatch (real bug)** — + `packages/rs-sdk/src/platform/dashpay/contact_request.rs:431-435`: the entropy used to build the + document ID in `create_contact_request` is **not** the entropy passed to + `put_to_platform_and_wait_for_response`; `send_contact_request` generates *fresh* entropy at + `:434`. The code comments call this out: *"In a real implementation, we'd need to store the + entropy used during creation. For now, we'll generate new entropy (this is a simplification)."* + The document ID and the state-transition entropy therefore diverge, which can cause the platform + to compute a different document ID than `ContactRequestResult.id`. Worth verifying against + `PutDocument` behavior (it may overwrite the ID from entropy via `set_id`, in which case the + returned `result.id` from `create_contact_request` is stale rather than fatal). + +2. **Wrong fallback contract ID** — `packages/rs-sdk/src/platform/dashpay/mod.rs:33` hardcodes + `GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec`, which is the **DPNS** contract ID, not DashPay + (correct DashPay = `Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7`). Only reachable when the + `dashpay-contract` feature is **off**; in default builds it is dead code, but it is latent. + +3. **No SDK helpers for `profile` or `contactInfo`** — `rs-sdk` only implements `contactRequest` + create/send/query. Profiles and contactInfo documents must be built/put via the generic + `Document` + `PutDocument` path; there are no `create_profile` / `put_profile` / + `create_contact_info` helpers, and no Rust string constants for those type names in + `dashpay-contract` (only `contactRequest` / `toUserId` are named in `v1/mod.rs`). + +4. **No FFI for `profile` / `contactInfo`** — the FFI surface is limited to contact requests. Swift + has no `dash_sdk_dashpay_*_profile` or `*_contact_info` entry points; those would go through the + generic document FFI. + +5. **No FFI for the receive/decrypt path** — there is no `dash_sdk_dashpay_decrypt_*` extern "C" + function; decryption (`decrypt_extended_public_key` / `decrypt_account_label`) is only reachable + from Rust (`rs-platform-wallet` uses it directly). The FFI exposes only the *send* direction of + the contact-request handshake. diff --git a/docs/dashpay/research/05-swift-app.md b/docs/dashpay/research/05-swift-app.md new file mode 100644 index 00000000000..3cda0a4e0c5 --- /dev/null +++ b/docs/dashpay/research/05-swift-app.md @@ -0,0 +1,365 @@ +# 05 — Swift App (SwiftExampleApp + swift-sdk) Architecture & DashPay Surface + +Research date: 2026-06-10. Worktree: `/Users/ivanshumkov/Projects/dashpay/platform.worktrees/dev`. +Base dir: `packages/swift-sdk/`. + +Goal: map the iOS app architecture, the Swift→FFI call pattern, and the +**existing** DashPay/identity/profile/contact surface, so we can design a +polished full-DashPay UI (sync → contact request → approve → send money → +profile) and a test plan. **Key finding up front: a working DashPay surface +already exists end-to-end** (contacts list, incoming/outgoing requests, +accept/reject, send-money-to-contact, profile create/edit, DPNS lookup). It +is functional-but-utilitarian and buried under a per-identity drill-in, not a +first-class tab. The work is largely **UI polish + promotion + test +coverage**, not greenfield wiring. + +--- + +## 1. App architecture + +### 1.1 Entry point & app-state coordinators + +`SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift` (`@main`) builds one +`ModelContainer` (`DashModelContainer.create()`) and injects a set of +`@StateObject` coordinators into the environment. There is **no single +`UnifiedAppState`** anymore — the old `UnifiedAppState` / `WalletService` / +`CoreWalletManager` / `SPVClient` stack was removed (see +`SwiftExampleApp/CLAUDE.md` lines 67-91). Current coordinators: + +| Object | Type | Role | File | +|---|---|---|---| +| `AppState` | `@StateObject platformState` | Platform SDK wrapper: identity/document/contract state, holds the `sdk` handle, `currentNetwork`. | `SwiftExampleApp/SwiftExampleApp/AppState.swift` | +| `WalletManagerStore` | `@StateObject` | Per-network store that lazy-creates + caches one `PlatformWalletManager` per network and republishes the active one. | `SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift` | +| `PlatformWalletManager` | env object (`walletManagerStore.activeManager`) | The workhorse. Holds N wallets keyed by walletId, drives SPV/BLAST/shielded sync, identity registration, **and all DashPay ops**. Publishes `spvProgress`, `wallets`, `lastError`. | `Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift` | +| `ShieldedService` | `@StateObject` | Orchard shielded-pool ops. | `Core/Services/ShieldedService.swift` | +| `PlatformBalanceSyncService` | `@StateObject` | Periodic BLAST platform-address balance sync. | `Core/Services/PlatformBalanceSyncService.swift` | +| `TransitionState` | `@StateObject` | Ephemeral state-transition flow state (pricing/eligibility). | `SwiftExampleApp/SwiftExampleApp/TransitionState.swift` | +| `AppUIState` | `@StateObject` | Tiny UI-only flag bag (e.g. `showWalletsSyncDetails`). | defined inline in `SwiftExampleAppApp.swift` | + +There is **no dedicated `IdentityService`** — identity ops live on +`PlatformWalletManager` / `ManagedPlatformWallet` / `ManagedIdentity` and the +`Services/*Coordinator.swift` files (registration, asset-lock funding). + +### 1.2 Navigation / tab structure + +`ContentView.swift` is the root. A 5-tab `TabView` (enum `RootTab`): + +```text +sync · wallets · identities · contracts · settings +``` + +Each tab wraps its content in its own `NavigationStack` +(`SyncStatusView`, `WalletsTabView`, `IdentitiesTabView`, +`ContractsTabView`, `SettingsView`). **There is no Contacts / DashPay +top-level tab.** DashPay lives under: `Identities` tab → `IdentityRow` +→ `IdentityDetailView` → `Section("DashPay")` → `FriendsView`, plus a +`Section("DashPay Profile")` on the same detail screen +(`ContentView.swift:99-111`, `IdentityDetailView.swift:184-204, 332-346`). + +A global sync progress bar is rendered as a `.overlay(alignment: .top)` on the +TabView (`ContentView.swift:120-125`, `GlobalSyncIndicator`). + +### 1.3 SwiftData models + +Models live in `Sources/SwiftDashSDK/Persistence/Models/` and are registered in +`DashModelContainer.swift`. DashPay-relevant ones: + +- `PersistentIdentity` — owns optional `dashpayProfile` (cascade) and DashPay + contact-request rows. +- `PersistentDashpayProfile` — `displayName`, `publicMessage`, `bio`, + `avatarUrl`, `avatarHash`, `avatarFingerprint`, `network`, back-ref + `identity`. (`Models/PersistentDashpayProfile.swift`) +- `PersistentDashpayContactRequest` — `ownerIdentityId`, `contactIdentityId`, + `isOutgoing`, `senderKeyIndex`, `recipientKeyIndex`, `accountReference`, + `encryptedPublicKey`, `encryptedAccountLabel`, `autoAcceptProof`, + `coreHeightCreatedAt`, `createdAtMillis`, back-ref `owner`. + (`Models/PersistentDashpayContactRequest.swift`) +- `PersistentDPNSName`, plus wallet/account/tx/token/shielded models. + +Both DashPay models are **cascade-owned by `PersistentIdentity`** and written by +the Rust persister callback via +`PlatformWalletPersistenceHandler.upsertDashpayProfile` (see +`DashModelContainer.swift:141-157`). **NB:** `FriendsView` today does NOT +`@Query` these rows — it reads live state off the Rust `ManagedIdentity` +snapshot each load. So the persisted rows exist but the UI doesn't yet use them +reactively (a clean opportunity for the new UI — see §4). + +### 1.4 FFI call pattern (view → service → FFI) + +Architecture rule (`packages/swift-sdk/CLAUDE.md`): the Swift SDK only +**persists, loads, and bridges**. All orchestration (gap-limit scans, derivation +pipelines, DashPay payment address derivation) lives in the Rust +`platform-wallet` crate, reached via `rs-platform-wallet-ffi`. Swift wrappers +are thin: resolve handle → marshal in → call → marshal out. + +The canonical async-FFI shape (from `ManagedPlatformWallet.sendContactRequest`, +`ManagedPlatformWallet.swift:1484`): + +```swift +public func sendContactRequest( + senderIdentityId: Identifier, + recipientIdentityId: Identifier, + accountLabel: String? = nil, + autoAcceptProof: Data? = nil, + signer: KeychainSigner +) async throws -> ContactRequest { + let handle = self.handle // UInt64 wallet handle + let signerHandle = signer.handle + let senderBytes = senderIdentityId.withFFIBytes { Array(UnsafeBufferPointer(start: $0, count: 32)) } + let recipientBytes = recipientIdentityId.withFFIBytes { Array(UnsafeBufferPointer(start: $0, count: 32)) } + + let requestHandle: Handle = try await Task.detached(priority: .userInitiated) { + _ = signer // keepalive — ctx is passUnretained + var outHandle: Handle = NULL_HANDLE + let result = senderBytes.withUnsafeBufferPointer { s in + recipientBytes.withUnsafeBufferPointer { r in + platform_wallet_send_contact_request_with_signer( + handle, s.baseAddress!, r.baseAddress!, /*label*/nil, + /*proof*/nil, 0, signerHandle, &outHandle) + } + } + try result.check() // PlatformWalletFFIResult → throws PlatformWalletError + return outHandle + }.value + return ContactRequest(handle: requestHandle) +} +``` + +Pattern characteristics, repeated across the SDK: +- **Handles** are `UInt64` (`typealias Handle`); opaque Rust objects + (`ManagedPlatformWallet`, `ContactRequest`, `EstablishedContact`, + `ManagedIdentity`) are `final class … @unchecked Sendable` wrapping one + handle, freeing it in `deinit`. +- **Blocking FFI runs in `Task.detached(priority: .userInitiated)`**; the + wrapper method is `async throws`. Result codes come back as + `PlatformWalletFFIResult` with a `.check()` that throws + `PlatformWalletError`. +- **Signing** always goes through a `KeychainSigner` (FFI `_with_signer` + variants) — never the wallet seed. `KeychainSigner` + (`Sources/SwiftDashSDK/FFI/KeychainSigner.swift`) is the C-ABI signer + trampoline: Rust calls back with raw pubkey bytes, Swift looks up the + `PersistentPublicKey` row, pulls the 32-byte scalar from Keychain, signs, + zeroes the buffer. Must be kept alive across the detached task (`_ = signer`). +- **Out-params** are inline C tuples (32-byte ids, etc.) copied into owned + `Data`; arrays come back as FFI structs with a paired `_free` function called + via `defer`. +- Optional C-strings marshalled via the local `invokeWithOptionalCStrings` + helper (in `ManagedPlatformWallet.swift`). + +View-side dispatch (from `FriendsView.acceptRequest`, +`FriendsView.swift:244`): a `@State` var holds UI data; the action wraps the +async call in `Task { @MainActor in … }`, resolves the wallet via +`walletManager.wallet(for: walletId)`, constructs a `KeychainSigner`, calls the +wrapper, sets `errorMessage` on throw, and re-runs `loadFriends()` on success. + +--- + +## 2. Existing identity / wallet / DashPay surface + +### 2.1 DashPay UI — **already exists** (the big finding) + +`SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift` (917 lines) is a +complete, working DashPay contacts screen. It contains these views: + +| View (in FriendsView.swift) | What it does | Status | +|---|---|---| +| `FriendsView` | List of established contacts + incoming-requests section; toolbar "add friend"; per-contact tap → send-money sheet. Loads via `wallet.syncContactRequests()` then reads `ManagedIdentity` snapshot ids. | Working, utilitarian | +| `ContactRowView` | Avatar (initial circle), display name, dpns/hex subtitle, note. | Working | +| `ContactRequestRow` | Incoming request with Accept/Reject buttons; relative timestamp. | Working | +| `AddFriendView` | Send contact request by **DPNS name** or **Identity ID** (segmented picker); resolves via `wallet.resolveDpnsName` / base58 parse; calls `wallet.sendContactRequest(…signer:)`. | Working | +| `SendDashPayPaymentSheet` | Send Dash to a contact: amount in DASH→duffs, shows sender balance, recipient profile/avatar/DPNS, over-spend validation, calls `wallet.sendDashPayPayment(…)`, shows txid. | Working, fairly polished | +| value types `DashPayContact`, `DashPayContactRequest` | Lightweight UI models. | — | + +Profile UI is on `IdentityDetailView.swift`: +- `Section("DashPay Profile")` + `dashPayProfileCard(identity:)` — three states + (populated / empty placeholder / loading), "Edit Profile" / "Set up profile" + button (`IdentityDetailView.swift:332-346, 748-835`). +- `DashPayProfileEditorView` (`IdentityDetailView.swift:1169-1410`) — Form with + display name / public message / avatar URL; on save fetches avatar bytes + (for DIP-15 hash/fingerprint), calls `wallet.createDashPayProfile` / + `updateDashPayProfile(…signer:)`. +- `loadCachedDashPayProfile` / `refreshDashPayProfilesFromPlatform` — cached read + + `syncDashPayProfiles()` network refresh. +- `EditAliasView` (`IdentityDetailView.swift:1411`) — local alias editing. + +DPNS UI: `Section("DPNS Names")` in `IdentityDetailView` (register/contested/ +select-main), plus `RegisterNameView.swift`, `SelectMainNameView.swift`, +`DPNSTestView.swift`. Recipient selection helper: +`Views/Components/RecipientPickerView.swift` (pick a local identity / paste a +base58 id — used by credit-transfer flows, reusable for DashPay). + +### 2.2 Identity & wallet surface + +Extensive. Identities: `IdentitiesContentView` (list, swipe-to-remove), +`IdentityDetailView` (the hub), `CreateIdentityView`, `LoadIdentityView`, +`TopUpIdentityView`, `AddIdentityKeyView`, `KeysListView`/`KeyDetailView`, +`SearchWalletsForIdentitiesView`, registration progress views, and the +`Services/*RegistrationCoordinator*` / `AddressFundFromAssetLock*` controllers. +Wallets/Core: `Core/Views/` — `WalletsContentView`, `WalletDetailView`, +`AccountListView`/`AccountDetailView`, `CreateWalletView`, `SeedBackupView`, +`SendTransactionView`, `ReceiveAddressView`, `TransactionListView`. Plus the +shielded-pool funding views. So DashPay sits inside a mature, busy demo app. + +--- + +## 3. Swift SDK wrapper status — DashPay FFI coverage + +**All core DashPay FFI functions are already wrapped.** From +`Sources/SwiftDashSDK/PlatformWallet/`: + +| Capability | Swift method | FFI symbol | File:line | +|---|---|---|---| +| Sync incoming contact requests | `ManagedPlatformWallet.syncContactRequests()` | `platform_wallet_sync_contact_requests` | `ManagedPlatformWallet.swift:1452` | +| Send contact request | `.sendContactRequest(…signer:)` | `platform_wallet_send_contact_request_with_signer` | `:1484` | +| Accept contact request | `.acceptContactRequest(_:signer:)` → `EstablishedContact` | `platform_wallet_accept_contact_request_with_signer` | `:1556` | +| Reject contact request | `.rejectContactRequest(ourIdentityId:contactIdentityId:)` | `platform_wallet_reject_contact_request` | `:1585` | +| Fetch sent requests | `.fetchSentContactRequests(identityId:)` | `platform_wallet_fetch_sent_contact_requests` | `:1612` | +| Send money to contact | `.sendDashPayPayment(from:to:amountDuffs:memo:)` → txid | `platform_wallet_send_dashpay_payment` | `:1651` | +| Read cached profile | `.getDashPayProfile(identityId:)` | `platform_wallet_get_dashpay_profile` | `:1714` | +| Sync all profiles | `.syncDashPayProfiles()` | `platform_wallet_sync_dashpay_profiles` | `:1744` | +| Create profile | `.createDashPayProfile(…signer:)` | `platform_wallet_create_or_update_dashpay_profile_with_signer` | `:1763` | +| Update profile | `.updateDashPayProfile(…signer:)` | same (`doCreate:false`) | `:1779` | +| Resolve DPNS name | `.resolveDpnsName(_:)` | `platform_wallet_resolve_dpns_name` | `:1261` | +| Register DPNS name | `.registerDpnsName(…signer:)` | `platform_wallet_register_dpns_name_with_signer` | `:1215` | +| Search DPNS | `.searchDpnsNames(prefix:limit:)` | `platform_wallet_search_dpns_names` | `:1285` | +| Sync DPNS names | `.syncDpnsNames(identityId:)` | `platform_wallet_sync_dpns_names` | `:1335` | + +Supporting wrapper types/objects (all in `Sources/SwiftDashSDK/PlatformWallet/`): +- `ContactRequest.swift` — opaque handle; getters for sender/recipient id, key + indices, account ref, encrypted pubkey, createdAt; `create(…)` builder. +- `EstablishedContact.swift` — `getContactIdentityId`, `getAlias`/`setAlias`/ + `clearAlias`, `getNote`/`setNote`/`clearNote`, `isHidden`/`hide`/`unhide`. +- `DashPayProfile.swift` — value struct (`displayName`, `publicMessage`, + `avatarUrl`, `avatarHash`, `avatarFingerprint`) + `DashPayProfileUpdate` + input struct (+ `avatarBytes` for DIP-15 hashing). +- `ManagedIdentity.swift` — local-cache accessors: + `getSentContactRequestIds` / `getIncomingContactRequestIds` / + `getEstablishedContactIds` (`:240-307`), single-item + `getSentContactRequest` / `getIncomingContact` / `getEstablishedContact` + (`:307-360`), `isContactEstablished` (`:360`), `getDashPayProfile()` + (`:444`), `getDpnsNames` / `getContestedDpnsNames` (`:399-407`). + +**Known gaps / caveats (from docstrings):** +1. **ECDH on watch-only wallets** — `sendContactRequest` still derives the + sender's ECDH key Rust-side from the wallet seed; watch-only wallets fail at + the encryption step (`ManagedPlatformWallet.swift:1480-1483`). A future FFI + must push ECDH across. +2. **Reject is local-only** — `rejectContactRequest` only drops the local entry; + no `display_hidden` contactInfo doc is written yet (`:1580-1584`). +3. **Payment memo** is recorded on the Rust `PaymentEntry` but not embedded + on-chain; the payment sheet deliberately omits a memo field + (`FriendsView.swift:765-772, 890-893`). +4. `DashPayProfileEditorView` falls back to `firstWallet` when `walletId` is nil + — needs tightening for multi-wallet (`IdentityDetailView.swift:1171-1175`). +5. **Persisted DashPay rows are not yet `@Query`-driven in the UI** — FriendsView + reads the live Rust snapshot; the `PersistentDashpay*` rows exist but aren't + the reactive source of truth. + +--- + +## 4. Where new DashPay screens slot in + +The cleanest design move is to **promote DashPay from a per-identity drill-in to +a first-class experience** while reusing the already-wrapped FFI. Two viable +shapes: + +**Option A (lower risk, matches house style): keep it per-identity, polish in +place.** Extend `IdentityDetailView`'s `Section("DashPay")` + the existing +`FriendsView` / `DashPayProfileEditorView`. No new tab; the identity is the +account context, which is architecturally honest (contacts belong to an +identity). + +**Option B (nicer UX, more work): add a top-level `RootTab.dashpay` tab.** Add a +case to `RootTab` (`ContentView.swift:6-7`), a `DashPayTabView { NavigationStack +{ … } }` wrapper, and an identity picker at the top (most DashPay UIs assume one +active identity). This is where a "nice UI" lives. Insertion points: + +| New screen | Slots into | Service/method to call (already wrapped) | +|---|---|---| +| **Contacts list** | New `ContactsView` (tab root) or replace `FriendsView` body; drive off `@Query [PersistentDashpayContactRequest]`/`[PersistentDashpayProfile]` for reactivity, refreshed by `syncContactRequests()` + `syncDashPayProfiles()`. | `getEstablishedContactIds`, `getDashPayProfile`, `EstablishedContact` | +| **Contact requests (incoming/outgoing)** | A `Section`/segmented sub-view; today incoming is inline in `FriendsView`, outgoing (`sentRequests`) is loaded but **not rendered** — add the outgoing section. | `getIncomingContactRequestIds`, `getSentContactRequestIds`, `fetchSentContactRequests` | +| **Send contact request by username** | Replace/restyle `AddFriendView`; add live DPNS prefix search. | `searchDpnsNames`, `resolveDpnsName`, `sendContactRequest(…signer:)` | +| **Approve / reject requests** | Already in `ContactRequestRow`; just restyle + add toast/animation. | `acceptContactRequest(…signer:)`, `rejectContactRequest` | +| **Profile view/edit** | Promote `DashPayProfileEditorView`; add avatar image preview + DPNS handle. Move it out of `IdentityDetailView` into a standalone `ProfileView`. | `getDashPayProfile`, `createDashPayProfile`/`updateDashPayProfile(…signer:)`, `syncDashPayProfiles` | +| **Send money to contact** | Restyle `SendDashPayPaymentSheet`; it's already the most polished piece. | `sendDashPayPayment`, `wallet.balance()` | +| **Initial sync** | Wire DashPay sync into the existing `Sync` tab / `GlobalSyncIndicator`, or run `syncContactRequests()`+`syncDashPayProfiles()` on the DashPay tab `.task`. | existing sync coordinators on `PlatformWalletManager` | + +**Service to extend:** all of it lands on `ManagedPlatformWallet` (resolved via +`walletManager.wallet(for: identity.wallet?.walletId)`). No new FFI is required +for the happy path — only the watch-only ECDH gap (caveat 1) and the +persistent-reject doc (caveat 2) need Rust-side follow-ups, and those are +**platform-wallet crate** changes per `swift-sdk/CLAUDE.md`, not Swift. + +--- + +## 5. House-style conventions a new screen must follow + +From the existing DashPay/identity views (`FriendsView`, `IdentityDetailView`, +`RecipientPickerView`) and `SwiftExampleApp/CLAUDE.md`: + +- **View naming:** `*View` for screens/rows, `*Sheet` for modal sheets, + `*EditorView` for forms. One file may hold several related views + (`FriendsView.swift` holds 6). +- **State:** `@EnvironmentObject var walletManager: PlatformWalletManager` + + `@EnvironmentObject var appState: AppState` (a.k.a. `platformState`); + `@Environment(\.modelContext)`; local `@State` for view data, `isLoading` / + `isSending` flags, and a `String? errorMessage` surfaced as a red caption. +- **Reactivity:** prefer SwiftUI `@Query` on `Persistent*` models for lists + (CLAUDE.md "Use `@Query` for reactive data"). The new DashPay UI should move + off the snapshot-read pattern onto `@Query [PersistentDashpay*]`. +- **Async FFI dispatch:** wrap calls in `Task { @MainActor in … }`; `defer { + isLoading = false }`; resolve wallet via `walletManager.wallet(for:)`; + construct `KeychainSigner(modelContainer: modelContext.container)` per submit; + set `errorMessage = error.localizedDescription` on `catch`. +- **Layout:** `Form` + `Section` for editors/detail; `List` + `Section` with + `Text("… (\(count))")` headers for lists. `NavigationLink(destination:)` for + drill-down; `.sheet(isPresented:)` / `.sheet(item:)` for modals (item-based + uses an `Identifiable` value type). Toolbar Cancel (leading) / action + (trailing, swaps to `ProgressView` while busy). +- **Theming:** SF Symbols everywhere (`person.2`, `person.badge.plus`, + `paperplane`, `pencil`, `person.crop.circle`); avatar = blue + `Circle().fill(.opacity(0.2))` with the first initial, upgraded to + `AsyncImage` when `avatarUrl` is present (see `SendDashPayPaymentSheet`). + Accent `Color.blue`; destructive `.tint(.red)`; success `.green` captions. + `.buttonStyle(.borderedProminent)` for primary actions. +- **Amounts:** input in **DASH**, convert to **duffs** (`× 100_000_000`) before + the FFI; show spendable balance and block over-spends + (`SendDashPayPaymentSheet.amountDuffs`). +- **Identifiers vs display:** ids are `Data` (32 bytes); display via + `toBase58String()` / truncated `toHexString().prefix(12) + "…"`; prefer + DashPay `displayName` → DPNS label → truncated hex (the + `recipientDisplayName` precedence in `SendDashPayPaymentSheet`). +- **Architecture guardrail:** never orchestrate derivation/iteration in Swift; + every multi-step DashPay op must be one `platform-wallet` FFI call + (`packages/swift-sdk/CLAUDE.md`). New screens only marshal + persist + render. +- **Accessibility:** add `.accessibilityIdentifier(...)` on tab/key controls + (precedent: `rootTab.wallets`) — needed for the UI test plan. + +### Test plan seed +- **Existing coverage is thin:** no DashPay-specific tests in + `SwiftTests/SwiftDashSDKTests/` or `SwiftExampleApp/SwiftExampleAppTests/` + (only `WalletDeletionTests` mentions profiles tangentially). `ContactRequest`, + `EstablishedContact`, `DashPayProfile` round-trips and the wrapper marshalling + are **untested**. +- Add: (a) Swift unit tests in `SwiftTests/SwiftDashSDKTests/` for + `ContactRequest`/`EstablishedContact`/`DashPayProfile(ffi:)` round-trips and + `DashPayProfileUpdate` marshalling; (b) flow tests for the + send→sync→accept→established cycle against a regtest/devnet wallet + (mirror `PlatformWalletIntegrationTests.swift`); (c) XCUITest in + `SwiftExampleAppUITests/` driving the new tab (add request by DPNS → approve → + send money), keyed on accessibility identifiers; (d) the + `simulator-control` skill is available to drive SwiftData + screenshots for + UAT verification. + +--- + +## Appendix — key file paths + +- App root / tabs: `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift`, `SwiftExampleAppApp.swift` +- DashPay screens: `…/Views/FriendsView.swift`, `…/Views/IdentityDetailView.swift` (profile section + editor), `…/Views/Components/RecipientPickerView.swift` +- Identity/wallet hub: `…/Core/Views/IdentitiesContentView.swift`, `…/Views/IdentityDetailView.swift`, `…/Core/Views/WalletsContentView.swift` +- SDK DashPay wrappers: `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/{ManagedPlatformWallet,ContactRequest,EstablishedContact,DashPayProfile,ManagedIdentity}.swift` +- Signer / FFI plumbing: `…/Sources/SwiftDashSDK/FFI/KeychainSigner.swift`, `…/PlatformWallet/PlatformWalletFFI.swift`, `…/PlatformWallet/PlatformWalletResult.swift` +- SwiftData DashPay models: `…/Sources/SwiftDashSDK/Persistence/Models/{PersistentDashpayProfile,PersistentDashpayContactRequest,PersistentIdentity}.swift`, container `…/Persistence/DashModelContainer.swift` +- Persister callback: `…/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift` +- Architecture rules: `packages/swift-sdk/CLAUDE.md` (SDK), `packages/swift-sdk/SwiftExampleApp/CLAUDE.md` (app) diff --git a/docs/dashpay/research/06-interop-desk-check.md b/docs/dashpay/research/06-interop-desk-check.md new file mode 100644 index 00000000000..74608dc4d71 --- /dev/null +++ b/docs/dashpay/research/06-interop-desk-check.md @@ -0,0 +1,458 @@ +# DashPay cross-client interop desk-check + +Research date: 2026-06-10 (Milestone 1, task 5 — verify-only). +Question: do THIS stack's DashPay wire formats match the reference clients (iOS DashSync, +Android dashj/android-dashpay), per DIP-15? If not, contacts established by our wallet +cannot pay / be paid by mobile-app users. + +Reference sources (all read on this date): + +| Source | Ref | +|---|---| +| DIP-15 spec | https://raw.githubusercontent.com/dashpay/dips/master/dip-0015.md | +| iOS DashSync (master) | https://github.com/dashpay/dashsync-iOS | +| iOS crypto core (master) | https://github.com/dashpay/dash-shared-core (`dash-spv-masternode-processor`) | +| Android DashPay lib (master) | https://github.com/dashpay/android-dashpay | +| Android dashj (master) | https://github.com/dashpay/dashj | +| key-wallet (our BIP32) | rust-dashcore @ `3d0d5dcd4ad64e2199a726651bca7f8ffac123e6`, `key-wallet/src/bip32.rs` | + +## Verdict summary + +| # | Item | Verdict | +|---|------|---------| +| 1 | encryptedPublicKey plaintext layout | **FAIL** — ours is a 107-byte DIP-14 serialization; spec + both reference clients use the 69-byte compact (`fingerprint(4) ‖ chainCode(32) ‖ pubKey(33)`). Our current send path cannot even produce a valid document (128-byte ciphertext vs the contract's hard 96). Our receive path rejects reference-client payloads. | +| 2 | ECDH shared-key derivation | **PASS** — all three stacks compute libsecp256k1-style `SHA256((y[31]&0x1\|0x2) ‖ x)`. | +| 3 | accountReference | **PASS for cross-client interop** (recipients disregard it per DIP-15; our hardcoded 0 is harmless to mobile counterparties) — but **our compute helper is wrong on two axes** (HMAC input + ASK28 byte order) and must be fixed before we ever send real values. | +| + | senderKeyIndex / recipientKeyIndex conventions | **Interop hazard (bonus finding)** — mobile clients reference the identity's first ECDSA key (key 0, purpose AUTHENTICATION); our stack requires purpose ENCRYPTION/DECRYPTION on both send and receive, so cross-client requests fail key validation in both directions. | + +--- + +## (1) encryptedPublicKey plaintext — FAIL + +### What DIP-15 specifies + +DIP-15 "Encrypted Extended Public Key (encryptedPublicKey)": + +> The `encryptedPublicKey` property is a binary field that has the following format once it is +> deserialized to bytes: +> * Initialization Vector (16 bytes) +> * Encrypted extended public key with padding (80 bytes) that is encrypted by CBC-AES-256 using a +> shared ECDH key +> +> The data format of the extended public key differs from what is defined in the Serialization +> Format of BIP32. This is because only the following fields are necessary when constructing +> derivations. The binary format used is as follows: +> * Parent fingerprint (4 bytes) +> * Chain code (32 bytes) +> * Public Key (33 bytes) + +So the plaintext is the **compact 69-byte** form. 69 → PKCS7 pad → 80; +16 IV = the 96 bytes +that the deployed contract enforces (`packages/dashpay-contract/schema/v1/dashpay.schema.json:207-212`, +`minItems: 96, maxItems: 96`). + +### What OUR stack encrypts + +The send path (`packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:124-160`) +derives the full DashPay receiving path and encrypts `ExtendedPubKey::encode()`: + +```rust +// contact_requests.rs:131-150 +let account_type = AccountType::DashpayReceivingFunds { + index: account_index, + user_identity_id: sender_identity_id.to_buffer(), + friend_identity_id: recipient_identity_id.to_buffer(), +}; +let account_path = account_type.derivation_path(self.sdk.network) ...; +let account_xpub = wallet.derive_extended_public_key(&account_path) ...; +let xpub = account_xpub.encode(); +``` + +That path is `m/9'/coin'/15'/0'/(sender_id)₂₅₆/(recipient_id)₂₅₆` +(key-wallet `account_type.rs:469-492` pushes two `ChildNumber::Normal256`), so the derived +key's `child_number.is_256_bits()` is true and `encode()` dispatches to the **DIP-14 +107-byte** serialization, not the 78-byte BIP32 one: + +```rust +// key-wallet/src/bip32.rs:1884-1890 +pub fn encode(&self) -> Vec { + if self.child_number.is_256_bits() { + self.encode_256().to_vec() // [u8; 107]: version(4)+depth(1)+fingerprint(4)+hardening(1)+child(32)+chaincode(32)+pubkey(33) + } else { + self.encode_32().to_vec() // [u8; 78]: standard BIP32 + } +} +``` + +The bytes are passed verbatim through the write seam +(`network/sdk_writer.rs:254-257`) into `Sdk::send_contact_request` → +`create_contact_request` (`packages/rs-sdk/src/platform/dashpay/contact_request.rs:256-273`), +which AES-encrypts them via `encrypt_extended_public_key` +(`packages/rs-platform-encryption/src/lib.rs:97-105`, IV prepended) and then asserts: + +```rust +// rs-sdk contact_request.rs:267-273 +if encrypted_public_key.len() != 96 { + return Err(Error::Generic(format!( + "Encrypted public key size mismatch: expected 96 bytes, got {}", ... +``` + +**Arithmetic:** 107-byte plaintext → PKCS7 → 112 → +16 IV = **128 bytes ≠ 96**. The current +platform-wallet send path therefore errors at runtime on every real send — it doesn't merely +produce an incompatible document, it produces none. (The rs-sdk doc comment at +`contact_request.rs:150` saying "typically 78 bytes" is also wrong per DIP-15: a 78-byte BIP32 +xpub would pass the 96-byte check but would still be undecryptable by reference clients.) + +Our **receive** path is equally non-interoperable: after decrypting, it requires the plaintext +to be a 78- or 107-byte serialization: + +```rust +// packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs:447 +let contact_xpub = key_wallet::bip32::ExtendedPubKey::decode(&decrypted_xpub_bytes) ... +// key-wallet/src/bip32.rs:1893-1899 +pub fn decode(data: &[u8]) -> Result { + match data.len() { + 78 => Self::decode_32(data), + 107 => Self::decode_256(data), + _ => Err(Error::WrongExtendedKeyLength(data.len())), +``` + +A 69-byte compact payload from iOS/Android → `WrongExtendedKeyLength(69)` → treated as a +PERMANENT failure → `mark_contact_channel_broken` +(`network/contact_requests.rs:667-687`). **We can never pay a mobile contact.** + +### What iOS DashSync encrypts + +`DashSync/shared/Models/Identity/DSPotentialOneWayFriendship.m:114-133` +(https://github.com/dashpay/dashsync-iOS/blob/master/DashSync/shared/Models/Identity/DSPotentialOneWayFriendship.m): + +```objc +- (void)encryptExtendedPublicKeyWithCompletion:(void (^)(BOOL success))completion { + ... + [self.sourceBlockchainIdentity encryptData:[DSKeyManager extendedPublicKeyData:self.extendedPublicKey] + withKeyAtIndex:self.sourceKeyIndex + forRecipientKey:recipientKey ... +``` + +`extendedPublicKeyData` resolves through dash-shared-core +(`dash-spv-masternode-processor/src/keys/ecdsa_key.rs:333-341`, +https://github.com/dashpay/dash-shared-core/blob/master/dash-spv-masternode-processor/src/keys/ecdsa_key.rs): + +```rust +fn extended_public_key_data(&self) -> Option> { + self.is_extended.then_some({ + let mut writer = Vec::::new(); + self.fingerprint.enc(&mut writer); // 4 bytes + self.chaincode.enc(&mut writer); // 32 bytes + writer.extend(self.public_key_data()); // 33 bytes (compressed) + writer + }) +} +``` + +→ **69 bytes**, exactly the DIP-15 compact layout. (The fingerprint `u32` is read from and +written back as the same raw `HASH160[0..4]` bytes, so the wire bytes match dashj's +big-endian `putInt` — both emit the raw fingerprint bytes.) + +### What Android encrypts + +Send path, `android-dashpay dashpay/src/main/kotlin/org/dashj/platform/dashpay/ContactRequests.kt:22-52` +(https://github.com/dashpay/android-dashpay/blob/master/dashpay/src/main/kotlin/org/dashj/platform/dashpay/ContactRequests.kt): + +```kotlin +val contactKeyChain = fromUser.getReceiveFromContactChain(toUser, aesKey) +val contactKey = contactKeyChain.watchingKey +val contactPub = contactKey.serializeContactPub() +... +val (encryptedContactPubKey, encryptedAccountLabel) = fromUser.encryptExtendedPublicKey(contactPub, toUser, toUserPublicKey.id, aesKey) +``` + +dashj `core/src/main/java/org/bitcoinj/crypto/DeterministicKey.java:584-607` +(https://github.com/dashpay/dashj/blob/master/core/src/main/java/org/bitcoinj/crypto/DeterministicKey.java): + +```java +/** serializes a HD Key according to the dashpay encryptedPublicKey specification **/ +public byte[] serializeContactPub() { + ByteBuffer ser = ByteBuffer.allocate(69); + ser.putInt(getParentFingerprint()); + ser.put(getChainCode()); + ser.put(getPubKey()); + checkState(ser.position() == 69); + return ser.array(); +} +public static DeterministicKey deserializeContactPub(NetworkParameters params, byte [] contactPub) { + checkArgument(contactPub.length == 69); + ... +} +``` + +→ **69 bytes**, and the Android receive path hard-rejects anything else +(`BlockchainIdentity.kt:1816` → `deserializeContactPub` `checkArgument(len == 69)`), so even +a 78-byte BIP32 plaintext from us would fail on Android. + +### Required change (precise) + +Send side — `send_contact_request_with_external_signer` +(`packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:150`): replace +`account_xpub.encode()` with the 69-byte compact assembly. The components already exist on +`ContactXpubData` (`crypto/dip14.rs:49-58`: `parent_fingerprint: [u8;4]`, +`chain_code: [u8;32]`, `public_key: [u8;33]`); nothing currently assembles them. Layout: +`parent_fingerprint ‖ chain_code ‖ public_key` (raw bytes, no version/depth/child-number). +Same change applies to any other producer feeding `get_extended_public_key` (rs-sdk-ffi +`src/dashpay/contact_request.rs` accepts caller bytes — Swift-side guidance must say +"69-byte compact"); the rs-sdk doc comments at `contact_request.rs:148-150,365-367` +("typically 78 bytes") need correcting. + +Receive side — `register_external_contact_account` +(`packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs:446-453`): replace +`ExtendedPubKey::decode(&decrypted)` with a 69-byte compact parser: split into +fingerprint/chaincode/pubkey and reconstruct an `ExtendedPubKey` with synthesized +depth/child-number (dashj does exactly this in `deserializeContactPub`, using depth 7 and +child 0 — only chaincode+pubkey matter for non-hardened child derivation). Reject ≠ 69 bytes. + +Account-reference helper — `calculate_account_reference` +(`crypto/dip14.rs:147-172`) HMACs `contact_xpub.encode()` (107 bytes); per DIP-15 §"The data +format of the extended public key is an abbreviated version", the HMAC input must be the same +69-byte compact. (See item 3 for the ASK28 byte-order issue.) + +### Blast radius + +Effectively **zero for on-chain compatibility**: the current send path always fails the +96-byte assertion (128 ≠ 96) before broadcast, and the same `account_xpub.encode()` source +goes back through the file's history (verified at `c556a86db2~1`), so no contact-request +document with our 107-byte plaintext can exist on devnet/testnet. The contract's +`minItems/maxItems: 96` also makes any nonconforming document impossible to have landed. +Local consequence only: tests that feed synthetic 78-byte xpubs (e.g. +`rs-platform-encryption/src/lib.rs:236`, rs-sdk `contact_request.rs:482`) pin the wrong +plaintext size and will need updating to 69 — that is the "tests harden the wrong format" +risk this desk-check was meant to catch, confirmed. + +--- + +## (2) ECDH derivation — PASS + +DIP-15: "This shared key is derived using the libsecp256k1_ecdh method … calculate +`SHA256((y[31]&0x1|0x2) || x)`". + +- **Ours** — `packages/rs-platform-encryption/src/lib.rs:24-34`: + `dashcore::secp256k1::ecdh::SharedSecret::new(public_key, private_key)` — rust-secp256k1's + `SharedSecret` is exactly libsecp256k1's default ECDH hash (SHA256 of compressed-point + prefix ‖ x). +- **iOS** — dash-shared-core `ecdsa_key.rs:610-616`: + ```rust + impl DHKey for ECDSAKey { + fn init_with_dh_key_exchange_with_public_key(public_key: &mut Self, private_key: &Self) -> Option { + ... Some(Self::with_shared_secret(secp256k1::ecdh::SharedSecret::new(&pubkey, &seckey), false)), + ``` + (same crate, same function; also used directly in `encrypt_with_secret_key_using_iv`, + `ecdsa_key.rs:620-632`). +- **Android** — dashj `Secp256k1ECDHAgreement.java:99-105` + (https://github.com/dashpay/dashj/blob/master/core/src/main/java/org/bitcoinj/crypto/Secp256k1ECDHAgreement.java): + ```java + // SHA256((y[31]&0x2|0x1) + x) ... (comment's mask is a typo; code below is &0x01|0x02) + x32withVersion[0] = (byte)((y32[y32.length - 1] & 0x01) | 0x02); + System.arraycopy(x32, 0, x32withVersion, 1, 32); + return new BigInteger(Sha256Hash.hash(x32withVersion)); + ``` + +Symmetric cipher also matches everywhere: AES-256-CBC, PKCS7, random 16-byte IV, IV prepended +to the ciphertext (ours `rs-platform-encryption/src/lib.rs:45-105`; iOS +`crypto_data.rs:23-25` + IV-prepend in `ecdsa_key.rs:621,627-630`; Android +`KeyCrypterAESCBC.java:73-91` `PaddedBufferedBlockCipher(new CBCBlockCipher(new AESEngine()))` ++ IV-prepend in `BlockchainIdentity.kt:1750-1754`). + +--- + +## (3) accountReference — PASS for interop; our helper is wrong for future use + +DIP-15: "accountReference (integer) — … This is encrypted [masked] for the sender. **The +recipient should disregard this field.**" + +**What reference clients SEND:** a *computed* masked value, not 0. + +- iOS `DSPotentialOneWayFriendship.m:136-139`: + `return key_create_account_reference(key, self.extendedPublicKey, self.account.accountNumber);` + → dash-shared-core `bindings/keys.rs:1109-1130`: `HMAC-SHA256(sourceKey, 69-byte compact)`, + `version = 0`, `version_bits | (ask28 ^ shortened_account_bits)`. +- Android `ContactRequests.kt:45` → `BlockchainIdentity.kt:1898-1916` (`getAccountReference`): + `HDUtils.hmacSha256(privateKey.privKeyBytes, extendedPublicKey.serializeContactPub())`, + version 0. + +**What reference clients do on RECEIVE:** disregard/secondary. Android +`addContactPaymentKeyChain` (`BlockchainIdentity.kt:1819-1864`) reads it but the actual gate +is comparing the *decrypted xpub* against the locally derived account-0 xpub ("contactRequest +does not match account 0"); iOS likewise derives the friendship path from its own account and +uses the field only for the sender's own bookkeeping. Neither validates/un-masks a +counterparty's value. + +**Ours:** the send path hardcodes 0 — `account_reference: account_index` where +`let account_index: u32 = 0;` (`network/contact_requests.rs:124,195`); the receive path +stores the field without interpreting it (`parse_contact_request_doc`, +`network/contact_requests.rs:437-439`). → **Interops fine with mobile clients today** (G3 +holds), with two caveats: + +1. Same-seed cross-wallet recovery: if a user imports our seed into DashWallet iOS/Android, + the mobile app will un-mask our literal `0` to a garbage account index. Both mobile + implementations fall back to xpub-vs-account-0 comparison / own derivation, so account 0 + still works, but account rotation (>0) from our side will not round-trip. +2. The DIP-15 unique index is `($ownerId, toUserId, accountReference)` — with a constant 0 we + can never broadcast a superseding (rotated) request for the same pair. + +**When we do implement real values**, `calculate_account_reference` +(`crypto/dip14.rs:147-172`) needs two fixes: +- HMAC input must be the **69-byte compact**, not `contact_xpub.encode()` (107 bytes). +- ASK28 byte interpretation: ours reads HMAC bytes `[0..4]` big-endian. iOS reads bytes + `[28..32]` big-endian (`account_secret_key.reversed()` then `u32_le() >> 4`); Android reads + bytes `[0..4]` little-endian (`Sha256Hash.wrapReversed(...).toBigInteger().toInt() ushr 4`). + Note the two reference clients **disagree with each other** here, which is survivable only + because nobody validates the field cross-client; for the same-seed-recovery scenario the + pragmatic choice is to match whichever client our users co-install (decide at + implementation time; flag upstream — this is a reference-client divergence worth a DIP + clarification). + +--- + +## (Bonus) senderKeyIndex / recipientKeyIndex conventions — interop hazard + +- **iOS** (`DSBlockchainIdentity.m:3064-3065`): both indices = + `firstIndexOfKeyOfType:self.currentMainKeyType` (ECDSA) → in practice the identity's key 0 + (a MASTER/AUTHENTICATION key). +- **Android** (`ContactRequests.kt:29-36,50`): `senderKeyIndex = + getIdentityPublicKeyByPurpose(AUTHENTICATION).id`; `recipientKeyIndex` = first enabled + `ECDSA_SECP256K1` key with `securityLevel <= MEDIUM` (any purpose) → in practice key 0. +- **Ours**: send requires sender key `Purpose::ENCRYPTION` and recipient key + `Purpose::DECRYPTION` and errors otherwise (rs-sdk `contact_request.rs:211-234`; + platform-wallet resolves indices the same way, `network/contact_requests.rs:98-119`); + receive runs `validate_contact_request` (`crypto/validation.rs:76-150`) which demands + ENCRYPTION/DECRYPTION purposes and **permanently marks the payment channel broken** on + mismatch (`network/contact_requests.rs:649-665`). + +Consequences: (a) we cannot SEND to a mobile-registered identity that lacks an +ENCRYPTION/DECRYPTION-purpose key (mobile identities typically register only +AUTHENTICATION-purpose keys); (b) requests FROM mobile clients reference AUTHENTICATION-purpose +key 0 and fail our receive-side validation → channel broken. The drive-side data trigger +(`rs-drive-abci/.../triggers/dashpay/v0/mod.rs`) validates only `ownerId != toUserId` and +`toUserId` existence, so this is purely a client-convention mismatch. Needs a re-scope +decision: relax our purpose check to accept ECDSA AUTHENTICATION keys (matching deployed +reference behavior), rather than waiting for the mobile ecosystem to adopt +ENCRYPTION/DECRYPTION-purpose keys. + +--- + +## Re-scope recommendation + +Items to fix before Milestone-2 tests harden formats: + +1. **Plaintext format (blocker):** switch send to the 69-byte compact; switch receive to a + compact parser. Touches `network/contact_requests.rs` (send), `network/contacts.rs` + (receive), `crypto/dip14.rs` (helper + accountReference HMAC input), rs-sdk doc comments, + and all xpub-size-pinning tests (78→69). No on-chain blast radius (current path cannot + broadcast). +2. **Key purpose convention (blocker for cross-client):** accept/emit first-ECDSA-key + (AUTHENTICATION) indices like the reference clients, or gate behind a compatibility mode. +3. **accountReference:** keep sending 0 for now (valid per DIP receiver semantics), but fix + `calculate_account_reference`'s HMAC input when implementing rotation, and record the + iOS/Android ASK28 divergence upstream. + +--- + +## G15 testnet verification (2026-06-10) + +Empirical check of the key-purpose convention against real testnet data (M1 task 8, +verification half). Data source: pshenmic platform-explorer REST API. The frontend at +`testnet.platform-explorer.com` proxies nothing — the API base URL is baked into the JS +bundle: **`https://testnet.platform-explorer.pshenmic.dev`** (routes in +`pshenmic/platform-explorer` `packages/api/src/routes.js`). Endpoints used: + +```text +GET /dataContract/Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7/documents?document_type_name=contactRequest&limit=100&order=desc&page=N +GET /identity/ # includes full publicKeys[] with purpose/securityLevel/contractBounds +``` + +### Census: all 368 on-chain contactRequest documents (testnet, dash-testnet-51) + +| (senderKeyIndex, recipientKeyIndex) | label? | docs | distinct owners | era | +|---|---|---|---|---| +| (2, 2) | yes | 223 | 126 | 2024–2026, dominant; still active 2026-06 | +| (4, 5) | no | 52 | 28 | 2026 only | +| (1, 0) | yes | 30 | 15 | 2024 only | +| (4, 5) | yes | 16 | 15 | 2026 only | +| (0, 0) | no | 16 | 1 | 2026 (single test identity `DcoJJ3W9…`) | +| (1, 1) | yes | 11 | 6 | 2024 | +| (2, 1) | mixed | 12 | 7 | mixed (incl. `DcoJJ3W9…` test traffic 2026-06) | +| other (2,0)/(5,5)/(4,4)/(1,2) | mixed | 8 | — | scattered | + +("label?" = presence of optional `encryptedAccountLabel`, an Android-mobile tell.) +All `encryptedPublicKey` payloads observed are exactly **96 bytes**. `accountReference` +is a real non-zero value in almost all docs (one `(4,4)` doc has 0). + +### Key purposes actually referenced (per-cohort identity lookups) + +**Cohort (2,2)+label — dominant mobile population.** Sample senders +`85KDhzeJYhqirovivDN54V2n4qXPbZCxDYpvPuFbatJP`, `E5mQ8e9UDUgtFe6ScnUiX9UnsQK4waKyTSBsb5qxiBTS`; +sample recipient `EPsCcSgYKkcfrsVGJjHKzTnkscHV85SZ4dhaj1C3zek8`. Identity key layout: +`0=AUTHENTICATION/MASTER, 1=AUTHENTICATION/HIGH, 2=ENCRYPTION/MEDIUM (unbound), [3=TRANSFER]`. +→ **Both indices point at an ENCRYPTION-purpose, MEDIUM, NOT-contract-bound key (id 2).** +These identities have **no DECRYPTION key at all**. + +**Cohort (4,5) — newest, 2026-only.** Sample sender +`FBSdgBCNu99mwXf6pxV2vGdsqZaABsLemf1DABMKYZk7`, recipient +`BBUJEMAiPLzu2P62PeY9oGvfonxTvbqUPZhM4WNoUsup`. Key layout: +`0–2=AUTHENTICATION, 3=TRANSFER, 4=ENCRYPTION/MEDIUM, 5=DECRYPTION/MEDIUM`, where keys 4 +and 5 carry `contractBounds = {identifier: Bwr4WHCP…NS1C7, documentTypeName: contactRequest}`. +→ **sender=contract-bound ENCRYPTION, recipient=contract-bound DECRYPTION — exactly our +convention and exactly the contract's `requiresIdentityEncryptionBoundedKey: 2` / +`requiresIdentityDecryptionBoundedKey: 2` shape.** (A second (4,5)-shaped pair, +`4DtUte2t…`/`DyNXS4te…`, has the same purposes but unbound.) + +**Cohort (1,0)/(1,1) — 2024 legacy.** Sample identities +`wHq4kk4wFk33A8ugtpYnuFoads5qxica3hT9egRQCdA`, `3paQGWPRFGg1iuH4o3Tjj6dek7Ebp7SxYGfaELxfAjzf`: +**only two keys, both AUTHENTICATION (0=MASTER, 1=HIGH)**. These docs reference +AUTHENTICATION keys because the identities had nothing else. No new docs in this shape +since 2024. + +**Cohort (0,0)/(2,1) 2026 — test noise.** Owner `DcoJJ3W9…` (1,509 txs, 345 contracts, +dozens of `test-*.dash` aliases — a dev harness identity) created 2026 docs whose indices +point at AUTHENTICATION/MASTER (id 0) and AUTHENTICATION/CRITICAL (id 2) keys — *while the +same identity owns contract-bound ENCRYPTION(18)/DECRYPTION(19) keys it didn't reference*. +Its recipient `EesiqQz3…` has only AUTHENTICATION keys. + +### Verdict + +**(a) What purposes do real contactRequests reference?** Three populations, none of them +"key 0 by convention": the dominant mobile cohort (223/368 docs, 126 owners, still active +June 2026) references an **unbound ENCRYPTION/MEDIUM key (id 2) for BOTH indices** — +i.e., `recipientKeyIndex` points at the recipient's ENCRYPTION (not DECRYPTION) key. The +newest 2026 cohort (68 docs, ~40 owners) uses **contract-bound ENCRYPTION (sender) / +DECRYPTION (recipient)** — identical to our convention. AUTHENTICATION-key references +exist only in the dead 2024 cohort (whose identities had no other keys) and in one test +identity's 2026 noise. + +**(b) Do sender identities have ENCRYPTION/DECRYPTION keys?** Modern mobile identities: +yes for ENCRYPTION (unbound id 2), **no DECRYPTION key at all**. Newest-cohort identities: +both, contract-bound, matching the contract requirement. 2024 identities: neither. + +**(c) Does consensus enforce the bounded-key requirement on these fields?** **No.** +`senderKeyIndex`/`recipientKeyIndex` are plain integers; on-chain documents reference +AUTHENTICATION/MASTER keys (2026\!) and unbound ENCRYPTION keys without rejection. The +`requiresIdentity*BoundedKey: 2` contract flags govern bound-key *registration* shape, not +document validation, and the drive data trigger checks only `ownerId \!= toUserId` + +recipient existence. So the desk-check's "key 0 AUTHENTICATION" reading of the mobile +sources is **stale for current testnet**: deployed mobile clients since ~late 2024 *do* +register and reference an ENCRYPTION-purpose key. The real residual mismatch is narrower +than feared: mobile's `recipientKeyIndex` carries ENCRYPTION (not DECRYPTION) purpose and +is unbound, and mobile recipients have no DECRYPTION key for us to select when sending. + +### Alignment recommendation (supersedes re-scope item 2 above) + +- **Send:** keep current preference — sender ENCRYPTION key, recipient DECRYPTION key + (live convention of the newest cohort). Add ONE fallback: if the recipient has no + DECRYPTION-purpose key, select the recipient's **ENCRYPTION**-purpose ECDSA key (covers + the entire 126-owner mobile population). Accept both bound and unbound keys on both + sides. Do **not** fall back to AUTHENTICATION keys — no live client population needs it, + and reusing signing keys for ECDH is poor key separation. +- **Receive/validate:** accept `senderKeyIndex` of purpose ENCRYPTION (bound or unbound); + accept `recipientKeyIndex` (our key) of purpose ENCRYPTION **or** DECRYPTION. Keep the + ECDSA_SECP256K1 key-*type* gate (every observed key is that type). On purpose mismatch + (e.g. legacy 2024 AUTHENTICATION docs), degrade to a warning/skip — do **not** + permanently mark the payment channel broken, since on-chain history demonstrably + contains nonconforming-but-honest documents. diff --git a/docs/dashpay/research/07-contactinfo-conventions.md b/docs/dashpay/research/07-contactinfo-conventions.md new file mode 100644 index 00000000000..f448893c07e --- /dev/null +++ b/docs/dashpay/research/07-contactinfo-conventions.md @@ -0,0 +1,94 @@ +# contactInfo wire conventions (research, 2026-06-12) + +Source-verified findings for implementing the DashPay `contactInfo` +document (M3 task 13). Full citations at the bottom. + +## Decisive finding: no reference client implements contactInfo + +DashSync-iOS (`DSBlockchainIdentity.m` + Identity models), dashj / +android-dpp / kotlin-platform, and dash-shared-core contain **zero** +contactInfo creation or encryption code. DIP-15 + the deployed +dashpay-contract schema are the only authoritative sources, and **this +repo's implementation sets the de-facto wire convention.** There is no +cross-client byte-compatibility constraint — only self-consistency and +schema validity. + +## Conventions adopted (CONFIRMED unless marked INFERRED) + +### Key derivation (DIP-15) + +The "root encryption key" is the identity's **registered ENCRYPTION +key** (DIP-11 purpose 1); `rootEncryptionKeyIndex` is that key's id on +the identity. Two child keys are derived from its extended form in the +owner's HD tree (hardened CKDpriv): + +```text +encToUserId key: rootEncryptionKey / 65536' / derivationEncryptionKeyIndex' (2^16) +privateData key: rootEncryptionKey / 65537' / derivationEncryptionKeyIndex' (2^16 + 1) +``` + +The 2^16 offset is DIP-15's explicit "discount other potential +derivations" choice. The AES-256 key is the raw 32-byte child private +key scalar (INFERRED — no hash step is specified anywhere; matches how +contactRequest ECDH consumes key material). + +`derivationEncryptionKeyIndex` is sequential per `$ownerId` starting at +0 (one per contactInfo document; the unique index is +`($ownerId, rootEncryptionKeyIndex, derivationEncryptionKeyIndex)`). + +### encToUserId (DIP-15, verbatim justification in the DIP) + +`AES-256-ECB(toUserId)` — exactly 32 bytes = two blocks, **no IV, no +padding**. ECB is sound here because the plaintext is itself a SHA-256 +output and the key is never reused for other purposes. + +### privateData + +> **CORRECTION (2026-06-18): use DIP-15 varint, not CBOR.** The conclusion +> below ("the deployed schema description wins → CBOR") over-weighted an +> advisory note. The contract validates `privateData` by **length only** +> (`byteArray`, 48–2048); its "array in cbor" text is documentation, NOT an +> enforced structural constraint. The encrypted plaintext format is a free +> writer/reader convention, so we follow **DIP-15** (the authoritative protocol +> spec) with `version`/varstr/`acceptedAccounts`. See `CONTACTINFO_FORMAT_SPEC.md`. + +`IV(16) ‖ AES-256-CBC(plaintext)` — IV prepended (INFERRED from the +`encryptedPublicKey` convention; DIP-15 doesn't state placement for +this field). + +Plaintext (~~CBOR~~ → **DIP-15 varint**, per the correction above): the original +analysis adopted a **CBOR array `[aliasName, note, displayHidden]`** per the +deployed schema's field description — positional, with CBOR `null` for absent +strings (INFERRED). DIP-15 prose instead describes Bitcoin-varint "Dash message +data" with `version` + `acceptedAccounts` — and that is what we now use (the +schema enforces length only, so there's no conflict and no contract change). + +### Privacy rule (DIP-15, spec-only — no client enforces it today) + +> "A client should not transmit a contact info document for a user to +> the network until that user has at least two established contacts." + +Enforced at the publish gate: with <2 established contacts the local +state still updates; the document write is deferred until the rule is +satisfied. + +## Discrepancy table (DIP-15 prose vs deployed schema) + +| Question | DIP-15 prose | Deployed schema | +|---|---|---| +| Plaintext format | Bitcoin varint stream | CBOR array | +| Fields | version, aliasName, note, displayHidden, acceptedAccounts | aliasName, note, displayHidden | +| version | uInt32 present | absent | +| acceptedAccounts | array of uInt32 | absent | + +## Sources + +- DIP-0015 (dashpay/dips) — derivation offsets, ECB/CBC modes, privacy rule +- dashpay-contract `schema/v1/dashpay.schema.json` — CBOR-array description, + unique index, 48–2048B bounds +- DIP-0011 (key purposes), DIP-0013 (identity key paths), DIP-0009 + assignments (15'/16' are incoming-funds / auto-accept — no contactInfo path) +- dashsync-iOS Identity models, android-dpp, kotlin-platform, + dash-shared-core — checked: no contactInfo implementation anywhere +- rs-dpp `lib.rs` `RootEncryptionKeyIndex` / `DerivationEncryptionKeyIndex` + type aliases diff --git a/packages/rs-platform-encryption/Cargo.toml b/packages/rs-platform-encryption/Cargo.toml index 19c823eabd2..7c6757d23a0 100644 --- a/packages/rs-platform-encryption/Cargo.toml +++ b/packages/rs-platform-encryption/Cargo.toml @@ -7,11 +7,18 @@ license = "MIT" description = "Cryptographic utilities for Dash Platform (DIP-15 DashPay encryption)" [dependencies] -# Cryptography -dashcore = { workspace = true } +# Cryptography — direct deps only, so a consumer that needs just this crate +# doesn't pull in/compile all of dashcore. `secp256k1` is pinned to the same +# 0.30 dashcore re-exports, so the public `SecretKey`/`PublicKey` types unify +# with dashcore-typed callers (platform-wallet, rs-sdk-ffi). +secp256k1 = { version = "0.30.0", features = ["std"] } aes = "0.8" cbc = "0.1" +hmac = "0.12" +sha2 = "0.10" thiserror = "1.0" [dev-dependencies] -hex = "0.4" +# Tests generate keypairs via secp256k1's RNG helpers (`generate_keypair`, +# `secp256k1::rand`), gated behind the `rand` feature. +secp256k1 = { version = "0.30.0", features = ["std", "rand"] } diff --git a/packages/rs-platform-encryption/src/account_label.rs b/packages/rs-platform-encryption/src/account_label.rs new file mode 100644 index 00000000000..9f002188db0 --- /dev/null +++ b/packages/rs-platform-encryption/src/account_label.rs @@ -0,0 +1,85 @@ +//! DIP-15 DashPay `encryptedAccountLabel` encryption. + +use crate::aes::{decrypt_aes_256_cbc, encrypt_aes_256_cbc}; +use crate::error::CryptoError; + +/// Encrypt an account label for DashPay (DIP-15) +/// +/// # Arguments +/// * `shared_key` - 32-byte shared secret from ECDH +/// * `iv` - 16-byte initialization vector (must be randomly generated, different from xpub IV) +/// * `label` - Account label string to encrypt +/// +/// # Returns +/// Encrypted label with IV prepended (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) +pub fn encrypt_account_label(shared_key: &[u8; 32], iv: &[u8; 16], label: &str) -> Vec { + let encrypted_data = encrypt_aes_256_cbc(shared_key, iv, label.as_bytes()); + + // Prepend IV to encrypted data as per DIP-15 + let mut result = Vec::with_capacity(16 + encrypted_data.len()); + result.extend_from_slice(iv); + result.extend_from_slice(&encrypted_data); + result +} + +/// Decrypt an account label from DashPay (DIP-15) +/// +/// # Arguments +/// * `shared_key` - 32-byte shared secret from ECDH +/// * `encrypted_data` - Encrypted label with IV prepended (48-80 bytes total) +/// +/// # Returns +/// Decrypted label string +pub fn decrypt_account_label( + shared_key: &[u8; 32], + encrypted_data: &[u8], +) -> Result { + if encrypted_data.len() < 16 { + return Err(CryptoError::InvalidCiphertextLength); + } + + // Extract IV from first 16 bytes + let iv: [u8; 16] = encrypted_data[..16].try_into().unwrap(); + let ciphertext = &encrypted_data[16..]; + + let decrypted = decrypt_aes_256_cbc(shared_key, &iv, ciphertext)?; + String::from_utf8(decrypted).map_err(|_| CryptoError::InvalidUtf8) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ecdh::derive_shared_key_ecdh; + use secp256k1::rand::{thread_rng, RngCore}; + use secp256k1::Secp256k1; + + #[test] + fn test_account_label_encryption() { + let secp = Secp256k1::new(); + let (secret1, _public1) = secp.generate_keypair(&mut thread_rng()); + let (_secret2, public2) = secp.generate_keypair(&mut thread_rng()); + + // Derive shared key + let shared_key = derive_shared_key_ecdh(&secret1, &public2); + + // Generate random IV + let mut iv = [0u8; 16]; + thread_rng().fill_bytes(&mut iv); + + let label = "My DashPay Account"; + + // Encrypt and decrypt + let encrypted = encrypt_account_label(&shared_key, &iv, label); + + // Verify size is in valid range: 48-80 bytes (16-byte IV + 32-64 bytes encrypted) + assert!( + encrypted.len() >= 48 && encrypted.len() <= 80, + "Encrypted label should be 48-80 bytes, got {}", + encrypted.len() + ); + + let decrypted = decrypt_account_label(&shared_key, &encrypted).unwrap(); + + assert_eq!(label, decrypted); + } +} diff --git a/packages/rs-platform-encryption/src/account_reference.rs b/packages/rs-platform-encryption/src/account_reference.rs new file mode 100644 index 00000000000..4e9d04f1da9 --- /dev/null +++ b/packages/rs-platform-encryption/src/account_reference.rs @@ -0,0 +1,161 @@ +//! DIP-15 `accountReference` (masked account index). + +/// `ASK28 = (HMAC-SHA256(sender_secret_key, compact_xpub))[28..32] big-endian >> 4`. +/// +/// HMAC input is the 69-byte DIP-15 compact form (the `encryptedPublicKey` +/// plaintext). The ASK28 byte order matches iOS dash-shared-core +/// (`be(ASK[28..32]) >> 4`); see [`extract_ask28`] for the full four-convention +/// split (Android, dash-evo-tool, and the DIP literal all differ). Since +/// `accountReference` is a one-time-pad obfuscation that recipients ignore (only +/// the original sender un-masks it on re-send), every convention round-trips for +/// its own sender; we match iOS so our sent requests are bit-identical to the +/// incumbent wallet's. +fn account_secret_key_28(sender_secret_key: &[u8; 32], compact_xpub: &[u8]) -> u32 { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = Hmac::::new_from_slice(sender_secret_key) + .expect("HMAC accepts a key of any length"); + mac.update(compact_xpub); + let mut ask = [0u8; 32]; + ask.copy_from_slice(&mac.finalize().into_bytes()); + extract_ask28(&ask) +} + +/// Extract `ASK28` from the 32-byte HMAC digest using the iOS dash-shared-core +/// convention: `be(ASK[28..32]) >> 4`. DIP-15 leaves the extraction ambiguous +/// ("28 most significant bits of ASK"); four readings exist in the wild and +/// give different values, but since the field is a sender-private one-time pad +/// there is no on-chain interop failure — we lock to iOS (the most-deployed +/// DashPay wallet) for bit-identical sent requests. +fn extract_ask28(ask_bytes: &[u8; 32]) -> u32 { + u32::from_be_bytes([ask_bytes[28], ask_bytes[29], ask_bytes[30], ask_bytes[31]]) >> 4 +} + +/// Calculate the masked DIP-15 `accountReference`: +/// `result = (version << 28) | (ASK28 ^ (account_index & 0x0FFF_FFFF))`. +/// +/// Top 4 bits carry the rotation `version` (bumped on each friendship re-key); +/// the low 28 bits are the account index masked by a PRF of the contact xpub so +/// observers can't correlate accounts across requests. Keyed by the sender's +/// 32-byte ECDH private key (the same key that encrypts the xpub). +pub fn calculate_account_reference( + sender_secret_key: &[u8; 32], + compact_xpub: &[u8], + account_index: u32, + version: u32, +) -> u32 { + let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); + let shortened_account_bits = account_index & 0x0FFF_FFFF; + let version_bits = version << 28; + version_bits | (ask28 ^ shortened_account_bits) +} + +/// Recover `(version, account_index)` from a masked `accountReference`. Inverse +/// of [`calculate_account_reference`] for the same `(sender_secret_key, +/// compact_xpub)` — only the original sender can un-mask (the PRF key is their +/// ECDH private key). Used on re-send to read the previous rotation version. +pub fn unmask_account_reference( + account_reference: u32, + sender_secret_key: &[u8; 32], + compact_xpub: &[u8], +) -> (u32, u32) { + let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); + let version = account_reference >> 28; + let account_index = (account_reference & 0x0FFF_FFFF) ^ ask28; + (version, account_index) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Deterministic 69-byte compact xpub fixture for the account-reference + /// tests (the helper only HMACs the bytes, so a synthetic buffer of the + /// right length keeps the vectors stable). + fn test_compact_xpub() -> [u8; 69] { + std::array::from_fn(|i| i as u8) + } + + #[test] + fn account_reference_version_bits() { + let secret_key = [1u8; 32]; + let compact = test_compact_xpub(); + assert_eq!(calculate_account_reference(&secret_key, &compact, 0, 0) >> 28, 0); + assert_eq!(calculate_account_reference(&secret_key, &compact, 0, 1) >> 28, 1); + assert_eq!(calculate_account_reference(&secret_key, &compact, 0, 15) >> 28, 15); + } + + #[test] + fn account_reference_deterministic() { + let secret_key = [0xABu8; 32]; + let compact = test_compact_xpub(); + assert_eq!( + calculate_account_reference(&secret_key, &compact, 0, 0), + calculate_account_reference(&secret_key, &compact, 0, 0), + "same inputs → same account reference" + ); + } + + /// ASK28 must come from HMAC digest bytes `[28..32]` big-endian `>> 4` (iOS + /// dash-shared-core) — not the head-of-digest reading (the old bug). + #[test] + fn account_reference_ask28_uses_digest_tail_big_endian() { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let secret_key = [0x42u8; 32]; + let compact = test_compact_xpub(); + + let mut mac = Hmac::::new_from_slice(&secret_key).expect("hmac key"); + mac.update(&compact); + let mut digest = [0u8; 32]; + digest.copy_from_slice(&mac.finalize().into_bytes()); + let expected_ask28 = + u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]) >> 4; + + let reference = calculate_account_reference(&secret_key, &compact, 0, 0); + assert_eq!( + reference & 0x0FFF_FFFF, + expected_ask28, + "ASK28 must be digest bytes [28..32] big-endian >> 4 (iOS dash-shared-core)" + ); + let old_ask28 = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]) >> 4; + assert_ne!( + reference & 0x0FFF_FFFF, + old_ask28, + "head-of-digest extraction is the old bug" + ); + } + + /// Mask → unmask round-trips `(version, account_index)` for the sender. + #[test] + fn account_reference_round_trips_version_and_account() { + let secret_key = [0x07u8; 32]; + let compact = test_compact_xpub(); + for version in [0u32, 1, 7, 15] { + for account in [0u32, 1, 5, 0x0FFF_FFFF] { + let reference = + calculate_account_reference(&secret_key, &compact, account, version); + let (got_version, got_account) = + unmask_account_reference(reference, &secret_key, &compact); + assert_eq!(got_version, version, "version round-trip"); + assert_eq!(got_account, account, "account round-trip"); + } + } + let reference = calculate_account_reference(&secret_key, &compact, 5, 0); + let (_, wrong) = unmask_account_reference(reference, &[0x08u8; 32], &compact); + assert_ne!(wrong, 5, "a different PRF key must not unmask the account"); + } + + /// Known-answer pin for the ASK28 extraction conventions (iOS vs the others). + #[test] + fn ask28_extraction_matches_ios_and_diverges_from_others() { + let ask: [u8; 32] = std::array::from_fn(|i| i as u8); + assert_eq!(extract_ask28(&ask), 0x01c1_d1e1, "iOS dash-shared-core: be(ASK[28..32])>>4"); + let android = u32::from_le_bytes([ask[0], ask[1], ask[2], ask[3]]) >> 4; + let dip_literal = u32::from_be_bytes([ask[0], ask[1], ask[2], ask[3]]) >> 4; + assert_eq!(android, 0x0030_2010, "kotlin-platform: le(ASK[0..4])>>4"); + assert_eq!(dip_literal, 0x0000_1020, "dash-evo-tool / DIP literal: be(ASK[0..4])>>4"); + assert_ne!(extract_ask28(&ask), android); + assert_ne!(extract_ask28(&ask), dip_literal); + } +} diff --git a/packages/rs-platform-encryption/src/aes.rs b/packages/rs-platform-encryption/src/aes.rs new file mode 100644 index 00000000000..c1609592fd7 --- /dev/null +++ b/packages/rs-platform-encryption/src/aes.rs @@ -0,0 +1,81 @@ +//! AES-256-CBC primitives (PKCS7) shared by the DIP-15 encrypted fields. + +use aes::cipher::{block_padding::Pkcs7, KeyIvInit}; +use aes::Aes256; + +use crate::error::CryptoError; + +type Aes256CbcEnc = cbc::Encryptor; +type Aes256CbcDec = cbc::Decryptor; + +/// Encrypt data using CBC-AES-256 +/// +/// # Arguments +/// * `key` - 32-byte encryption key +/// * `iv` - 16-byte initialization vector (must be randomly generated and unique) +/// * `data` - Data to encrypt +/// +/// # Returns +/// Encrypted data with PKCS7 padding +pub fn encrypt_aes_256_cbc(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Vec { + use aes::cipher::BlockEncryptMut; + + let cipher = Aes256CbcEnc::new(key.into(), iv.into()); + let mut buffer = Vec::new(); + buffer.extend_from_slice(data); + + // Add padding + let padding_needed = 16 - (data.len() % 16); + buffer.resize(data.len() + padding_needed, padding_needed as u8); + + cipher + .encrypt_padded_mut::(&mut buffer, data.len()) + .expect("encryption failed") + .to_vec() +} + +/// Decrypt data using CBC-AES-256 +/// +/// # Arguments +/// * `key` - 32-byte encryption key +/// * `iv` - 16-byte initialization vector +/// * `ciphertext` - Encrypted data to decrypt +/// +/// # Returns +/// Decrypted data with padding removed +pub fn decrypt_aes_256_cbc( + key: &[u8; 32], + iv: &[u8; 16], + ciphertext: &[u8], +) -> Result, CryptoError> { + use aes::cipher::BlockDecryptMut; + + let cipher = Aes256CbcDec::new(key.into(), iv.into()); + let mut buffer = ciphertext.to_vec(); + + let decrypted = cipher + .decrypt_padded_mut::(&mut buffer) + .map_err(|_| CryptoError::DecryptionFailed)?; + + Ok(decrypted.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + use secp256k1::rand::{thread_rng, RngCore}; + + #[test] + fn test_aes_encryption_decryption() { + let key = [0u8; 32]; + let mut iv = [0u8; 16]; + thread_rng().fill_bytes(&mut iv); + + let plaintext = b"Hello, DashPay!"; + + let ciphertext = encrypt_aes_256_cbc(&key, &iv, plaintext); + let decrypted = decrypt_aes_256_cbc(&key, &iv, &ciphertext).unwrap(); + + assert_eq!(plaintext, decrypted.as_slice()); + } +} diff --git a/packages/rs-platform-encryption/src/compact_xpub.rs b/packages/rs-platform-encryption/src/compact_xpub.rs new file mode 100644 index 00000000000..01816f34006 --- /dev/null +++ b/packages/rs-platform-encryption/src/compact_xpub.rs @@ -0,0 +1,234 @@ +//! DIP-15 compact extended public key (`encryptedPublicKey`) — the 69-byte +//! compact plaintext, its parse/serialize, and its AES-256-CBC encryption. + +use crate::aes::{decrypt_aes_256_cbc, encrypt_aes_256_cbc}; +use crate::error::CryptoError; + +/// Length of the DIP-15 compact extended-public-key plaintext, in bytes. +/// +/// `parentFingerprint(4) ‖ chainCode(32) ‖ publicKey(33)` = 69 bytes. This is +/// the plaintext layout DIP-15 specifies for `encryptedPublicKey` and the form +/// both reference clients (iOS dash-shared-core, Android dashj) emit and +/// hard-check on receive. Encrypting exactly 69 bytes yields a 96-byte +/// ciphertext (16-byte IV + 80-byte AES-256-CBC/PKCS7 block), matching the +/// deployed contract's `minItems/maxItems: 96`. +pub const COMPACT_XPUB_LEN: usize = 69; + +/// Encrypt an extended public key for DashPay contact requests (DIP-15) +/// +/// # Arguments +/// * `shared_key` - 32-byte shared secret from ECDH +/// * `iv` - 16-byte initialization vector (must be randomly generated) +/// * `xpub` - Extended public key bytes to encrypt +/// +/// # Returns +/// Encrypted extended public key with IV prepended (96 bytes: 16-byte IV + 80-byte encrypted data) +pub fn encrypt_extended_public_key(shared_key: &[u8; 32], iv: &[u8; 16], xpub: &[u8]) -> Vec { + let encrypted_data = encrypt_aes_256_cbc(shared_key, iv, xpub); + + // Prepend IV to encrypted data as per DIP-15 + let mut result = Vec::with_capacity(16 + encrypted_data.len()); + result.extend_from_slice(iv); + result.extend_from_slice(&encrypted_data); + result +} + +/// Decrypt an extended public key from DashPay contact requests (DIP-15) +/// +/// # Arguments +/// * `shared_key` - 32-byte shared secret from ECDH +/// * `encrypted_data` - Encrypted extended public key with IV prepended (96 bytes total) +/// +/// # Returns +/// Decrypted extended public key bytes +pub fn decrypt_extended_public_key( + shared_key: &[u8; 32], + encrypted_data: &[u8], +) -> Result, CryptoError> { + if encrypted_data.len() < 16 { + return Err(CryptoError::InvalidCiphertextLength); + } + + // Extract IV from first 16 bytes + let iv: [u8; 16] = encrypted_data[..16].try_into().unwrap(); + let ciphertext = &encrypted_data[16..]; + + decrypt_aes_256_cbc(shared_key, &iv, ciphertext) +} + +/// The three components of a DIP-15 compact extended public key. +/// +/// `parent_fingerprint ‖ chain_code ‖ public_key` is the 69-byte compact +/// form DIP-15 defines for `encryptedPublicKey`. A named struct (rather +/// than a `([u8; 4], [u8; 32], [u8; 33])` tuple) keeps the component +/// meaning explicit at every call site — the three byte arrays are +/// otherwise easy to mis-read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CompactXpub { + /// 4-byte fingerprint of the parent key. + pub parent_fingerprint: [u8; 4], + /// 32-byte chain code of the shared (account) key. + pub chain_code: [u8; 32], + /// 33-byte compressed secp256k1 public key. + pub public_key: [u8; 33], +} + +impl CompactXpub { + /// Serialize to the 69-byte DIP-15 compact plaintext + /// (`parent_fingerprint ‖ chain_code ‖ public_key`). This is the + /// plaintext fed to [`encrypt_extended_public_key`] — *not* a + /// BIP32/DIP-14 serialization, which carries extra + /// version/depth/child-number metadata the wire format omits. + pub fn to_bytes(&self) -> [u8; COMPACT_XPUB_LEN] { + let mut out = [0u8; COMPACT_XPUB_LEN]; + out[0..4].copy_from_slice(&self.parent_fingerprint); + out[4..36].copy_from_slice(&self.chain_code); + out[36..69].copy_from_slice(&self.public_key); + out + } +} + +/// Assemble the DIP-15 compact extended-public-key plaintext from its +/// three components. Thin wrapper over [`CompactXpub::to_bytes`] kept for +/// call sites that have the components loose rather than in a struct. +pub fn compact_xpub_bytes( + parent_fingerprint: [u8; 4], + chain_code: [u8; 32], + public_key: [u8; 33], +) -> [u8; COMPACT_XPUB_LEN] { + CompactXpub { + parent_fingerprint, + chain_code, + public_key, + } + .to_bytes() +} + +/// Parse a DIP-15 compact extended-public-key plaintext into a +/// [`CompactXpub`]. +/// +/// Inverse of [`CompactXpub::to_bytes`] / [`compact_xpub_bytes`]. Rejects +/// any input whose length is not exactly [`COMPACT_XPUB_LEN`] (69) bytes — +/// the reference clients hard-check this on receive, so a non-69-byte +/// payload is not a valid DIP-15 compact xpub and must be handled +/// separately (e.g. a legacy 78/107-byte BIP32/DIP-14 serialization) by +/// the caller. +/// +/// # Errors +/// [`CryptoError::InvalidCompactXpubLength`] if `bytes.len() != 69`. +pub fn parse_compact_xpub(bytes: &[u8]) -> Result { + if bytes.len() != COMPACT_XPUB_LEN { + return Err(CryptoError::InvalidCompactXpubLength(bytes.len())); + } + + let mut parent_fingerprint = [0u8; 4]; + let mut chain_code = [0u8; 32]; + let mut public_key = [0u8; 33]; + parent_fingerprint.copy_from_slice(&bytes[0..4]); + chain_code.copy_from_slice(&bytes[4..36]); + public_key.copy_from_slice(&bytes[36..69]); + + Ok(CompactXpub { + parent_fingerprint, + chain_code, + public_key, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ecdh::derive_shared_key_ecdh; + use secp256k1::rand::{thread_rng, RngCore}; + use secp256k1::Secp256k1; + + #[test] + fn test_extended_public_key_encryption() { + let secp = Secp256k1::new(); + let (secret1, _public1) = secp.generate_keypair(&mut thread_rng()); + let (_secret2, public2) = secp.generate_keypair(&mut thread_rng()); + + // Derive shared key + let shared_key = derive_shared_key_ecdh(&secret1, &public2); + + // Generate random IV + let mut iv = [0u8; 16]; + thread_rng().fill_bytes(&mut iv); + + // DIP-15 compact xpub plaintext (69 bytes). 69 → PKCS7 → 80, + 16-byte + // IV = exactly 96 bytes, matching the contract's minItems/maxItems: 96. + let xpub_data = vec![0x04; COMPACT_XPUB_LEN]; + + // Encrypt and decrypt + let encrypted = encrypt_extended_public_key(&shared_key, &iv, &xpub_data); + + // Verify size: 16 bytes (IV) + 80 bytes (encrypted data) = 96 bytes + assert_eq!(encrypted.len(), 96, "Encrypted xpub should be 96 bytes"); + + let decrypted = decrypt_extended_public_key(&shared_key, &encrypted).unwrap(); + + assert_eq!(xpub_data, decrypted); + } + + #[test] + fn test_compact_xpub_round_trip() { + // Distinct byte patterns per field so a mis-sliced offset is caught. + let parent_fingerprint = [0x11u8, 0x22, 0x33, 0x44]; + let chain_code = [0xAAu8; 32]; + let mut pubkey = [0xBBu8; 33]; + pubkey[0] = 0x02; // compressed-pubkey prefix + + let compact = compact_xpub_bytes(parent_fingerprint, chain_code, pubkey); + assert_eq!(compact.len(), COMPACT_XPUB_LEN); + assert_eq!(compact.len(), 69); + + // Byte-exact layout: fingerprint ‖ chaincode ‖ pubkey. + assert_eq!(&compact[0..4], &parent_fingerprint); + assert_eq!(&compact[4..36], &chain_code); + assert_eq!(&compact[36..69], &pubkey); + + let parsed = parse_compact_xpub(&compact).expect("parse 69-byte compact"); + assert_eq!(parsed.parent_fingerprint, parent_fingerprint); + assert_eq!(parsed.chain_code, chain_code); + assert_eq!(parsed.public_key, pubkey); + // Struct round-trips back to the same bytes. + assert_eq!(parsed.to_bytes(), compact); + } + + #[test] + fn test_encrypt_compact_xpub_is_exactly_96_bytes() { + // The whole point of the 69-byte compact form: it encrypts to exactly + // 96 bytes (16-byte IV + 80-byte AES-256-CBC/PKCS7), which is what the + // deployed contract enforces. A 107-byte DIP-14 serialization would + // yield 128 bytes and fail the contract's maxItems: 96. + let shared_key = [0x07u8; 32]; + let iv = [0x09u8; 16]; + let plaintext = [0xCDu8; COMPACT_XPUB_LEN]; + + let encrypted = encrypt_extended_public_key(&shared_key, &iv, &plaintext); + assert_eq!( + encrypted.len(), + 96, + "69-byte compact plaintext must encrypt to exactly 96 bytes" + ); + + let decrypted = decrypt_extended_public_key(&shared_key, &encrypted).unwrap(); + assert_eq!(&decrypted[..], &plaintext[..]); + } + + #[test] + fn test_parse_compact_xpub_rejects_wrong_length() { + // Lengths that are NOT 69 must be rejected — including the legacy 78/107 + // BIP32/DIP-14 serializations and the empty case. + for bad_len in [0usize, 36, 68, 70, 78, 107, 128] { + let bytes = vec![0u8; bad_len]; + let err = parse_compact_xpub(&bytes).expect_err("non-69-byte input must be rejected"); + assert!( + matches!(err, CryptoError::InvalidCompactXpubLength(n) if n == bad_len), + "expected InvalidCompactXpubLength({}), got {:?}", + bad_len, + err + ); + } + } +} diff --git a/packages/rs-platform-encryption/src/contact_info.rs b/packages/rs-platform-encryption/src/contact_info.rs new file mode 100644 index 00000000000..48564c93143 --- /dev/null +++ b/packages/rs-platform-encryption/src/contact_info.rs @@ -0,0 +1,119 @@ +//! DIP-15 `contactInfo` field encryption: `encToUserId` (AES-256-ECB) and +//! `privateData` (`IV ‖ AES-256-CBC`). + +use aes::Aes256; + +use crate::aes::encrypt_aes_256_cbc; +use crate::error::CryptoError; + +/// Encrypt a 32-byte identity id with AES-256-ECB (DIP-15 +/// `contactInfo.encToUserId`). +/// +/// Exactly two raw AES blocks — **no IV, no padding**. ECB is sound +/// for this one field per DIP-15's own analysis: the plaintext is +/// itself a SHA-256 output (pseudorandom, no repeated-block structure) +/// and the key — a dedicated hardened child at +/// `rootEncryptionKey/2^16'/index'` — is never reused for any other +/// purpose. Do NOT use this for anything but `encToUserId`. +pub fn encrypt_enc_to_user_id(key: &[u8; 32], to_user_id: &[u8; 32]) -> [u8; 32] { + use aes::cipher::{BlockEncrypt, KeyInit}; + + let cipher = Aes256::new(key.into()); + let mut out = *to_user_id; + let (block1, block2) = out.split_at_mut(16); + cipher.encrypt_block(block1.into()); + cipher.encrypt_block(block2.into()); + out +} + +/// Decrypt a 32-byte `contactInfo.encToUserId` ciphertext +/// (inverse of [`encrypt_enc_to_user_id`]). +pub fn decrypt_enc_to_user_id(key: &[u8; 32], ciphertext: &[u8; 32]) -> [u8; 32] { + use aes::cipher::{BlockDecrypt, KeyInit}; + + let cipher = Aes256::new(key.into()); + let mut out = *ciphertext; + let (block1, block2) = out.split_at_mut(16); + cipher.decrypt_block(block1.into()); + cipher.decrypt_block(block2.into()); + out +} + +/// Encrypt a `contactInfo.privateData` plaintext (CBOR bytes) as +/// `IV(16) ‖ AES-256-CBC(plaintext)` — the same prepended-IV layout +/// `encryptedPublicKey` uses (DIP-15 doesn't pin the layout for this +/// field; we adopt the same convention). +pub fn encrypt_private_data(key: &[u8; 32], iv: &[u8; 16], plaintext: &[u8]) -> Vec { + let mut out = Vec::with_capacity(16 + plaintext.len() + 16); + out.extend_from_slice(iv); + out.extend_from_slice(&encrypt_aes_256_cbc(key, iv, plaintext)); + out +} + +/// Decrypt a `contactInfo.privateData` blob (inverse of +/// [`encrypt_private_data`]). +pub fn decrypt_private_data(key: &[u8; 32], blob: &[u8]) -> Result, CryptoError> { + use crate::aes::decrypt_aes_256_cbc; + + if blob.len() < 16 { + return Err(CryptoError::InvalidCiphertextLength); + } + let iv: [u8; 16] = blob[..16].try_into().expect("length checked above"); + decrypt_aes_256_cbc(key, &iv, &blob[16..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enc_to_user_id_round_trips_and_is_two_independent_blocks() { + let key = [0x11u8; 32]; + let mut id = [0u8; 32]; + for (i, b) in id.iter_mut().enumerate() { + *b = i as u8; + } + + let ct = encrypt_enc_to_user_id(&key, &id); + assert_ne!(ct, id, "ciphertext must differ from plaintext"); + assert_eq!(decrypt_enc_to_user_id(&key, &ct), id, "round trip"); + + // ECB property we rely on: equal plaintext blocks → equal + // ciphertext blocks (sound here only because identity ids are + // hash outputs). This pins that the implementation really is + // ECB and not CBC-with-zero-IV. + let same_blocks = [0xAAu8; 32]; + let ct2 = encrypt_enc_to_user_id(&key, &same_blocks); + assert_eq!( + ct2[..16], + ct2[16..], + "ECB: identical blocks encrypt identically" + ); + + // Wrong key must not round-trip. + assert_ne!(decrypt_enc_to_user_id(&[0x22u8; 32], &ct), id); + } + + #[test] + fn private_data_round_trips_with_prepended_iv() { + let key = [0x33u8; 32]; + let iv = [0x44u8; 16]; + // Minimal CBOR-ish payload; the schema floor is 48 bytes of + // ciphertext which IV(16) + one padded block satisfies — the + // length policy lives at the document-build layer, not here. + let plaintext = b"[\"alias\",\"note\",false] stand-in cbor"; + + let blob = encrypt_private_data(&key, &iv, plaintext); + assert_eq!(&blob[..16], &iv, "IV must be prepended verbatim"); + assert!(blob.len() >= 48, "IV + padded CBC reaches the schema floor"); + + let plain = decrypt_private_data(&key, &blob).expect("decrypt"); + assert_eq!(plain, plaintext); + + // Truncated blob → typed error, not a panic. + assert!(matches!( + decrypt_private_data(&key, &blob[..10]), + Err(CryptoError::InvalidCiphertextLength) + )); + } +} diff --git a/packages/rs-platform-encryption/src/ecdh.rs b/packages/rs-platform-encryption/src/ecdh.rs new file mode 100644 index 00000000000..7e165365eec --- /dev/null +++ b/packages/rs-platform-encryption/src/ecdh.rs @@ -0,0 +1,86 @@ +//! DIP-15 ECDH shared-secret derivation. + +use secp256k1::{PublicKey, SecretKey}; + +/// Derive a shared secret key using ECDH as specified in DIP-15 +/// +/// This uses libsecp256k1_ecdh which computes: SHA256((y[31]&0x1|0x2) || x) +/// where (x, y) is the EC point result of scalar multiplication +/// +/// # Arguments +/// * `private_key` - The private key for this side of the exchange +/// * `public_key` - The public key from the other party +/// +/// # Returns +/// A 32-byte shared secret key +pub fn derive_shared_key_ecdh(private_key: &SecretKey, public_key: &PublicKey) -> [u8; 32] { + use secp256k1::ecdh::SharedSecret; + + // Use secp256k1's built-in ECDH which matches libsecp256k1_ecdh + // This computes SHA256((y[31]&0x1|0x2) || x) internally + let shared_secret = SharedSecret::new(public_key, private_key); + + let mut key = [0u8; 32]; + key.copy_from_slice(shared_secret.as_ref()); + key +} + +#[cfg(test)] +mod tests { + use super::*; + use secp256k1::rand::thread_rng; + use secp256k1::Secp256k1; + + #[test] + fn test_ecdh_key_derivation() { + let secp = Secp256k1::new(); + + // Generate two key pairs + let (secret1, public1) = secp.generate_keypair(&mut thread_rng()); + let (secret2, public2) = secp.generate_keypair(&mut thread_rng()); + + // Derive shared keys from both sides + let shared1 = derive_shared_key_ecdh(&secret1, &public2); + let shared2 = derive_shared_key_ecdh(&secret2, &public1); + + // Both sides should derive the same shared key + assert_eq!(shared1, shared2); + } + + /// Known-answer test for the ECDH shared-key convention. We had been + /// trusting `SharedSecret::new` to compute `SHA256((y[31]&1|2) || x)` from + /// a library comment, not bytes — a cross-impl mismatch with dashj would + /// break contactRequest/contactInfo interop silently. This recomputes the + /// shared key by hand for fixed keys and pins (a) symmetry `a·B == b·A` and + /// (b) the exact compressed-y-prefix-‖-x preimage convention. + #[test] + fn ecdh_matches_sha256_y_parity_prefix_convention() { + use secp256k1::{Scalar, Secp256k1}; + use sha2::{Digest, Sha256}; + + let secp = Secp256k1::new(); + let priv_a = SecretKey::from_slice(&[0xC0u8; 32]).expect("valid scalar"); + let priv_b = SecretKey::from_slice(&[0x0Du8; 32]).expect("valid scalar"); + let pub_a = PublicKey::from_secret_key(&secp, &priv_a); + let pub_b = PublicKey::from_secret_key(&secp, &priv_b); + + let ab = derive_shared_key_ecdh(&priv_a, &pub_b); + let ba = derive_shared_key_ecdh(&priv_b, &pub_a); + assert_eq!(ab, ba, "ECDH must be symmetric (a·B == b·A)"); + assert_eq!(ab, derive_shared_key_ecdh(&priv_a, &pub_b), "deterministic"); + + // Recompute by hand: shared point P = a·B; shared key = + // SHA256( (0x02 | (P.y & 1)) ‖ P.x ). Pins that it's the compressed-y + // prefix + x, NOT x‖y or some other layout. + let scalar_a = Scalar::from_be_bytes([0xC0u8; 32]).expect("scalar in range"); + let shared_point = pub_b.mul_tweak(&secp, &scalar_a).expect("point mul"); + let uncompressed = shared_point.serialize_uncompressed(); // 0x04 ‖ x(32) ‖ y(32) + let prefix = 0x02u8 | (uncompressed[64] & 1); // y parity from the last y byte + let mut preimage = Vec::with_capacity(33); + preimage.push(prefix); + preimage.extend_from_slice(&uncompressed[1..33]); // x + let mut manual = [0u8; 32]; + manual.copy_from_slice(&Sha256::digest(&preimage)); + assert_eq!(ab, manual, "ECDH must be SHA256((y&1|2)‖x)"); + } +} diff --git a/packages/rs-platform-encryption/src/error.rs b/packages/rs-platform-encryption/src/error.rs new file mode 100644 index 00000000000..763aaa6ee6b --- /dev/null +++ b/packages/rs-platform-encryption/src/error.rs @@ -0,0 +1,17 @@ +//! Error type shared across the DIP-15 crypto modules. + +/// Errors that can occur during cryptographic operations +#[derive(Debug, thiserror::Error)] +pub enum CryptoError { + #[error("Decryption failed")] + DecryptionFailed, + + #[error("Invalid UTF-8 in decrypted data")] + InvalidUtf8, + + #[error("Invalid ciphertext length (must be at least 16 bytes for IV)")] + InvalidCiphertextLength, + + #[error("Invalid compact xpub length (DIP-15 requires exactly 69 bytes, got {0})")] + InvalidCompactXpubLength(usize), +} diff --git a/packages/rs-platform-encryption/src/lib.rs b/packages/rs-platform-encryption/src/lib.rs index 8cdaa283b63..6a6f9c4cc93 100644 --- a/packages/rs-platform-encryption/src/lib.rs +++ b/packages/rs-platform-encryption/src/lib.rs @@ -2,277 +2,36 @@ //! //! This crate implements the Diffie-Hellman key exchange and encryption/decryption //! operations as specified in DIP-15 for secure communication between Dash identities. - -use aes::cipher::{block_padding::Pkcs7, KeyIvInit}; -use aes::Aes256; -use dashcore::secp256k1::{PublicKey, SecretKey}; - -type Aes256CbcEnc = cbc::Encryptor; -type Aes256CbcDec = cbc::Decryptor; - -/// Derive a shared secret key using ECDH as specified in DIP-15 -/// -/// This uses libsecp256k1_ecdh which computes: SHA256((y[31]&0x1|0x2) || x) -/// where (x, y) is the EC point result of scalar multiplication -/// -/// # Arguments -/// * `private_key` - The private key for this side of the exchange -/// * `public_key` - The public key from the other party -/// -/// # Returns -/// A 32-byte shared secret key -pub fn derive_shared_key_ecdh(private_key: &SecretKey, public_key: &PublicKey) -> [u8; 32] { - use dashcore::secp256k1::ecdh::SharedSecret; - - // Use secp256k1's built-in ECDH which matches libsecp256k1_ecdh - // This computes SHA256((y[31]&0x1|0x2) || x) internally - let shared_secret = SharedSecret::new(public_key, private_key); - - let mut key = [0u8; 32]; - key.copy_from_slice(shared_secret.as_ref()); - key -} - -/// Encrypt data using CBC-AES-256 -/// -/// # Arguments -/// * `key` - 32-byte encryption key -/// * `iv` - 16-byte initialization vector (must be randomly generated and unique) -/// * `data` - Data to encrypt -/// -/// # Returns -/// Encrypted data with PKCS7 padding -pub fn encrypt_aes_256_cbc(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Vec { - use aes::cipher::BlockEncryptMut; - - let cipher = Aes256CbcEnc::new(key.into(), iv.into()); - let mut buffer = Vec::new(); - buffer.extend_from_slice(data); - - // Add padding - let padding_needed = 16 - (data.len() % 16); - buffer.resize(data.len() + padding_needed, padding_needed as u8); - - cipher - .encrypt_padded_mut::(&mut buffer, data.len()) - .expect("encryption failed") - .to_vec() -} - -/// Decrypt data using CBC-AES-256 -/// -/// # Arguments -/// * `key` - 32-byte encryption key -/// * `iv` - 16-byte initialization vector -/// * `ciphertext` - Encrypted data to decrypt -/// -/// # Returns -/// Decrypted data with padding removed -pub fn decrypt_aes_256_cbc( - key: &[u8; 32], - iv: &[u8; 16], - ciphertext: &[u8], -) -> Result, CryptoError> { - use aes::cipher::BlockDecryptMut; - - let cipher = Aes256CbcDec::new(key.into(), iv.into()); - let mut buffer = ciphertext.to_vec(); - - let decrypted = cipher - .decrypt_padded_mut::(&mut buffer) - .map_err(|_| CryptoError::DecryptionFailed)?; - - Ok(decrypted.to_vec()) -} - -/// Encrypt an extended public key for DashPay contact requests (DIP-15) -/// -/// # Arguments -/// * `shared_key` - 32-byte shared secret from ECDH -/// * `iv` - 16-byte initialization vector (must be randomly generated) -/// * `xpub` - Extended public key bytes to encrypt -/// -/// # Returns -/// Encrypted extended public key with IV prepended (96 bytes: 16-byte IV + 80-byte encrypted data) -pub fn encrypt_extended_public_key(shared_key: &[u8; 32], iv: &[u8; 16], xpub: &[u8]) -> Vec { - let encrypted_data = encrypt_aes_256_cbc(shared_key, iv, xpub); - - // Prepend IV to encrypted data as per DIP-15 - let mut result = Vec::with_capacity(16 + encrypted_data.len()); - result.extend_from_slice(iv); - result.extend_from_slice(&encrypted_data); - result -} - -/// Decrypt an extended public key from DashPay contact requests (DIP-15) -/// -/// # Arguments -/// * `shared_key` - 32-byte shared secret from ECDH -/// * `encrypted_data` - Encrypted extended public key with IV prepended (96 bytes total) -/// -/// # Returns -/// Decrypted extended public key bytes -pub fn decrypt_extended_public_key( - shared_key: &[u8; 32], - encrypted_data: &[u8], -) -> Result, CryptoError> { - if encrypted_data.len() < 16 { - return Err(CryptoError::InvalidCiphertextLength); - } - - // Extract IV from first 16 bytes - let iv: [u8; 16] = encrypted_data[..16].try_into().unwrap(); - let ciphertext = &encrypted_data[16..]; - - decrypt_aes_256_cbc(shared_key, &iv, ciphertext) -} - -/// Encrypt an account label for DashPay (DIP-15) -/// -/// # Arguments -/// * `shared_key` - 32-byte shared secret from ECDH -/// * `iv` - 16-byte initialization vector (must be randomly generated, different from xpub IV) -/// * `label` - Account label string to encrypt -/// -/// # Returns -/// Encrypted label with IV prepended (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) -pub fn encrypt_account_label(shared_key: &[u8; 32], iv: &[u8; 16], label: &str) -> Vec { - let encrypted_data = encrypt_aes_256_cbc(shared_key, iv, label.as_bytes()); - - // Prepend IV to encrypted data as per DIP-15 - let mut result = Vec::with_capacity(16 + encrypted_data.len()); - result.extend_from_slice(iv); - result.extend_from_slice(&encrypted_data); - result -} - -/// Decrypt an account label from DashPay (DIP-15) -/// -/// # Arguments -/// * `shared_key` - 32-byte shared secret from ECDH -/// * `encrypted_data` - Encrypted label with IV prepended (48-80 bytes total) -/// -/// # Returns -/// Decrypted label string -pub fn decrypt_account_label( - shared_key: &[u8; 32], - encrypted_data: &[u8], -) -> Result { - if encrypted_data.len() < 16 { - return Err(CryptoError::InvalidCiphertextLength); - } - - // Extract IV from first 16 bytes - let iv: [u8; 16] = encrypted_data[..16].try_into().unwrap(); - let ciphertext = &encrypted_data[16..]; - - let decrypted = decrypt_aes_256_cbc(shared_key, &iv, ciphertext)?; - String::from_utf8(decrypted).map_err(|_| CryptoError::InvalidUtf8) -} - -/// Errors that can occur during cryptographic operations -#[derive(Debug, thiserror::Error)] -pub enum CryptoError { - #[error("Decryption failed")] - DecryptionFailed, - - #[error("Invalid UTF-8 in decrypted data")] - InvalidUtf8, - - #[error("Invalid ciphertext length (must be at least 16 bytes for IV)")] - InvalidCiphertextLength, -} - -#[cfg(test)] -mod tests { - use super::*; - use dashcore::secp256k1::rand::{thread_rng, RngCore}; - use dashcore::secp256k1::Secp256k1; - - #[test] - fn test_ecdh_key_derivation() { - let secp = Secp256k1::new(); - - // Generate two key pairs - let (secret1, public1) = secp.generate_keypair(&mut thread_rng()); - let (secret2, public2) = secp.generate_keypair(&mut thread_rng()); - - // Derive shared keys from both sides - let shared1 = derive_shared_key_ecdh(&secret1, &public2); - let shared2 = derive_shared_key_ecdh(&secret2, &public1); - - // Both sides should derive the same shared key - assert_eq!(shared1, shared2); - } - - #[test] - fn test_aes_encryption_decryption() { - let key = [0u8; 32]; - let mut iv = [0u8; 16]; - thread_rng().fill_bytes(&mut iv); - - let plaintext = b"Hello, DashPay!"; - - let ciphertext = encrypt_aes_256_cbc(&key, &iv, plaintext); - let decrypted = decrypt_aes_256_cbc(&key, &iv, &ciphertext).unwrap(); - - assert_eq!(plaintext, decrypted.as_slice()); - } - - #[test] - fn test_extended_public_key_encryption() { - let secp = Secp256k1::new(); - let (secret1, _public1) = secp.generate_keypair(&mut thread_rng()); - let (_secret2, public2) = secp.generate_keypair(&mut thread_rng()); - - // Derive shared key - let shared_key = derive_shared_key_ecdh(&secret1, &public2); - - // Generate random IV - let mut iv = [0u8; 16]; - thread_rng().fill_bytes(&mut iv); - - // Mock extended public key data (78 bytes) - let xpub_data = vec![0x04; 78]; - - // Encrypt and decrypt - let encrypted = encrypt_extended_public_key(&shared_key, &iv, &xpub_data); - - // Verify size: 16 bytes (IV) + 80 bytes (encrypted data) = 96 bytes - assert_eq!(encrypted.len(), 96, "Encrypted xpub should be 96 bytes"); - - let decrypted = decrypt_extended_public_key(&shared_key, &encrypted).unwrap(); - - assert_eq!(xpub_data, decrypted); - } - - #[test] - fn test_account_label_encryption() { - let secp = Secp256k1::new(); - let (secret1, _public1) = secp.generate_keypair(&mut thread_rng()); - let (_secret2, public2) = secp.generate_keypair(&mut thread_rng()); - - // Derive shared key - let shared_key = derive_shared_key_ecdh(&secret1, &public2); - - // Generate random IV - let mut iv = [0u8; 16]; - thread_rng().fill_bytes(&mut iv); - - let label = "My DashPay Account"; - - // Encrypt and decrypt - let encrypted = encrypt_account_label(&shared_key, &iv, label); - - // Verify size is in valid range: 48-80 bytes (16-byte IV + 32-64 bytes encrypted) - assert!( - encrypted.len() >= 48 && encrypted.len() <= 80, - "Encrypted label should be 48-80 bytes, got {}", - encrypted.len() - ); - - let decrypted = decrypt_account_label(&shared_key, &encrypted).unwrap(); - - assert_eq!(label, decrypted); - } -} +//! +//! The DIP-15 surface is split by concern: +//! - [`ecdh`] — ECDH shared-secret derivation. +//! - [`aes`] — AES-256-CBC primitives shared by the encrypted fields. +//! - [`compact_xpub`] — the 69-byte compact xpub (`encryptedPublicKey`) + its encryption. +//! - [`account_label`] — `encryptedAccountLabel`. +//! - [`contact_info`] — `contactInfo` (`encToUserId` + `privateData`). +//! - [`account_reference`] — the masked `accountReference`. +//! - [`error`] — the shared [`CryptoError`]. +//! +//! Every public item is re-exported at the crate root, so the API is flat +//! (`platform_encryption::derive_shared_key_ecdh`, etc.) regardless of module. + +mod account_label; +mod account_reference; +mod aes; +mod compact_xpub; +mod contact_info; +mod ecdh; +mod error; + +pub use account_label::{decrypt_account_label, encrypt_account_label}; +pub use account_reference::{calculate_account_reference, unmask_account_reference}; +pub use aes::{decrypt_aes_256_cbc, encrypt_aes_256_cbc}; +pub use compact_xpub::{ + compact_xpub_bytes, decrypt_extended_public_key, encrypt_extended_public_key, + parse_compact_xpub, CompactXpub, COMPACT_XPUB_LEN, +}; +pub use contact_info::{ + decrypt_enc_to_user_id, decrypt_private_data, encrypt_enc_to_user_id, encrypt_private_data, +}; +pub use ecdh::derive_shared_key_ecdh; +pub use error::CryptoError; diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 8a2bd4ef2b4..370e71754ea 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -30,6 +30,10 @@ dashcore = { workspace = true } key-wallet = { workspace = true, features = ["bincode"] } dash-network = { workspace = true, features = ["ffi"] } +# For the glue impl of platform-wallet's async `DrainCryptoProvider` trait over +# the resolver-backed signer (deferred-crypto drain FFI). +async-trait = "0.1" + # Bincode used to serialize RootExtendedPubKey / ExtendedPubKey across # FFI for watch-only restore. Same version pinned elsewhere in workspace. bincode = { version = "=2.0.1" } diff --git a/packages/rs-platform-wallet-ffi/src/contact.rs b/packages/rs-platform-wallet-ffi/src/contact.rs index d0553068fee..2fdfd5343e7 100644 --- a/packages/rs-platform-wallet-ffi/src/contact.rs +++ b/packages/rs-platform-wallet-ffi/src/contact.rs @@ -122,27 +122,25 @@ pub unsafe extern "C" fn managed_identity_accept_contact_request( PlatformWalletFFIResult::ok() } -/// Reject an incoming contact request -/// This will remove the request from incoming_contact_requests +/// Ignore a contact sender (per-sender mute, = block, reversible). +/// +/// Local in-memory path on a managed-identity handle (no persister) — +/// drops the sender's pending incoming request and records them in +/// `ignored_senders`. The durable, persisted path is the wallet-scoped +/// `platform_wallet_ignore_contact_sender`. #[no_mangle] -pub unsafe extern "C" fn managed_identity_reject_contact_request( +pub unsafe extern "C" fn managed_identity_ignore_contact_sender( identity_handle: Handle, sender_id: *const u8, ) -> PlatformWalletFFIResult { let id = unwrap_result_or_return!(unsafe { read_identifier(sender_id) }); let option = MANAGED_IDENTITY_STORAGE.with_item_mut(identity_handle, |identity| { - identity.remove_incoming_contact_request(&id).0.is_some() + // Drop the returned changeset — this handle has no persister. + let _ = identity.ignore_sender(&id); }); - let removed = unwrap_option_or_return!(option); - if removed { - PlatformWalletFFIResult::ok() - } else { - PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorContactNotFound, - "Contact request not found", - ) - } + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() } #[cfg(test)] diff --git a/packages/rs-platform-wallet-ffi/src/contact_info.rs b/packages/rs-platform-wallet-ffi/src/contact_info.rs new file mode 100644 index 00000000000..ea70de0fddf --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/contact_info.rs @@ -0,0 +1,118 @@ +//! FFI bindings for DashPay `contactInfo` (alias / note / hidden). +//! +//! One write entry point: set the metadata locally AND publish the +//! self-encrypted `contactInfo` document. The local state ALWAYS +//! lands in SwiftData via the persister; the document publish may be +//! deferred (DIP-15 ≥2-contacts privacy rule) or skipped (watch-only). +//! The `out_outcome` param reports which happened so the UI can tell +//! the user the truth instead of unconditionally claiming a sync. +//! Reads need no new FFI: decrypted values flow into the +//! established-contact changeset during the recurring sync. + +use std::os::raw::c_char; + +use platform_wallet::ContactInfoPublishOutcome; +use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; + +use crate::dashpay::resolver_contact_crypto_provider; + +use crate::check_ptr; +use crate::dashpay_profile::decode_opt_c_str; +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::types::*; +use crate::{unwrap_option_or_return, unwrap_result_or_return}; + +/// Outcome discriminant written to `out_outcome` by +/// [`platform_wallet_set_dashpay_contact_info_with_signer`]. Mirrors +/// [`ContactInfoPublishOutcome`]. +pub const CONTACT_INFO_PUBLISHED: u8 = 0; +pub const CONTACT_INFO_DEFERRED_UNTIL_TWO_CONTACTS: u8 = 1; +pub const CONTACT_INFO_SKIPPED_WATCH_ONLY: u8 = 2; + +/// Set alias / note / hidden for an established contact and publish +/// the corresponding `contactInfo` document. +/// +/// `alias` / `note` may be NULL (= clear the field). The signer is +/// the same vtable signer the profile write entry point takes. +/// `out_outcome` (if non-null) receives the publish outcome +/// discriminant (`CONTACT_INFO_*` above): local state is always +/// updated, but the cross-device document publish may have been +/// deferred or skipped. +/// +/// `core_signer_handle` is the wallet-HD resolver signer (as for send/accept): +/// the contactInfo AES keys are derived through it, so no resident seed is +/// needed and watch-only / external-signable wallets publish too. +/// +/// # Safety +/// `wallet_handle` must be a live wallet handle; `identity_id` and +/// `contact_id` must point at 32 readable bytes; `signer_handle` +/// must be a live `VTableSigner` and `core_signer_handle` a live +/// `*mut MnemonicResolverHandle` for the duration of the call; +/// `out_outcome` must be null or point at one writable byte. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_set_dashpay_contact_info_with_signer( + wallet_handle: Handle, + identity_id: *const u8, + contact_id: *const u8, + alias: *const c_char, + note: *const c_char, + display_hidden: bool, + signer_handle: *mut SignerHandle, + core_signer_handle: *mut MnemonicResolverHandle, + out_outcome: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(signer_handle); + check_ptr!(core_signer_handle); + + let identity = unwrap_result_or_return!(read_identifier(identity_id)); + let contact = unwrap_result_or_return!(read_identifier(contact_id)); + let alias = unwrap_result_or_return!(decode_opt_c_str(alias)); + let note = unwrap_result_or_return!(decode_opt_c_str(note)); + + let signer_addr = signer_handle as usize; + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| { + let identity_wallet = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: same lifetime contract as the drain/send FFI — the caller pins + // both handles for the duration of this call. + let provider = unsafe { + resolver_contact_crypto_provider( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity_wallet + .set_contact_info_with_external_signer( + &identity, + &contact, + alias, + note, + display_hidden, + signer, + &provider, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let outcome = unwrap_result_or_return!(result); + if !out_outcome.is_null() { + *out_outcome = match outcome { + ContactInfoPublishOutcome::Published => CONTACT_INFO_PUBLISHED, + ContactInfoPublishOutcome::DeferredUntilTwoContacts => { + CONTACT_INFO_DEFERRED_UNTIL_TWO_CONTACTS + } + ContactInfoPublishOutcome::SkippedWatchOnly => CONTACT_INFO_SKIPPED_WATCH_ONLY, + }; + } + PlatformWalletFFIResult::ok() +} diff --git a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs index f08d3b41c77..57dab72af29 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -101,6 +101,31 @@ pub struct ContactRequestFFI { pub core_height_created_at: u32, /// `ContactRequest::created_at` — Unix-millis timestamp. pub created_at: u64, + /// Whether the [`EstablishedContact`] this row was projected from + /// has a **permanently broken** payment channel. + /// + /// Only meaningful for rows projected from the `established` map — + /// both the outgoing and incoming row of an established pair carry + /// the same flag (it's a property of the relationship, not of one + /// direction). Always `false` for rows projected from pending + /// `sent_requests` / `incoming_requests` (a pending request has no + /// channel yet). The Swift handler persists it on both rows; the UI + /// reads it to disable "Send Dash" and surface "payment channel + /// broken — ask the contact to send a new request". + /// + /// [`EstablishedContact`]: platform_wallet::EstablishedContact + pub payment_channel_broken: bool, + /// Owner-private alias for the contact (`contactInfo`-backed). + /// Heap-allocated NUL-terminated UTF-8, or null when + /// unset. Only stamped on rows projected from the `established` + /// map (pending rows have no metadata); released by + /// [`free_contact_requests_ffi`]. + pub alias: *const std::os::raw::c_char, + /// Owner-private note — same conventions as [`Self::alias`]. + pub note: *const std::os::raw::c_char, + /// `contactInfo.displayHidden` — whether the owner hid this + /// contact. Established rows only; always `false` for pending. + pub is_hidden: bool, } /// Composite identifier for [`ContactChangeSet::removed_sent`] and @@ -121,6 +146,34 @@ pub struct ContactRequestRemovalFFI { pub contact_id: [u8; 32], } +/// Flat C mirror of a per-sender **ignore** delta for the `ignored` +/// array on [`OnPersistContactsFn`]. +/// +/// Ignore is a per-sender mute (= block, reversible, local-only); the +/// suppression key is `(owner_id, sender_id)` — bare sender id, so ALL +/// of the sender's requests (including rotated, bumped-`accountReference` +/// ones) are suppressed. The Swift handler persists one row per ignored +/// sender keyed on that pair so the sender stays suppressed across a +/// recurring re-sync. +/// +/// `is_ignored` is the insert/remove bit: `true` ⇒ persist the +/// ignored-sender row (from `ContactChangeSet::ignored`); `false` ⇒ +/// delete it (an un-ignore, from `ContactChangeSet::unignored`). Carrying +/// both in one array lets the host process a mixed delta in one callback. +/// +/// Flat POD (no owned pointers), so the host must copy any row it wants +/// to retain; nothing is freed on the Rust side. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ContactIgnoredSenderFFI { + /// The wallet-owned identity that ignored the sender (recipient). + pub owner_id: [u8; 32], + /// The ignored sender's identity. + pub sender_id: [u8; 32], + /// `true` ⇒ persist (ignore); `false` ⇒ delete (un-ignore). + pub is_ignored: bool, +} + // Compile-time guards. Pin the expected layouts so any reshape on // the Rust side fails the cargo build before it can ship a dylib // the Swift side will mis-parse at runtime. @@ -143,15 +196,49 @@ pub struct ContactRequestRemovalFFI { // 128..=131 core_height_created_at u32 // 132..=135 (padding to 8) // 136..=143 created_at u64 +// 144 payment_channel_broken bool +// 145..=151 (padding to 8) +// 152..=159 alias *const c_char +// 160..=167 note *const c_char +// 168 is_hidden bool +// 169..=175 (tail padding to alignment 8) // -// Total size = 144, alignment = 8 (from u64 / pointer fields). -const _: [u8; 144] = [0u8; std::mem::size_of::()]; +// Total size = 176, alignment = 8 (from u64 / pointer fields). +const _: [u8; 176] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // Expected `ContactRequestRemovalFFI` layout: 64 bytes, alignment 1. const _: [u8; 64] = [0u8; std::mem::size_of::()]; const _: [u8; 1] = [0u8; std::mem::align_of::()]; +// Expected `ContactIgnoredSenderFFI` layout on all targets: +// +// 0..=31 owner_id [u8; 32] +// 32..=63 sender_id [u8; 32] +// 64 is_ignored bool +// (no tail padding — alignment 1) +// +// Total size = 65, alignment = 1 (all-byte fields). +const _: [u8; 65] = [0u8; std::mem::size_of::()]; +const _: [u8; 1] = [0u8; std::mem::align_of::()]; + +impl ContactIgnoredSenderFFI { + /// Project an `(owner, sender)` ignore key onto its flat C mirror. + /// `is_ignored` distinguishes an ignore (persist the row) from an + /// un-ignore (delete the row). + pub fn new( + owner_id: &dpp::prelude::Identifier, + sender_id: &dpp::prelude::Identifier, + is_ignored: bool, + ) -> Self { + Self { + owner_id: owner_id.to_buffer(), + sender_id: sender_id.to_buffer(), + is_ignored, + } + } +} + // --------------------------------------------------------------------------- // Conversions // --------------------------------------------------------------------------- @@ -173,7 +260,9 @@ impl ContactRequestFFI { contact_id: [u8; 32], request: &platform_wallet::ContactRequest, ) -> Self { - Self::from_parts(owner_id, contact_id, true, request) + Self::from_parts( + owner_id, contact_id, true, request, false, None, None, false, + ) } /// Sibling of [`Self::from_outgoing`] for the incoming direction @@ -183,14 +272,75 @@ impl ContactRequestFFI { contact_id: [u8; 32], request: &platform_wallet::ContactRequest, ) -> Self { - Self::from_parts(owner_id, contact_id, false, request) + Self::from_parts( + owner_id, contact_id, false, request, false, None, None, false, + ) + } + + /// Build the **outgoing** row of an established contact, stamping + /// the relationship's `payment_channel_broken` flag and the + /// owner-private metadata (alias / note / hidden — contactInfo, + /// M3) onto the row. + /// + /// Used by the persister's `established` projection (one outgoing + + /// one incoming row per entry), where these are properties of the + /// relationship and are therefore replicated onto both rows. + #[allow(clippy::too_many_arguments)] + pub fn from_established_outgoing( + owner_id: [u8; 32], + contact_id: [u8; 32], + request: &platform_wallet::ContactRequest, + payment_channel_broken: bool, + alias: Option<&str>, + note: Option<&str>, + is_hidden: bool, + ) -> Self { + Self::from_parts( + owner_id, + contact_id, + true, + request, + payment_channel_broken, + alias, + note, + is_hidden, + ) } + /// Sibling of [`Self::from_established_outgoing`] for the **incoming** + /// row of an established contact. + #[allow(clippy::too_many_arguments)] + pub fn from_established_incoming( + owner_id: [u8; 32], + contact_id: [u8; 32], + request: &platform_wallet::ContactRequest, + payment_channel_broken: bool, + alias: Option<&str>, + note: Option<&str>, + is_hidden: bool, + ) -> Self { + Self::from_parts( + owner_id, + contact_id, + false, + request, + payment_channel_broken, + alias, + note, + is_hidden, + ) + } + + #[allow(clippy::too_many_arguments)] fn from_parts( owner_id: [u8; 32], contact_id: [u8; 32], is_outgoing: bool, request: &platform_wallet::ContactRequest, + payment_channel_broken: bool, + alias: Option<&str>, + note: Option<&str>, + is_hidden: bool, ) -> Self { let (encrypted_public_key, encrypted_public_key_len) = allocate_byte_buffer(&request.encrypted_public_key); @@ -219,10 +369,36 @@ impl ContactRequestFFI { auto_accept_proof_len, core_height_created_at: request.core_height_created_at, created_at: request.created_at, + payment_channel_broken, + alias: allocate_c_string(alias), + note: allocate_c_string(note), + is_hidden, } } } +/// Heap-allocate a NUL-terminated copy of `value`, or null for `None` +/// / interior-NUL strings. Released by [`free_contact_requests_ffi`] +/// via [`free_c_string`]. +fn allocate_c_string(value: Option<&str>) -> *const std::os::raw::c_char { + match value { + Some(v) => match std::ffi::CString::new(v) { + Ok(c) => c.into_raw(), + Err(_) => ptr::null(), + }, + None => ptr::null(), + } +} + +/// Reclaim a string previously published via [`allocate_c_string`]. +/// Idempotent on null slots. +fn free_c_string(slot: &mut *const std::os::raw::c_char) { + if !slot.is_null() { + let _ = unsafe { std::ffi::CString::from_raw(*slot as *mut std::os::raw::c_char) }; + } + *slot = ptr::null(); +} + /// Heap-allocate a `Box<[u8]>` from `bytes` and return a `(ptr, len)` /// pair owned by the caller. Empty slices return `(null, 0)` so the /// receiver can avoid an empty allocation walk; the matching free @@ -271,6 +447,8 @@ pub unsafe fn free_contact_requests_ffi(entries: *mut ContactRequestFFI, count: &mut entry.auto_accept_proof, &mut entry.auto_accept_proof_len, ); + free_c_string(&mut entry.alias); + free_c_string(&mut entry.note); } } @@ -305,6 +483,14 @@ fn free_byte_buffer(slot: &mut *const u8, len_slot: &mut usize) { /// rows (sent requests explicitly removed by the owner). /// - `removed_incoming` / `removed_incoming_count`: tombstones for /// incoming rows. +/// - `ignored` / `ignored_count`: per-sender ignore deltas, keyed +/// `(owner, sender)`. Each row's `is_ignored` bit says whether to +/// persist the ignored-sender row (`true`, from an ignore) or delete +/// it (`false`, from an un-ignore). The host persists/deletes these so +/// an ignored sender stays suppressed across a recurring re-sync — ALL +/// of the sender's requests (including rotated ones). Pointer is valid +/// only for the duration of the callback; rows are POD (no heap +/// payloads), so the host must copy any it wants to retain. /// /// Return code: `0` on success, non-zero to flag the round as failed /// for the bracketing changeset begin/end transaction. @@ -317,6 +503,8 @@ pub type OnPersistContactsFn = unsafe extern "C" fn( removed_sent_count: usize, removed_incoming: *const ContactRequestRemovalFFI, removed_incoming_count: usize, + ignored: *const ContactIgnoredSenderFFI, + ignored_count: usize, ) -> i32; #[cfg(test)] @@ -364,6 +552,8 @@ mod tests { assert_eq!(ffi.auto_accept_proof_len, 4); assert_eq!(ffi.core_height_created_at, 100_000); assert_eq!(ffi.created_at, 1_700_000_000_000); + // Pending (non-established) rows are never broken. + assert!(!ffi.payment_channel_broken); unsafe { free_contact_requests_ffi(&mut ffi as *mut ContactRequestFFI, 1) }; assert!(ffi.encrypted_public_key.is_null()); @@ -387,4 +577,92 @@ mod tests { assert_eq!(ffi.auto_accept_proof_len, 0); unsafe { free_contact_requests_ffi(&mut ffi as *mut ContactRequestFFI, 1) }; } + + /// The `established_*` constructors stamp the relationship's + /// `payment_channel_broken` flag onto BOTH the outgoing and incoming + /// row. This pins that the broken-channel flag survives the persister + /// projection (the plain `from_outgoing`/`from_incoming` pending constructors + /// always emit `false` — verified above), so a Swift `@Query`-driven + /// contact row can render the broken-channel badge without consulting + /// a live handle getter. + #[test] + fn established_rows_carry_payment_channel_broken_flag() { + let request = sample_request(); + let owner = [3u8; 32]; + let contact = [4u8; 32]; + + // Broken relationship: both projected rows must carry the flag — + // plus the owner-private metadata (contactInfo, M3). + let mut out = ContactRequestFFI::from_established_outgoing( + owner, + contact, + &request, + true, + Some("ally"), + Some("a note"), + true, + ); + let mut inc = ContactRequestFFI::from_established_incoming( + owner, + contact, + &request, + true, + Some("ally"), + Some("a note"), + true, + ); + assert!(out.is_outgoing); + assert!(!inc.is_outgoing); + assert!(out.payment_channel_broken); + assert!(inc.payment_channel_broken); + for row in [&out, &inc] { + let alias = unsafe { std::ffi::CStr::from_ptr(row.alias) }; + assert_eq!(alias.to_str().unwrap(), "ally"); + let note = unsafe { std::ffi::CStr::from_ptr(row.note) }; + assert_eq!(note.to_str().unwrap(), "a note"); + assert!(row.is_hidden); + } + + // Healthy relationship without metadata: flag clear, strings null. + let mut healthy = ContactRequestFFI::from_established_outgoing( + owner, contact, &request, false, None, None, false, + ); + assert!(!healthy.payment_channel_broken); + assert!(healthy.alias.is_null()); + assert!(healthy.note.is_null()); + assert!(!healthy.is_hidden); + + unsafe { + free_contact_requests_ffi(&mut out as *mut ContactRequestFFI, 1); + free_contact_requests_ffi(&mut inc as *mut ContactRequestFFI, 1); + free_contact_requests_ffi(&mut healthy as *mut ContactRequestFFI, 1); + } + assert!(out.alias.is_null(), "free must reclaim + null the alias"); + assert!(out.note.is_null()); + } + + /// `ContactIgnoredSenderFFI::new` must carry the `(owner, sender)` + /// suppression key and the insert/remove `is_ignored` bit, so the + /// Swift handler can persist an ignore (`true`) or delete an + /// un-ignore (`false`). + #[test] + fn ignored_sender_ffi_carries_key_and_insert_remove_bit() { + use dpp::prelude::Identifier; + + let owner = Identifier::from([7u8; 32]); + let sender = Identifier::from([8u8; 32]); + + let ignore = ContactIgnoredSenderFFI::new(&owner, &sender, true); + assert_eq!(ignore.owner_id, [7u8; 32]); + assert_eq!(ignore.sender_id, [8u8; 32]); + assert!(ignore.is_ignored, "ignore must set is_ignored = true"); + + let unignore = ContactIgnoredSenderFFI::new(&owner, &sender, false); + assert_eq!(unignore.owner_id, [7u8; 32]); + assert_eq!(unignore.sender_id, [8u8; 32]); + assert!( + !unignore.is_ignored, + "un-ignore must set is_ignored = false so the host deletes the row" + ); + } } diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 5ab9f0ed543..ea77b72dde5 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -2,7 +2,7 @@ //! the platform-wallet [`IdentityWallet`](platform_wallet::IdentityWallet). //! //! Replaces the local-state-only -//! `managed_identity_{send,accept,reject}_contact_request` FFI +//! `managed_identity_{send,accept}_contact_request` FFI //! family with Platform-broadcasting equivalents. Those local //! helpers still exist for in-memory manipulation (e.g. tests, //! initial bootstrap), but iOS flows should now drive from here so @@ -22,9 +22,10 @@ //! - [`platform_wallet_accept_contact_request_with_signer`] — //! reciprocate an incoming request, returning a handle into //! `ESTABLISHED_CONTACT_STORAGE`. -//! - [`platform_wallet_reject_contact_request`] — drop an incoming -//! request locally (on-chain contactInfo tombstone is a future -//! follow-up). +//! - [`platform_wallet_ignore_contact_sender`] / +//! [`platform_wallet_unignore_contact_sender`] — ignore (per-sender +//! mute, = block, reversible) / un-ignore a sender. Local-only; no +//! on-chain artifact. //! - [`platform_wallet_fetch_sent_contact_requests`] — query //! Platform for the identity's sent requests. //! - [`platform_wallet_send_payment`] — send a Dash payment to an @@ -35,7 +36,7 @@ use std::ffi::CStr; use std::os::raw::c_char; use platform_wallet::ContactRequest; -use rs_sdk_ffi::{SignerHandle, VTableSigner}; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; use crate::contact_request::CONTACT_REQUEST_STORAGE; use crate::error::*; @@ -204,12 +205,15 @@ pub unsafe extern "C" fn platform_wallet_sync_contact_requests( /// valid, non-destroyed handle produced by /// `dash_sdk_signer_create_with_ctx`; caller retains ownership. /// -/// CAVEAT — ECDH derivation: the Rust side still derives the -/// sender's ECDH private key from the wallet seed for the contact -/// request encryption step. Watch-only wallets (no seed Rust-side) -/// will fail at that step. See the docstring on -/// [`IdentityWallet::send_contact_request_with_external_signer`](platform_wallet::IdentityWallet::send_contact_request_with_external_signer) -/// for the planned follow-up to push ECDH across the FFI as well. +/// `core_signer_handle` is the wallet-HD resolver signer (the same handle the +/// drain takes): the Rust side derives the friendship xpub, the ECDH shared +/// secret, and the DIP-15 `accountReference` through it, so no resident seed is +/// needed and watch-only / external-signable wallets work. Caller retains +/// ownership of both handles for the duration of the call. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( @@ -220,10 +224,12 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( auto_accept_proof: *const u8, auto_accept_proof_len: usize, signer_handle: *mut SignerHandle, + core_signer_handle: *mut MnemonicResolverHandle, out_request_handle: *mut Handle, ) -> PlatformWalletFFIResult { check_ptr!(out_request_handle); check_ptr!(signer_handle); + check_ptr!(core_signer_handle); let sender = unwrap_result_or_return!(read_identifier(sender_identity_id)); let recipient = unwrap_result_or_return!(read_identifier(recipient_identity_id)); @@ -239,14 +245,31 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( }; let signer_addr = signer_handle as usize; + let core_signer_addr = core_signer_handle as usize; let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: same lifetime contract as the drain FFI — the caller pins + // both handles for the duration of this call. + let provider = unsafe { + resolver_contact_crypto_provider( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; block_on_worker(async move { let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); identity .send_contact_request_with_external_signer( - &sender, &recipient, label, proof, signer, + &sender, + &recipient, + label, + platform_wallet::AutoAcceptProofSource::from_option(proof), + signer, + &provider, ) .await }) @@ -257,6 +280,63 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( PlatformWalletFFIResult::ok() } +/// Send a contact request from a scanned DIP-15 auto-accept QR +/// (`dash:?du=&dapk=`). Resolves the QR's username to the +/// owner's identity, decodes the handed auto-accept key, signs the proof over +/// this send's `accountReference`, and broadcasts — so the owner can auto-accept +/// it. Inserts the resulting request at `*out_request_handle`. +/// +/// # Safety +/// - `sender_identity_id` must point to 32 readable bytes. +/// - `uri` must be a valid NUL-terminated UTF-8 C string. +/// - `signer_handle` / `core_signer_handle` must be valid, non-destroyed handles +/// (the caller pins both for the duration of this call). +/// - `out_request_handle` must be a valid `*mut Handle`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_send_contact_request_from_qr( + wallet_handle: Handle, + sender_identity_id: *const u8, + uri: *const c_char, + signer_handle: *mut SignerHandle, + core_signer_handle: *mut MnemonicResolverHandle, + out_request_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(uri); + check_ptr!(signer_handle); + check_ptr!(core_signer_handle); + check_ptr!(out_request_handle); + + let sender = unwrap_result_or_return!(read_identifier(sender_identity_id)); + let uri = unwrap_result_or_return!(CStr::from_ptr(uri).to_str()).to_string(); + let signer_addr = signer_handle as usize; + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: same lifetime contract as the send FFI — the caller pins both + // handles for the duration of this call. + let provider = unsafe { + resolver_contact_crypto_provider( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity + .send_contact_request_from_qr(&sender, &uri, signer, &provider) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let request = unwrap_result_or_return!(result); + *out_request_handle = CONTACT_REQUEST_STORAGE.insert(request); + PlatformWalletFFIResult::ok() +} + /// Accept an incoming contact request using an externally-supplied /// signer for the reciprocal request's document state-transition. /// @@ -268,29 +348,50 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( /// `request_handle` must be a live handle from /// `CONTACT_REQUEST_STORAGE` (typically obtained via /// `managed_identity_get_incoming_contact_request` or -/// [`platform_wallet_sync_contact_requests`]). Same ECDH caveat -/// applies as for [`platform_wallet_send_contact_request_with_signer`]. +/// [`platform_wallet_sync_contact_requests`]). `core_signer_handle` is the +/// wallet-HD resolver signer (as for +/// [`platform_wallet_send_contact_request_with_signer`]): the reciprocal send +/// and the external-account registration source all key material through it, so +/// no resident seed is needed. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`. #[no_mangle] pub unsafe extern "C" fn platform_wallet_accept_contact_request_with_signer( wallet_handle: Handle, request_handle: Handle, signer_handle: *mut SignerHandle, + core_signer_handle: *mut MnemonicResolverHandle, out_established_handle: *mut Handle, ) -> PlatformWalletFFIResult { check_ptr!(out_established_handle); check_ptr!(signer_handle); + check_ptr!(core_signer_handle); let request_option = CONTACT_REQUEST_STORAGE.with_item(request_handle, |req| req.clone()); let request = unwrap_option_or_return!(request_option); let signer_addr = signer_handle as usize; + let core_signer_addr = core_signer_handle as usize; let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: same lifetime contract as the drain FFI — the caller pins + // both handles for the duration of this call. + let provider = unsafe { + resolver_contact_crypto_provider( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; block_on_worker(async move { let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); identity - .accept_contact_request_with_external_signer(&request, signer) + .accept_contact_request_with_external_signer(&request, signer, &provider) .await }) }); @@ -301,17 +402,20 @@ pub unsafe extern "C" fn platform_wallet_accept_contact_request_with_signer( } // --------------------------------------------------------------------------- -// Reject contact request +// Ignore / un-ignore a contact sender (per-sender mute, local-only) // --------------------------------------------------------------------------- -/// Reject an incoming contact request. Drops the request from -/// `incoming_contact_requests` on the managed identity. A future -/// follow-up (noted on the Rust side) will also write a -/// `display_hidden` contactInfo document to Platform so the -/// rejection persists across devices; today the effect is local -/// only. +/// Ignore a contact sender (per-sender mute, = block, reversible). +/// +/// Drops the sender's pending incoming request and records the sender as +/// ignored so the recurring sync sweep suppresses ALL of their requests +/// (including rotated ones) from the main pending list. Ignore is +/// **local-only** — no on-chain artifact (syncing it would leak who you +/// ignored); it is persisted through the changeset → SwiftData pipeline so +/// it survives a relaunch. Reverse with +/// [`platform_wallet_unignore_contact_sender`]. #[no_mangle] -pub unsafe extern "C" fn platform_wallet_reject_contact_request( +pub unsafe extern "C" fn platform_wallet_ignore_contact_sender( wallet_handle: Handle, our_identity_id: *const u8, contact_identity_id: *const u8, @@ -321,7 +425,32 @@ pub unsafe extern "C" fn platform_wallet_reject_contact_request( let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity = wallet.identity().clone(); - block_on_worker(async move { identity.reject_contact_request(&our_id, &contact_id).await }) + block_on_worker(async move { identity.ignore_contact_sender(&our_id, &contact_id).await }) + }); + let result = unwrap_option_or_return!(option); + unwrap_result_or_return!(result); + PlatformWalletFFIResult::ok() +} + +/// Un-ignore a contact sender (reverse +/// [`platform_wallet_ignore_contact_sender`]). +/// +/// Removes the sender from the ignore set AND rewinds the received +/// high-water cursor so the next sweep re-fetches the sender's on-chain +/// requests (otherwise the cursor has already passed them and they'd never +/// reappear). A no-op (returns OK) when the sender wasn't ignored. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_unignore_contact_sender( + wallet_handle: Handle, + our_identity_id: *const u8, + contact_identity_id: *const u8, +) -> PlatformWalletFFIResult { + let our_id = unwrap_result_or_return!(unsafe { read_identifier(our_identity_id) }); + let contact_id = unwrap_result_or_return!(unsafe { read_identifier(contact_identity_id) }); + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.unignore_contact_sender(&our_id, &contact_id).await }) }); let result = unwrap_option_or_return!(option); unwrap_result_or_return!(result); @@ -357,15 +486,30 @@ pub unsafe extern "C" fn platform_wallet_fetch_sent_contact_requests( // --------------------------------------------------------------------------- /// Send a Dash payment from `from_identity_id` to `to_contact_identity_id`. +/// +/// The funding inputs are signed through the supplied +/// [`MnemonicResolverHandle`] — the same vtable shape used by +/// [`crate::core_wallet::core_wallet_send_to_addresses`] — which the FFI +/// wraps in a [`MnemonicResolverCoreSigner`] for the lifetime of this call. +/// The wallet seed is never made resident; every signature is produced +/// inside the signer's atomic derive-and-sign step. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`. Ownership is retained by the caller — +/// this function does NOT destroy it. #[no_mangle] +#[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_send_dashpay_payment( wallet_handle: Handle, from_identity_id: *const u8, to_contact_identity_id: *const u8, amount_duffs: u64, memo: *const c_char, + core_signer_handle: *mut MnemonicResolverHandle, out_txid: *mut [u8; 32], ) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); check_ptr!(out_txid); let from_id = unwrap_result_or_return!(unsafe { read_identifier(from_identity_id) }); @@ -375,11 +519,28 @@ pub unsafe extern "C" fn platform_wallet_send_dashpay_payment( } else { Some(unwrap_result_or_return!(unsafe { CStr::from_ptr(memo) }.to_str()).to_string()) }; + + let signer_addr = core_signer_handle as usize; + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: `signer_addr` came from `core_signer_handle`, which the + // caller pinned alive for the duration of this call (see fn-level + // safety doc). `MnemonicResolverCoreSigner` stores the handle as a + // `usize` and is `Send + Sync`, so it can move into the worker task; + // it is dropped when that task completes, before this call returns. + let signer = unsafe { + MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; block_on_worker(async move { identity - .send_payment(&from_id, &to_id, amount_duffs, memo_str) + .send_payment(&from_id, &to_id, amount_duffs, memo_str, &signer) .await }) }); @@ -392,3 +553,380 @@ pub unsafe extern "C" fn platform_wallet_send_dashpay_payment( } PlatformWalletFFIResult::ok() } + +/// Glue adapter implementing platform-wallet's [`ContactCryptoProvider`] over +/// the resolver-backed [`MnemonicResolverCoreSigner`]. The orphan rule needs +/// the impl's type local to this crate, so the signer is wrapped here rather +/// than implemented directly on it in `rs-sdk-ffi`. Serves the deferred-crypto +/// drain, the live send/accept flow, AND contactInfo publish. +pub(crate) struct ResolverContactCryptoProvider { + signer: MnemonicResolverCoreSigner, +} + +/// Wrap a caller-owned resolver handle as a [`ContactCryptoProvider`] for a +/// `(wallet_id, network)` — the single construction the contact-crypto FFI +/// entry points (send / accept / drain / contactInfo) share. +/// +/// # Safety +/// `core_signer_handle` must be a valid, non-destroyed `*mut MnemonicResolverHandle`; +/// the caller pins it for the duration of the provider's use. +pub(crate) unsafe fn resolver_contact_crypto_provider( + core_signer_handle: *mut MnemonicResolverHandle, + wallet_id: [u8; 32], + network: key_wallet::Network, +) -> ResolverContactCryptoProvider { + ResolverContactCryptoProvider { + signer: MnemonicResolverCoreSigner::new(core_signer_handle, wallet_id, network), + } +} + +#[async_trait::async_trait] +impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { + async fn receiving_xpub( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + use key_wallet::signer::Signer; + self.signer + .extended_public_key(path) + .await + .map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string())) + } + + async fn ecdh_shared_secret( + &self, + path: &key_wallet::bip32::DerivationPath, + peer: &dashcore::secp256k1::PublicKey, + ) -> Result<[u8; 32], platform_wallet::PlatformWalletError> { + self.signer + .ecdh_shared_secret(path, peer) + .map(|z| *z) + .map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string())) + } + + async fn export_auto_accept_private_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + let scalar = self + .signer + .export_auto_accept_private_key(path) + .map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string()))?; + dashcore::secp256k1::SecretKey::from_slice(scalar.as_ref()) + .map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string())) + } + + async fn account_reference( + &self, + path: &key_wallet::bip32::DerivationPath, + compact_xpub: &[u8], + account_index: u32, + version: u32, + ) -> Result { + self.signer + .account_reference(path, compact_xpub, account_index, version) + .map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string())) + } + + async fn unmask_account_reference( + &self, + path: &key_wallet::bip32::DerivationPath, + compact_xpub: &[u8], + account_reference: u32, + ) -> Result<(u32, u32), platform_wallet::PlatformWalletError> { + self.signer + .unmask_account_reference(path, compact_xpub, account_reference) + .map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string())) + } + + async fn contact_info_seal( + &self, + root_path: &key_wallet::bip32::DerivationPath, + derivation_index: u32, + contact_id: &[u8; 32], + private_data_plaintext: &[u8], + private_data_iv: &[u8; 16], + ) -> Result { + let sealed = self + .signer + .contact_info_seal( + root_path, + derivation_index, + contact_id, + private_data_plaintext, + private_data_iv, + ) + .map_err(|e| { + platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string()) + })?; + Ok(platform_wallet::ContactInfoSealed { + enc_to_user_id: sealed.enc_to_user_id, + private_data: sealed.private_data, + }) + } + + async fn contact_info_open( + &self, + root_path: &key_wallet::bip32::DerivationPath, + derivation_index: u32, + enc_to_user_id: &[u8; 32], + private_data_blob: &[u8], + ) -> Result { + let opened = self + .signer + .contact_info_open(root_path, derivation_index, enc_to_user_id, private_data_blob) + .map_err(|e| { + platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string()) + })?; + Ok(platform_wallet::ContactInfoOpened { + contact_id: opened.contact_id, + private_data: opened.private_data, + }) + } +} + +/// Drain the persisted deferred-crypto queue using the Keychain signer for the +/// key material. Call when a signer is available (Keychain unlock, or any +/// signer-present DashPay action). Runs the provider-only ops (account build / +/// contactInfo decrypt) AND the DIP-15 auto-accept pass (which needs the +/// identity `signer_handle` to send the reciprocal). Writes the total number of +/// completed entries (drained + auto-accepted) to `out_drained`. +/// +/// # Safety +/// - `signer_handle` (the identity document signer) and `core_signer_handle` +/// (the wallet-HD resolver) must each be valid, non-destroyed handles; +/// ownership is retained by the caller for the duration of this call. +/// - `out_drained` must be a valid `*mut u32`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_drain_pending_contact_crypto( + wallet_handle: Handle, + signer_handle: *mut SignerHandle, + core_signer_handle: *mut MnemonicResolverHandle, + out_drained: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(signer_handle); + check_ptr!(core_signer_handle); + check_ptr!(out_drained); + + let signer_addr = signer_handle as usize; + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: same lifetime contract as platform_wallet_send_dashpay_payment — + // the caller pins both handles for the duration of this call. + let provider = unsafe { + resolver_contact_crypto_provider( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + block_on_worker(async move { + let drained = identity.drain_pending_contact_crypto(&provider).await; + // The auto-accept pass needs the identity signer for the reciprocal. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let accepted = identity.drain_auto_accepts(signer, &provider).await; + drained + accepted + }) + }); + let total = unwrap_option_or_return!(option); + unsafe { + *out_drained = total as u32; + } + PlatformWalletFFIResult::ok() +} + +/// Number of deferred **account-build** contact-crypto ops queued for this +/// wallet (the `RegisterReceiving` / `RegisterExternal` ops that build a +/// contact's payment account and need a signer unlock). Writes the count to +/// `out_count`. +/// +/// Signerless read of in-memory state — no signer handle needed, safe to poll. +/// `> 0` means some contacts are waiting for an unlock to finish setup; it is a +/// wallet-scoped upper bound (aggregates the wallet's identities; may include +/// ops that resolve to channel-broken on the next drain). `ContactInfoDecrypt` +/// is excluded — it re-enqueues every sweep, so it is structurally always +/// present and is not an actionable backlog. +/// +/// # Safety +/// - `out_count` must be a valid `*mut u32`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_pending_contact_crypto_count( + wallet_handle: Handle, + out_count: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(out_count); + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.pending_contact_crypto_count().await }) + }); + let count = unwrap_option_or_return!(option); + unsafe { + *out_count = count as u32; + } + PlatformWalletFFIResult::ok() +} + +/// Build a DIP-15 auto-accept QR URI (`dash:?du=&dapk=`) for +/// this wallet, valid for 1 hour. `username` is the owner's DPNS name (a scanner +/// resolves it to the owner's identity). Writes a heap C string to `*out_uri`; +/// the caller frees it with `platform_wallet_string_free`. +/// +/// Derives the wallet's auto-accept private key through the resolver (the one +/// deliberate raw-key export — the key is a bearer credential the QR shares) and +/// encodes the `dapk` blob + URI Rust-side. +/// +/// # Safety +/// - `username` must be a valid NUL-terminated UTF-8 C string. +/// - `core_signer_handle` must be a valid, non-destroyed `*mut MnemonicResolverHandle` +/// (the caller pins it for the duration of this call). +/// - `out_uri` must be a valid `*mut *mut c_char`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_build_auto_accept_qr( + wallet_handle: Handle, + username: *const c_char, + core_signer_handle: *mut MnemonicResolverHandle, + out_uri: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(username); + check_ptr!(core_signer_handle); + check_ptr!(out_uri); + + let username = unwrap_result_or_return!(CStr::from_ptr(username).to_str()).to_string(); + let core_signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: same lifetime contract as the send/drain FFIs — the caller + // pins the resolver handle for the duration of this call. + let provider = unsafe { + resolver_contact_crypto_provider( + core_signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + block_on_worker(async move { identity.build_auto_accept_qr(&username, &provider).await }) + }); + let result = unwrap_option_or_return!(option); + let uri = unwrap_result_or_return!(result); + let c_uri = match std::ffi::CString::new(uri) { + Ok(c) => c, + Err(_) => { + return PlatformWalletFFIResult::from( + "auto-accept URI contained an interior NUL".to_string(), + ) + } + }; + unsafe { + *out_uri = c_uri.into_raw(); + } + PlatformWalletFFIResult::ok() +} + +/// Verify the resolver signer resolves the seed that owns this wallet, before +/// trusting it to sign. Derives the wallet's BIP44 account-0 xpub through the +/// signer and compares it to the persisted account xpub; a mismatch means the +/// signer is mapped to the wrong wallet and the call fails with +/// `ErrorInvalidParameter`. Run once at unlock (alongside the deferred-crypto +/// drain) so a mis-mapped Keychain slot can never sign for a wallet it does not +/// own — the wrong-seed detection without a resident seed. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`; ownership is retained by the caller. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_verify_seed_binds_to_wallet( + wallet_handle: Handle, + core_signer_handle: *mut MnemonicResolverHandle, +) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); + + let signer_addr = core_signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: same lifetime contract as the drain FFI — the caller pins the + // resolver handle for the duration of this call. + let provider = unsafe { + resolver_contact_crypto_provider( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + let wallet = wallet.clone(); + block_on_worker(async move { wallet.verify_seed_binds(&provider).await }) + }); + let result = unwrap_option_or_return!(option); + match result { + Ok(()) => PlatformWalletFFIResult::ok(), + Err(e @ platform_wallet::PlatformWalletError::SeedMismatch { .. }) => { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e.to_string(), + ) + } + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + e.to_string(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Marshalling-boundary coverage for the verify entry point, replacing what + // the removed attach FFI's input-validation tests upheld. The crypto + // semantics (matching seed binds, wrong seed rejected) are pinned library- + // side in `platform_wallet::...::seed_binding`. + + /// A null `core_signer_handle` is rejected with `ErrorNullPointer` (the + /// `check_ptr!` contract) before any wallet lookup. + #[test] + fn verify_seed_binds_null_signer_is_null_pointer() { + let r = unsafe { platform_wallet_verify_seed_binds_to_wallet(1, std::ptr::null_mut()) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// An unknown `wallet_handle` surfaces `NotFound` via the `with_item` + /// lookup miss. The signer handle is never dereferenced (the wallet lookup + /// fails first), so a non-null dummy pointer is safe here. + #[test] + fn verify_seed_binds_unknown_wallet_is_not_found() { + let dummy_signer = 1usize as *mut MnemonicResolverHandle; + let r = + unsafe { platform_wallet_verify_seed_binds_to_wallet(0xDEAD_BEEF, dummy_signer) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + } + + /// A null `out_count` is rejected with `ErrorNullPointer` (the `check_ptr!` + /// contract) before any wallet lookup. + #[test] + fn pending_contact_crypto_count_null_out_is_null_pointer() { + let r = + unsafe { platform_wallet_pending_contact_crypto_count(1, std::ptr::null_mut()) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// An unknown `wallet_handle` surfaces `NotFound` via the `with_item` + /// lookup miss; `out_count` is left untouched. + #[test] + fn pending_contact_crypto_count_unknown_wallet_is_not_found() { + let mut count: u32 = 7; + let r = unsafe { + platform_wallet_pending_contact_crypto_count(0xDEAD_BEEF, &mut count) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(count, 7, "out_count is untouched on a lookup miss"); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/dashpay_payment.rs b/packages/rs-platform-wallet-ffi/src/dashpay_payment.rs new file mode 100644 index 00000000000..893bedc46f8 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dashpay_payment.rs @@ -0,0 +1,348 @@ +//! FFI getter for per-contact DashPay payment history. +//! +//! Swift's `ContactDetailView` renders a payment list per contact +//! (`PaymentEntry` on `ManagedIdentity.dashpay_payments`, keyed by +//! txid). This module exposes that map off an existing +//! [`ManagedIdentity`](platform_wallet::ManagedIdentity) handle (the +//! one the host already obtains via +//! [`crate::platform_wallet_get_managed_identity`]) as a flat array of +//! POD-plus-C-string rows. +//! +//! ## Why a getter, not a persister callback +//! +//! The `dashpay_payments` map is already part of the persisted +//! `ManagedIdentity` state (it round-trips through `IdentityEntry` and +//! the `dashpay_payments_overlay` changeset field), and the FFI already +//! hands the host a live `ManagedIdentity` handle from which DashPay +//! fields are read directly (e.g. +//! [`crate::established_contact_is_payment_channel_broken`]). A +//! getter therefore lands the smaller, lower-risk diff: no new +//! persister callback, no new SwiftData rehydration path. It mirrors the +//! handle-based array-return pattern already used by +//! [`ContactRequestHandleArray`](crate::dashpay::ContactRequestHandleArray) +//! and [`IdentifierArray`](crate::IdentifierArray). +//! +//! ## Ownership +//! +//! Each [`DashpayPaymentFFI`] owns its `txid` and (optional) `memo` +//! C-strings. [`dashpay_payment_array_free`] releases every string +//! across the array and the array backing buffer itself. + +use std::os::raw::c_char; + +use platform_wallet::wallet::identity::{PaymentDirection, PaymentStatus}; + +use crate::error::*; +use crate::handle::*; +use crate::{check_ptr, unwrap_option_or_return}; + +/// Direction of a DashPay payment from the owner's perspective. +/// Matches [`PaymentDirection`]. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DashpayPaymentDirectionFFI { + /// The owner sent this payment to the counterparty. + Sent = 0, + /// The owner received this payment from the counterparty. + Received = 1, +} + +impl From for DashpayPaymentDirectionFFI { + fn from(d: PaymentDirection) -> Self { + match d { + PaymentDirection::Sent => DashpayPaymentDirectionFFI::Sent, + PaymentDirection::Received => DashpayPaymentDirectionFFI::Received, + } + } +} + +/// Status of a DashPay payment on Core chain. Matches [`PaymentStatus`]. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DashpayPaymentStatusFFI { + /// Broadcast but not yet confirmed. + Pending = 0, + /// Confirmed on Core chain. + Confirmed = 1, + /// Broadcast failed or the transaction was dropped. + Failed = 2, +} + +impl From for DashpayPaymentStatusFFI { + fn from(s: PaymentStatus) -> Self { + match s { + PaymentStatus::Pending => DashpayPaymentStatusFFI::Pending, + PaymentStatus::Confirmed => DashpayPaymentStatusFFI::Confirmed, + PaymentStatus::Failed => DashpayPaymentStatusFFI::Failed, + } + } +} + +/// Flat C mirror of one [`PaymentEntry`](platform_wallet::wallet::identity::PaymentEntry) +/// row on a [`ManagedIdentity`](platform_wallet::ManagedIdentity). +/// +/// The `PaymentEntry` value carries no timestamp field (the underlying +/// model keys history by txid and does not record a wall-clock time), so +/// none is exposed here — ordering on the Swift side is by txid / +/// arrival, matching the Rust map. `txid` is the +/// `dashpay_payments` map key, surfaced as a C-string. +#[repr(C)] +// Deliberately NOT Clone/Copy: this struct owns its `txid` / `memo` +// heap pointers (freed by `dashpay_payment_array_free`). A bitwise Copy +// or a derived Clone would duplicate those raw pointers, so freeing both +// the original and the copy double-frees. Build rows in place instead. +#[derive(Debug)] +pub struct DashpayPaymentFFI { + /// The other identity in this payment (`counterparty_id`). Whether + /// they are the sender or the receiver is encoded in `direction`. + pub counterparty_id: [u8; 32], + /// Amount in duffs. Always positive; `direction` carries the sign. + pub amount_duffs: u64, + /// Payment direction from the owner's perspective. + pub direction: DashpayPaymentDirectionFFI, + /// Core-chain status. + pub status: DashpayPaymentStatusFFI, + /// NUL-terminated transaction id (hex), the `dashpay_payments` map + /// key. Always non-null. Owned — released by + /// [`dashpay_payment_array_free`]. + pub txid: *mut c_char, + /// NUL-terminated sender memo, or null when the source `Option` was + /// `None`. Owned — released by [`dashpay_payment_array_free`]. + pub memo: *mut c_char, +} + +/// Heap-allocated array of [`DashpayPaymentFFI`] rows returned by +/// [`managed_identity_get_dashpay_payments`]; released via +/// [`dashpay_payment_array_free`]. +#[repr(C)] +pub struct DashpayPaymentArray { + pub items: *mut DashpayPaymentFFI, + pub count: usize, +} + +impl DashpayPaymentArray { + fn empty() -> Self { + Self { + items: std::ptr::null_mut(), + count: 0, + } + } +} + +/// Convert a `&str` into an owned C-string pointer, or null on an +/// interior NUL. txids are hex and memos are user text — neither should +/// carry a NUL, but a defensive null keeps the FFI total rather than +/// panicking on malformed input. +fn cstring_or_null(s: &str) -> *mut c_char { + match std::ffi::CString::new(s) { + Ok(c) => c.into_raw(), + Err(_) => std::ptr::null_mut(), + } +} + +/// Read the DashPay payment history off a `ManagedIdentity` handle as a +/// flat array, keyed by txid in the underlying map's iteration order +/// (BTreeMap → lexicographic by txid hex). +/// +/// On success `*out_array` is populated; an identity with no recorded +/// payments yields an empty array (null `items`, `count == 0`) and still +/// returns `Success`. Release via [`dashpay_payment_array_free`]. +/// +/// # Safety +/// - `identity_handle` must be a live handle from +/// [`crate::platform_wallet_get_managed_identity`] (or another +/// `MANAGED_IDENTITY_STORAGE` producer). +/// - `out_array` must point at writable `DashpayPaymentArray` storage. +#[no_mangle] +pub unsafe extern "C" fn managed_identity_get_dashpay_payments( + identity_handle: Handle, + out_array: *mut DashpayPaymentArray, +) -> PlatformWalletFFIResult { + check_ptr!(out_array); + unsafe { *out_array = DashpayPaymentArray::empty() }; + + let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { + identity + .dashpay_payments + .iter() + .map(|(txid, entry)| DashpayPaymentFFI { + counterparty_id: entry.counterparty_id.to_buffer(), + amount_duffs: entry.amount_duffs, + direction: entry.direction.into(), + status: entry.status.into(), + txid: cstring_or_null(txid), + memo: entry + .memo + .as_deref() + .map(cstring_or_null) + .unwrap_or(std::ptr::null_mut()), + }) + .collect::>() + }); + let rows = unwrap_option_or_return!(option); + + if rows.is_empty() { + // `*out_array` already holds the empty sentinel. + return PlatformWalletFFIResult::ok(); + } + let count = rows.len(); + let boxed = rows.into_boxed_slice(); + let ptr = Box::into_raw(boxed) as *mut DashpayPaymentFFI; + unsafe { *out_array = DashpayPaymentArray { items: ptr, count } }; + PlatformWalletFFIResult::ok() +} + +/// Release an array returned by [`managed_identity_get_dashpay_payments`], +/// including every owned `txid` / `memo` C-string. +/// +/// Pointer-only signature (the array is a 16-byte aggregate at the +/// Swift-ABI cliff): pass `&mut array`. Idempotent — fields are reset to +/// the empty sentinel after free so a second call no-ops. +/// +/// # Safety +/// `array` must point at a `DashpayPaymentArray` produced by +/// [`managed_identity_get_dashpay_payments`] and not previously freed. +#[no_mangle] +pub unsafe extern "C" fn dashpay_payment_array_free(array: *mut DashpayPaymentArray) { + if array.is_null() { + return; + } + let array = unsafe { &mut *array }; + if array.items.is_null() || array.count == 0 { + array.items = std::ptr::null_mut(); + array.count = 0; + return; + } + let slice = unsafe { std::slice::from_raw_parts_mut(array.items, array.count) }; + for row in slice.iter_mut() { + if !row.txid.is_null() { + let _ = unsafe { std::ffi::CString::from_raw(row.txid) }; + row.txid = std::ptr::null_mut(); + } + if !row.memo.is_null() { + let _ = unsafe { std::ffi::CString::from_raw(row.memo) }; + row.memo = std::ptr::null_mut(); + } + } + let _ = unsafe { Box::from_raw(slice as *mut [DashpayPaymentFFI]) }; + array.items = std::ptr::null_mut(); + array.count = 0; +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::prelude::Identifier; + use platform_wallet::wallet::identity::PaymentEntry; + use platform_wallet::ManagedIdentity; + + /// Build a `ManagedIdentity` handle carrying a couple of payments so + /// the getter has real rows to project. Uses the same + /// `ManagedIdentity::new(identity, 0)` construction the other + /// `managed_identity_*` tests use. + fn managed_identity_with_payments() -> Handle { + // A minimal valid identity is awkward to build here; the + // `dashpay_payments` map is a plain public field, so we mutate + // it directly on a default-constructed identity via the same + // path the persister load uses. + let identity = dpp::identity::Identity::V0(dpp::identity::v0::IdentityV0::default()); + let mut managed = ManagedIdentity::new(identity, 0); + managed.dashpay_payments.insert( + "aa".repeat(32), + PaymentEntry::new_sent(Identifier::from([1u8; 32]), 12_000, Some("lunch".into())), + ); + managed.dashpay_payments.insert( + "bb".repeat(32), + PaymentEntry::new_received(Identifier::from([2u8; 32]), 7_500, None), + ); + MANAGED_IDENTITY_STORAGE.insert(managed) + } + + /// The getter must project every `dashpay_payments` row with its + /// direction/status/amount and the txid map key, surface the memo + /// (and null it when absent), and the paired free must reclaim every + /// owned string without a double-free. Pins the per-contact payment + /// history surface Swift renders in `ContactDetailView`. + #[test] + fn get_dashpay_payments_projects_rows_and_frees_clean() { + let handle = managed_identity_with_payments(); + + let mut array = DashpayPaymentArray::empty(); + let r = unsafe { managed_identity_get_dashpay_payments(handle, &mut array) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::Success); + assert_eq!(array.count, 2); + assert!(!array.items.is_null()); + + let rows = unsafe { std::slice::from_raw_parts(array.items, array.count) }; + // BTreeMap order: "aa…" (sent, lunch) before "bb…" (received). + let sent = &rows[0]; + assert_eq!(sent.direction, DashpayPaymentDirectionFFI::Sent); + assert_eq!(sent.status, DashpayPaymentStatusFFI::Pending); + assert_eq!(sent.amount_duffs, 12_000); + assert_eq!(sent.counterparty_id, [1u8; 32]); + assert!(!sent.txid.is_null()); + let txid = unsafe { std::ffi::CStr::from_ptr(sent.txid) } + .to_str() + .unwrap(); + assert_eq!(txid, "aa".repeat(32)); + let memo = unsafe { std::ffi::CStr::from_ptr(sent.memo) } + .to_str() + .unwrap(); + assert_eq!(memo, "lunch"); + + let received = &rows[1]; + assert_eq!(received.direction, DashpayPaymentDirectionFFI::Received); + assert_eq!(received.status, DashpayPaymentStatusFFI::Confirmed); + assert_eq!(received.amount_duffs, 7_500); + // No memo on the received entry → null pointer. + assert!(received.memo.is_null()); + + unsafe { dashpay_payment_array_free(&mut array) }; + assert!(array.items.is_null()); + assert_eq!(array.count, 0); + // Idempotent — second free must not double-free. + unsafe { dashpay_payment_array_free(&mut array) }; + + let _ = MANAGED_IDENTITY_STORAGE.remove(handle); + } + + /// An identity with no recorded payments yields an empty array and + /// still returns `Success` — empty is not an error (matches the + /// sibling array getters' contract). + #[test] + fn get_dashpay_payments_empty_is_success() { + let identity = dpp::identity::Identity::V0(dpp::identity::v0::IdentityV0::default()); + let managed = ManagedIdentity::new(identity, 0); + let handle = MANAGED_IDENTITY_STORAGE.insert(managed); + + // Non-null sentinel so we can assert the getter resets it to + // the empty sentinel; the pointer is never dereferenced. + let mut array = DashpayPaymentArray { + items: std::ptr::NonNull::::dangling().as_ptr(), + count: 99, + }; + let r = unsafe { managed_identity_get_dashpay_payments(handle, &mut array) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::Success); + assert!(array.items.is_null()); + assert_eq!(array.count, 0); + + let _ = MANAGED_IDENTITY_STORAGE.remove(handle); + } + + /// Unknown handle → `NotFound`, and the out-array is left at the + /// empty sentinel rather than carrying stale caller-supplied junk. + #[test] + fn get_dashpay_payments_unknown_handle_is_not_found() { + // Non-null sentinel so we can assert the getter resets it to + // the empty sentinel; the pointer is never dereferenced. + let mut array = DashpayPaymentArray { + items: std::ptr::NonNull::::dangling().as_ptr(), + count: 99, + }; + let r = unsafe { managed_identity_get_dashpay_payments(0xDEAD_BEEF, &mut array) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + // Reset to the empty sentinel before the handle lookup failed. + assert!(array.items.is_null()); + assert_eq!(array.count, 0); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs b/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs index 43534e966d1..d4a5f0f492d 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs @@ -75,7 +75,9 @@ fn option_string_to_c(s: Option<&str>) -> *mut c_char { } } -unsafe fn decode_opt_c_str(ptr: *const c_char) -> Result, PlatformWalletFFIResult> { +pub(crate) unsafe fn decode_opt_c_str( + ptr: *const c_char, +) -> Result, PlatformWalletFFIResult> { if ptr.is_null() { return Ok(None); } @@ -142,6 +144,46 @@ pub unsafe extern "C" fn platform_wallet_get_dashpay_profile( PlatformWalletFFIResult::ok() } +/// Read the cached profile of a **contact** (by contact identity id) under +/// the given owner identity. `out_has_profile` is false when the owner has no +/// cached entry for that contact, or the entry is confirmed-absent (the +/// contact published no profile on Platform). Populated by the background +/// contact-profile sync; covers established contacts and pending senders. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_get_contact_profile( + wallet_handle: Handle, + owner_identity_id: *const u8, + contact_identity_id: *const u8, + out_profile: *mut DashPayProfileFFI, + out_has_profile: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_profile); + check_ptr!(out_has_profile); + + let owner = unwrap_result_or_return!(unsafe { read_identifier(owner_identity_id) }); + let contact = unwrap_result_or_return!(unsafe { read_identifier(contact_identity_id) }); + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let wm = wallet.wallet_manager().blocking_read(); + let info = wm.get_wallet_info(&wallet.wallet_id())?; + info.identity_manager + .managed_identity(&owner) + .and_then(|m| m.contact_profiles.get(&contact).cloned()) + }); + let entry = unwrap_option_or_return!(option); + match entry.and_then(|e| e.profile) { + Some(profile) => unsafe { + *out_profile = DashPayProfileFFI::from_profile(&profile); + *out_has_profile = true; + }, + None => unsafe { + *out_profile = DashPayProfileFFI::empty(); + *out_has_profile = false; + }, + } + PlatformWalletFFIResult::ok() +} + /// Release strings owned by a [`DashPayProfileFFI`]. #[no_mangle] pub unsafe extern "C" fn dashpay_profile_ffi_free(profile: *mut DashPayProfileFFI) { diff --git a/packages/rs-platform-wallet-ffi/src/dashpay_sync.rs b/packages/rs-platform-wallet-ffi/src/dashpay_sync.rs new file mode 100644 index 00000000000..50ea7bb2e92 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dashpay_sync.rs @@ -0,0 +1,249 @@ +//! FFI bindings for `PlatformWalletManager`'s recurring DashPay +//! (contact-request + profile) sync coordinator. +//! +//! Mirrors the shape of [`crate::identity_sync`] / +//! [`crate::platform_address_sync`]: lifecycle controls (`start` / +//! `stop` / `is_running` / `is_syncing` / `last_sync_unix_seconds` / +//! `set_interval` / `sync_now`). Unlike the identity-token coordinator +//! the DashPay sweep is **wallet-driven, not registry-driven** (see +//! [`DashPaySyncManager`](platform_wallet::manager::dashpay_sync::DashPaySyncManager)), +//! so there is no per-identity registry surface here — every registered +//! wallet is swept on every pass. +//! +//! `sync_now` differs from the identity/shielded `sync_now` in one way: +//! the underlying [`DashPaySyncManager::sync_now`] returns a +//! [`DashPaySyncSummary`], so this entry point surfaces the per-pass +//! success / error counts and completion timestamp through out-params +//! (the host's "Sync Now" button can report "synced N wallets"). All +//! three out-params are optional — pass null to ignore any of them. +//! +//! Not auto-started — exactly like the sibling coordinators. The Swift +//! lifecycle calls [`platform_wallet_manager_dashpay_sync_start`] once +//! the wallets are registered and the SDK is connected; the on-demand +//! `sync_now` entry point stays available for pull-to-refresh. +//! +//! Error handling follows the same shape as the rest of this crate: +//! every entry point returns a `PlatformWalletFFIResult`; null `Handle` +//! lookups surface through `unwrap_option_or_return!` and out-pointer +//! validation through `check_ptr!`. + +use std::time::Duration; + +use crate::error::*; +use crate::handle::*; +use crate::runtime::{block_on_worker, runtime}; +use crate::{check_ptr, unwrap_option_or_return}; + +/// Start the recurring DashPay sync loop in the background. Idempotent +/// — calling while already running is a no-op. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dashpay_sync_start( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + let _entered = runtime().enter(); + manager.dashpay_sync_arc().start(); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Stop the recurring DashPay sync loop if it is running. +/// +/// Cancel-only: a pass already inside `sync_now` keeps running to +/// completion. Manager shutdown uses the Rust-side `quiesce` barrier; +/// the host does not need to. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dashpay_sync_stop( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager.dashpay_sync().stop(); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Whether the recurring DashPay sync background loop is running. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dashpay_sync_is_running( + handle: Handle, + out_running: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_running); + + let option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| manager.dashpay_sync().is_running()); + let running = unwrap_option_or_return!(option); + *out_running = running; + PlatformWalletFFIResult::ok() +} + +/// Whether a DashPay sync pass is currently in flight. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dashpay_sync_is_syncing( + handle: Handle, + out_syncing: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_syncing); + + let option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| manager.dashpay_sync().is_syncing()); + let syncing = unwrap_option_or_return!(option); + *out_syncing = syncing; + PlatformWalletFFIResult::ok() +} + +/// Unix seconds of the last completed DashPay sync pass, or 0 if no +/// pass has ever completed. +/// +/// Unlike the identity-token coordinator this watermark is global (one +/// last-sync per manager, not per-identity), matching the +/// wallet-driven sweep. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dashpay_sync_last_sync_unix_seconds( + handle: Handle, + out_last_sync_unix: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_last_sync_unix); + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager.dashpay_sync().last_sync_unix_seconds() + }); + let value = unwrap_option_or_return!(option); + *out_last_sync_unix = value.unwrap_or(0); + PlatformWalletFFIResult::ok() +} + +/// Set the background DashPay sync interval in seconds. +/// +/// Clamped to a minimum of 1s on the Rust side; the running loop picks +/// up the new interval on its next sleep. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dashpay_sync_set_interval( + handle: Handle, + interval_seconds: u64, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager + .dashpay_sync() + .set_interval(Duration::from_secs(interval_seconds)); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Run one DashPay sync pass across every registered wallet. +/// +/// Synchronous from the FFI caller's point of view — blocks the +/// calling thread until the pass completes. If a pass is already in +/// flight (e.g. fired by the background loop), the underlying manager +/// skips and returns an empty summary immediately; this function then +/// reports `*out_success_count == 0`, `*out_error_count == 0`, and +/// `*out_sync_unix_seconds == 0` (the "no pass ran" sentinel). Check +/// `is_syncing` if the caller needs to distinguish "skipped" from +/// "swept zero wallets". +/// +/// All three out-params are optional — pass null to ignore any of +/// them: +/// * `out_success_count`: wallets whose `dashpay_sync()` succeeded. +/// * `out_error_count`: wallets whose `dashpay_sync()` failed (logged +/// Rust-side, non-fatal to the rest of the pass). +/// * `out_sync_unix_seconds`: Unix seconds the pass completed, or `0` +/// if no pass ran. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dashpay_sync_sync_now( + handle: Handle, + out_success_count: *mut usize, + out_error_count: *mut usize, + out_sync_unix_seconds: *mut u64, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + let mgr = manager.dashpay_sync_arc(); + // `block_on_worker`, NOT `runtime().block_on`: the sync pass + // verifies GroveDB document-query proofs whose recursion blows + // the ~512 KB stack of the iOS calling thread (SIGBUS observed + // on-device 2026-06-12). The worker dispatch moves the compute + // onto the runtime's 8 MB-stack threads (see runtime.rs). + block_on_worker(async move { mgr.sync_now().await }) + }); + let summary = unwrap_option_or_return!(option); + + if !out_success_count.is_null() { + *out_success_count = summary.success_count(); + } + if !out_error_count.is_null() { + *out_error_count = summary.error_count(); + } + if !out_sync_unix_seconds.is_null() { + *out_sync_unix_seconds = summary.sync_unix_seconds; + } + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every DashPay-sync entry point must reject an unknown `Handle` + /// with `NotFound` rather than dereferencing a stale slot — the + /// `unwrap_option_or_return!` contract every other coordinator's + /// FFI upholds. Pins the null-handle path for all seven calls. + #[test] + fn unknown_handle_returns_not_found() { + let bogus: Handle = 0xDEAD_BEEF; + + let r = unsafe { platform_wallet_manager_dashpay_sync_start(bogus) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let r = unsafe { platform_wallet_manager_dashpay_sync_stop(bogus) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let mut running = true; + let r = unsafe { platform_wallet_manager_dashpay_sync_is_running(bogus, &mut running) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let mut syncing = true; + let r = unsafe { platform_wallet_manager_dashpay_sync_is_syncing(bogus, &mut syncing) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let mut last = 123u64; + let r = unsafe { + platform_wallet_manager_dashpay_sync_last_sync_unix_seconds(bogus, &mut last) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let r = unsafe { platform_wallet_manager_dashpay_sync_set_interval(bogus, 30) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let mut ok = 7usize; + let mut err = 7usize; + let mut ts = 7u64; + let r = unsafe { + platform_wallet_manager_dashpay_sync_sync_now(bogus, &mut ok, &mut err, &mut ts) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + } + + /// Null out-pointers on the reader entry points must be rejected + /// with `ErrorNullPointer` (the `check_ptr!` contract) before the + /// handle is even looked up — guarding against a host that forgets + /// to pass storage for a required scalar out-param. + #[test] + fn null_required_out_pointers_are_rejected() { + let bogus: Handle = 1; + + let r = + unsafe { platform_wallet_manager_dashpay_sync_is_running(bogus, std::ptr::null_mut()) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let r = + unsafe { platform_wallet_manager_dashpay_sync_is_syncing(bogus, std::ptr::null_mut()) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let r = unsafe { + platform_wallet_manager_dashpay_sync_last_sync_unix_seconds(bogus, std::ptr::null_mut()) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/established_contact.rs b/packages/rs-platform-wallet-ffi/src/established_contact.rs index d38e9511ec5..e1fd23e1387 100644 --- a/packages/rs-platform-wallet-ffi/src/established_contact.rs +++ b/packages/rs-platform-wallet-ffi/src/established_contact.rs @@ -216,6 +216,28 @@ pub unsafe extern "C" fn established_contact_is_hidden( PlatformWalletFFIResult::ok() } +/// Check whether an established contact's DashPay payment channel is +/// permanently broken. +/// +/// `true` means the account-building sweep hit a permanent failure +/// (decrypt/decode of the counterparty xpub, or a key-index validation +/// failure) and stopped retrying. The UI should disable "Send Dash" and +/// surface "Payment channel broken — ask the contact to send a new +/// request"; the flag clears automatically when a superseding contact +/// request (re-)establishes the relationship. +#[no_mangle] +pub unsafe extern "C" fn established_contact_is_payment_channel_broken( + contact_handle: Handle, + out_is_broken: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_is_broken); + + let option = ESTABLISHED_CONTACT_STORAGE + .with_item(contact_handle, |contact| contact.payment_channel_broken); + *out_is_broken = unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + /// Hide an established contact from the contact list #[no_mangle] pub unsafe extern "C" fn established_contact_hide( diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 03065120b7b..60428b072f9 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -141,21 +141,92 @@ pub struct IdentityEntryFFI { /// [`free_identity_entry_ffi`]. Ignore unless /// [`Self::dashpay_profile_present`] is `true`. pub dashpay_profile_public_message: *const c_char, + /// Heap-allocated array of [`ContactProfileRowFFI`], one per + /// **present** cached contact profile on the underlying + /// [`IdentityEntry::contact_profiles`]. Confirmed-absent entries + /// (`profile: None`, the negative cache) are NOT projected — they + /// rebuild harmlessly on the next sync sweep, so persisting them + /// would only add write churn. Each row owns the same per-string + /// heap allocations the own-profile block does; every string plus + /// the outer boxed slice is released in [`free_identity_entry_ffi`]. + /// `null` when [`Self::contact_profiles_count`] is 0. + /// + /// Distinct from `dashpay_profile_*` above: that block is the + /// owner's *own* profile (one per identity); this array is the + /// *contacts'* profiles, keyed by each contact's identity id. They + /// land in separate SwiftData stores on the Swift side. + pub contact_profiles: *const ContactProfileRowFFI, + /// Number of rows pointed at by [`Self::contact_profiles`]. `0` + /// when the identity has no present cached contact profiles. + pub contact_profiles_count: usize, +} + +/// Flat C mirror of one **present** cached contact profile — +/// `(contact_id, DashPayProfile, checked_at_ms)` — projected from a +/// single entry of [`IdentityEntry::contact_profiles`]. +/// +/// The profile-field block (`display_name` … `public_message`) is the +/// SAME shape as the own-profile fields on [`IdentityEntryFFI`]; the +/// only additions are the leading `contact_id` key and the trailing +/// `checked_at_ms` self-heal timestamp. Confirmed-absent cache entries +/// never reach this struct (see [`IdentityEntryFFI::contact_profiles`]). +/// +/// All four `*const c_char` strings are heap-allocated via +/// [`optional_c_string`] and owned by the parent [`IdentityEntryFFI`]; +/// they are released row-by-row in [`free_identity_entry_ffi`] before +/// the outer boxed slice drops. Gate the byte-array fields on their +/// paired `_present` flag — `[0u8; N]` is a valid (if unlikely) hash / +/// fingerprint value. +#[repr(C)] +pub struct ContactProfileRowFFI { + /// The contact's 32-byte identity id — the + /// [`IdentityEntry::contact_profiles`] map key. Becomes the + /// `contactIdentityId` half of the SwiftData row's compound key. + pub contact_id: [u8; 32], + /// Heap-allocated `displayName`; `null` when the source field was + /// `None`. Freed in [`free_identity_entry_ffi`]. + pub display_name: *const c_char, + /// Heap-allocated `bio`; `null` when `None`. Freed in + /// [`free_identity_entry_ffi`]. + pub bio: *const c_char, + /// Heap-allocated `avatarUrl`; `null` when `None`. Freed in + /// [`free_identity_entry_ffi`]. + pub avatar_url: *const c_char, + /// SHA-256 avatar hash; zeroed when [`Self::avatar_hash_present`] + /// is `false`. + pub avatar_hash: [u8; 32], + /// `true` iff the source `avatar_hash` was `Some(_)`. + pub avatar_hash_present: bool, + /// DHash avatar fingerprint; zeroed when + /// [`Self::avatar_fingerprint_present`] is `false`. + pub avatar_fingerprint: [u8; 8], + /// `true` iff the source `avatar_fingerprint` was `Some(_)`. + pub avatar_fingerprint_present: bool, + /// Heap-allocated `publicMessage`; `null` when `None`. Freed in + /// [`free_identity_entry_ffi`]. + pub public_message: *const c_char, + /// Wall-clock ms of the last fetch attempt — the + /// [`ContactProfileEntry::checked_at_ms`] self-heal timestamp. + pub checked_at_ms: u64, } /// Flat C mirror of [`IdentityKeyEntry`] for forwarding across FFI. /// -/// No private-key bytes cross this boundary — the client receives the -/// DPP public key plus a `(wallet_id, identity_index, key_index)` -/// breadcrumb and is expected to re-derive the private half locally -/// from the owning wallet's mnemonic. `public_key_hash` is the -/// precomputed RIPEMD160(SHA256) of the pubkey so clients without a -/// RIPEMD-160 implementation can still round-trip it into the keychain. +/// For a key this wallet's seed reproduced under discovery's verify gate, +/// the already-verified 32-byte ECDSA scalar rides in [`Self::private_key`] +/// (flagged by [`Self::private_key_is_some`]) so the client stores it +/// directly without re-deriving from a mnemonic. For a watch-only key the +/// scalar is absent (`private_key_is_some == false`, buffer zeroed) and the +/// client uses the `(wallet_id, identity_index, key_index)` breadcrumb only +/// for the keychain account label. `public_key_hash` is the precomputed +/// RIPEMD160(SHA256) of the pubkey so clients without a RIPEMD-160 +/// implementation can still round-trip it into the keychain. /// /// `public_key_data_ptr` / `public_key_data_len` own a heap-allocated /// copy of the public-key bytes (compressed secp256k1 for ECDSA, hash /// for hash160, etc. — depends on `key_type`). Released by -/// [`free_identity_key_entry_ffi`]. +/// [`free_identity_key_entry_ffi`], which also zeroes the `private_key` +/// buffer unconditionally. #[repr(C)] pub struct IdentityKeyEntryFFI { pub identity_id: [u8; 32], @@ -219,6 +290,17 @@ pub struct IdentityKeyEntryFFI { pub contract_bounds_kind: u8, pub contract_bounds_id: [u8; 32], pub contract_bounds_document_type: *const c_char, + + // Verified private scalar. When `private_key_is_some` is true, + // `private_key` holds the already-verified 32-byte ECDSA secret that + // reproduces this key's on-chain public key; the client stores it + // directly (no mnemonic re-derive). When false the buffer is zeroed + // and the key is watch-only. Placed last to keep the rest of the + // layout stable. `free_identity_key_entry_ffi` zeroes `private_key` + // unconditionally (volatile), so the secret never lingers after the + // callback's copy. + pub private_key_is_some: bool, + pub private_key: [u8; 32], } /// Composite identifier for [`IdentityKeysChangeSet::removed`] entries @@ -231,12 +313,14 @@ pub struct IdentityKeyRemovalFFI { pub key_id: u32, } -// Compile-time guard — if anyone reshapes `IdentityKeyEntryFFI` -// without also updating the Swift mirror in -// `PlatformWalletFFI.swift`, cargo builds fail with an obvious -// error rather than producing a dylib that the Swift side will -// mis-parse at runtime (which surfaces as a random EXC_BAD_ACCESS -// in the persistIdentityKeys callback). +// Compile-time guard — if anyone reshapes `IdentityKeyEntryFFI`, cargo +// builds fail with an obvious size mismatch here rather than producing a +// dylib the Swift side mis-parses at runtime (which surfaces as a random +// EXC_BAD_ACCESS in the persistIdentityKeys callback). There is no +// hand-maintained Swift mirror struct: cbindgen regenerates the C header +// from this definition at build time (`build.rs`), so Swift auto-sees the +// fields after the framework is rebuilt; only `persistIdentityKeysCallback` +// reads them. // // Expected layout on 64-bit targets (all fields in declaration // order under `#[repr(C)]`): @@ -263,9 +347,12 @@ pub struct IdentityKeyRemovalFFI { // 137..=168 contract_bounds_id [u8; 32] // 169..=175 (padding to 8 for pointer alignment) // 176..=183 contract_bounds_document_type *const c_char +// 184 private_key_is_some bool +// 185..=216 private_key [u8; 32] +// 217..=223 (trailing padding to 8 for struct alignment) // -// Total size = 184, alignment = 8 (from u64 / pointer). -const _: [u8; 184] = [0u8; std::mem::size_of::()]; +// Total size = 224, alignment = 8 (from u64 / pointer). +const _: [u8; 224] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // Compile-time guard for `IdentityEntryFFI`. Same rationale as the @@ -302,9 +389,11 @@ const _: [u8; 8] = [0u8; std::mem::align_of::()]; // 193 dashpay_profile_avatar_fingerprint_present bool // 194..=199 (padding to 8 for pointer alignment) // 200..=207 dashpay_profile_public_message *const c_char +// 208..=215 contact_profiles *const ContactProfileRowFFI +// 216..=223 contact_profiles_count usize // -// Total size = 208, alignment = 8 (from u64 / pointer). -const _: [u8; 208] = [0u8; std::mem::size_of::()]; +// Total size = 224, alignment = 8 (from u64 / pointer). +const _: [u8; 224] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // --------------------------------------------------------------------------- @@ -344,6 +433,9 @@ impl IdentityEntryFFI { None => DashPayProfileFields::absent(), }; + let (contact_profiles, contact_profiles_count) = + allocate_contact_profile_rows(&entry.contact_profiles); + Self { identity_id: entry.id.to_buffer(), balance: entry.balance, @@ -365,6 +457,8 @@ impl IdentityEntryFFI { dashpay_profile_avatar_fingerprint: profile_fields.avatar_fingerprint, dashpay_profile_avatar_fingerprint_present: profile_fields.avatar_fingerprint_present, dashpay_profile_public_message: profile_fields.public_message, + contact_profiles, + contact_profiles_count, } } } @@ -483,6 +577,69 @@ fn allocate_dpns_arrays( (labels_ptr, acquired_ptr, count) } +/// Allocate the [`ContactProfileRowFFI`] array carried on +/// [`IdentityEntryFFI`] from the source +/// [`IdentityEntry::contact_profiles`] map. Returns `(rows, count)` — +/// both `null`/`0` when no entry carries a **present** profile. +/// +/// **Present profiles only.** Confirmed-absent entries +/// (`ContactProfileEntry::profile == None`, the negative cache) are +/// skipped: they rebuild harmlessly on the next sync sweep, so +/// persisting them would only add write churn (the "persist only on +/// change" discipline). The returned `count` +/// is therefore the number of *present* profiles, not the map length. +/// +/// `rows` is a `Box<[ContactProfileRowFFI]>` (via [`Box::into_raw`]). +/// Each row's four nullable C-strings are [`CString::into_raw`] +/// pointers — every one must be released with `CString::from_raw` +/// before the outer slice drops. [`free_identity_entry_ffi`] does this +/// row-by-row, mirroring the DPNS label-array free path exactly. +fn allocate_contact_profile_rows( + contact_profiles: &std::collections::BTreeMap< + dpp::prelude::Identifier, + platform_wallet::ContactProfileEntry, + >, +) -> (*const ContactProfileRowFFI, usize) { + if contact_profiles.is_empty() { + return (ptr::null(), 0); + } + let mut rows: Vec = Vec::with_capacity(contact_profiles.len()); + for (contact_id, entry) in contact_profiles { + // Skip confirmed-absent entries — the negative cache is not + // persisted; it rebuilds on the next sweep. + let Some(profile) = entry.profile.as_ref() else { + continue; + }; + let (avatar_hash, avatar_hash_present) = match profile.avatar_hash { + Some(h) => (h, true), + None => ([0u8; 32], false), + }; + let (avatar_fingerprint, avatar_fingerprint_present) = match profile.avatar_fingerprint { + Some(f) => (f, true), + None => ([0u8; 8], false), + }; + rows.push(ContactProfileRowFFI { + contact_id: contact_id.to_buffer(), + display_name: optional_c_string(profile.display_name.as_deref()), + bio: optional_c_string(profile.bio.as_deref()), + avatar_url: optional_c_string(profile.avatar_url.as_deref()), + avatar_hash, + avatar_hash_present, + avatar_fingerprint, + avatar_fingerprint_present, + public_message: optional_c_string(profile.public_message.as_deref()), + checked_at_ms: entry.checked_at_ms, + }); + } + if rows.is_empty() { + // Every entry was confirmed-absent — nothing present to carry. + return (ptr::null(), 0); + } + let count = rows.len(); + let rows_ptr = Box::into_raw(rows.into_boxed_slice()) as *const ContactProfileRowFFI; + (rows_ptr, count) +} + impl IdentityKeyEntryFFI { /// Copy an [`IdentityKeyEntry`] into a fresh FFI struct. The /// caller owns the heap-allocated `public_key_data_ptr` byte @@ -514,6 +671,14 @@ impl IdentityKeyEntryFFI { None => (false, 0, 0), }; + // Carry the verified scalar by value when present; zero the buffer + // otherwise. The callback copies the bytes out and the post-callback + // `free_identity_key_entry_ffi` loop scrubs this buffer. + let (private_key_is_some, private_key) = match &entry.private_key { + Some(scalar) => (true, **scalar), + None => (false, [0u8; 32]), + }; + // Project the DPP `ContractBounds` enum into the kind / // id / doc-type-cstring trio so the Swift side can switch // on a single discriminant. Strings containing interior @@ -558,6 +723,8 @@ impl IdentityKeyEntryFFI { contract_bounds_kind, contract_bounds_id, contract_bounds_document_type, + private_key_is_some, + private_key, } } } @@ -581,9 +748,11 @@ fn status_discriminant(status: IdentityStatus) -> u8 { /// Release heap allocations owned by an [`IdentityEntryFFI`] — /// the DPNS label C-string array (each entry plus the outer boxed -/// slice), the parallel `acquired_at` timestamp array, and (when +/// slice), the parallel `acquired_at` timestamp array, (when /// [`IdentityEntryFFI::dashpay_profile_present`] is true) the -/// per-string profile C-strings. +/// own-profile per-string C-strings, and the cached contact-profile +/// row array (each row's four per-string C-strings plus the outer +/// boxed slice). /// /// Idempotent: pointers are nulled, the `_present` flag is reset, /// and counts are zeroed after release, so a second call is a no-op. @@ -644,6 +813,31 @@ pub unsafe fn free_identity_entry_ffi(entry: &mut IdentityEntryFFI) { entry.dashpay_profile_avatar_fingerprint_present = false; entry.dashpay_profile_present = false; } + + // Release the cached contact-profile rows. Mirrors the DPNS + // label-array free path: reconstruct the `Box<[ContactProfileRowFFI]>` + // we created via `Box::into_raw`, walk every row to release its four + // per-string `CString`s, then drop the outer slice. Each string was + // produced by `optional_c_string` (`CString::into_raw`) so it MUST be + // reclaimed with `CString::from_raw` — the byte arrays are inline and + // need no free. + if !entry.contact_profiles.is_null() && entry.contact_profiles_count > 0 { + let rows = unsafe { + std::slice::from_raw_parts_mut( + entry.contact_profiles as *mut ContactProfileRowFFI, + entry.contact_profiles_count, + ) + }; + for row in rows.iter_mut() { + free_optional_c_string(&mut row.display_name); + free_optional_c_string(&mut row.bio); + free_optional_c_string(&mut row.avatar_url); + free_optional_c_string(&mut row.public_message); + } + let _ = unsafe { Box::from_raw(rows as *mut [ContactProfileRowFFI]) }; + entry.contact_profiles = ptr::null(); + } + entry.contact_profiles_count = 0; } /// Release a heap-allocated C string produced by @@ -696,9 +890,13 @@ pub unsafe fn free_identity_key_entry_ffi(entry: &mut IdentityKeyEntryFFI) { let _ = unsafe { CString::from_raw(entry.contract_bounds_document_type as *mut c_char) }; entry.contract_bounds_document_type = ptr::null(); } - // No private-key heap allocations to reclaim — the new FFI shape - // carries only scalar derivation breadcrumbs, not an owned path - // string or key-material buffer. + // Scrub the inline private scalar with a volatile write so the + // optimizer cannot elide it as a dead store (the codebase replaced + // non-volatile `*byte = 0` scrubs for exactly this reason). Done + // unconditionally: the 32 bytes are present in the buffer even when + // `private_key_is_some == false`, so they must be wiped either way. + zeroize::Zeroize::zeroize(&mut entry.private_key); + entry.private_key_is_some = false; } #[cfg(test)] @@ -727,6 +925,8 @@ mod tests { wallet_id: Some([9u8; 32]), dashpay_profile: None, dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), }; let mut ffi = IdentityEntryFFI::from_entry(&entry); assert_eq!(ffi.identity_id, [7u8; 32]); @@ -768,6 +968,8 @@ mod tests { wallet_id: None, dashpay_profile: None, dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), }; let mut ffi = IdentityEntryFFI::from_entry(&entry); assert_eq!(ffi.dpns_names_count, 2); @@ -825,6 +1027,8 @@ mod tests { public_message: None, }), dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), }; let mut ffi = IdentityEntryFFI::from_entry(&entry); assert!(ffi.dashpay_profile_present); @@ -872,6 +1076,8 @@ mod tests { wallet_id: None, dashpay_profile: None, dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), }; let mut ffi = IdentityEntryFFI::from_entry(&entry); assert!(!ffi.wallet_id_is_some); @@ -904,6 +1110,8 @@ mod tests { identity_index: 3, key_index: 5, }), + // A verified scalar to materialize. + private_key: Some(zeroize::Zeroizing::new([0xC7; 32])), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.identity_id, [2u8; 32]); @@ -923,8 +1131,14 @@ mod tests { assert!(ffi.derivation_indices_is_some); assert_eq!(ffi.identity_index, 3); assert_eq!(ffi.key_index, 5); + // The verified scalar rides by value, flagged present. + assert!(ffi.private_key_is_some); + assert_eq!(ffi.private_key, [0xC7; 32]); unsafe { free_identity_key_entry_ffi(&mut ffi) }; assert!(ffi.public_key_data_ptr.is_null()); + // Free scrubs the secret and clears the flag. + assert_eq!(ffi.private_key, [0u8; 32], "free must zero the scalar"); + assert!(!ffi.private_key_is_some); } #[test] @@ -946,6 +1160,7 @@ mod tests { public_key_hash: [0x00; 20], wallet_id: None, derivation_indices: None, + private_key: None, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert!(!ffi.wallet_id_is_some); @@ -955,7 +1170,18 @@ mod tests { assert_eq!(ffi.disabled_at, 1_700_000_000); assert_eq!(ffi.contract_bounds_kind, 0); assert!(ffi.contract_bounds_document_type.is_null()); + // A watch-only key carries no secret — flag false, buffer zeroed. + assert!(!ffi.private_key_is_some); + assert_eq!(ffi.private_key, [0u8; 32]); + // `free` scrubs the buffer even when the flag is false: the 32 + // bytes are physically present regardless, so a residual value + // (set here directly to simulate a leftover) must still be wiped. + ffi.private_key = [0xEE; 32]; unsafe { free_identity_key_entry_ffi(&mut ffi) }; + assert_eq!( + ffi.private_key, [0u8; 32], + "free must zero the scalar even when the flag is false" + ); } #[test] @@ -979,6 +1205,7 @@ mod tests { public_key_hash: [0x11; 20], wallet_id: None, derivation_indices: None, + private_key: None, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 1); @@ -1011,6 +1238,7 @@ mod tests { public_key_hash: [0x22; 20], wallet_id: None, derivation_indices: None, + private_key: None, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 2); diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 74897c6df09..a2b0bf8aa76 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -12,13 +12,16 @@ pub mod asset_lock; pub mod asset_lock_persistence; pub mod contact; +pub mod contact_info; pub mod contact_persistence; pub mod contact_request; pub mod core_address_types; pub mod core_wallet; pub mod core_wallet_types; pub mod dashpay; +pub mod dashpay_payment; pub mod dashpay_profile; +pub mod dashpay_sync; pub mod data_contract; pub mod derivation; pub mod derive_and_persist_callbacks; @@ -84,7 +87,9 @@ pub use core_address_types::*; pub use core_wallet::*; pub use core_wallet_types::*; pub use dashpay::*; +pub use dashpay_payment::*; pub use dashpay_profile::*; +pub use dashpay_sync::*; pub use data_contract::*; pub use derivation::*; pub use derive_and_persist_callbacks::*; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 5930c1c4db6..ffe617b865b 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -105,7 +105,9 @@ unsafe fn create_wallet_from_seed_impl( let network: Network = network.into(); - let mut seed = [0u8; 64]; + // Zeroize the FFI-boundary copy of the master secret on drop. Passed by + // reference so the manager method doesn't take an un-zeroized owned copy. + let mut seed = zeroize::Zeroizing::new([0u8; 64]); std::ptr::copy_nonoverlapping(seed_bytes, seed.as_mut_ptr(), 64); let accounts = match account_options { @@ -117,7 +119,7 @@ unsafe fn create_wallet_from_seed_impl( let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { runtime().block_on(manager.create_wallet_from_seed_bytes( network, - seed, + &seed, accounts, birth_height_override, )) @@ -415,4 +417,5 @@ mod tests { assert_eq!(birth_height_override_opt(false, 0), None); assert_eq!(birth_height_override_opt(false, 99), None); } + } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 86b5561518c..9a979c7bbfb 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -34,7 +34,7 @@ use crate::asset_lock_persistence::{ build_asset_lock_entries, outpoint_to_bytes, AssetLockEntryFFI, }; use crate::contact_persistence::{ - free_contact_requests_ffi, ContactRequestFFI, ContactRequestRemovalFFI, + free_contact_requests_ffi, ContactIgnoredSenderFFI, ContactRequestFFI, ContactRequestRemovalFFI, }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI}; use crate::core_wallet_types::{free_wallet_changeset_ffi, WalletChangeSetFFI}; @@ -46,9 +46,10 @@ use crate::platform_address_types::AddressBalanceEntryFFI; use crate::token_persistence::{TokenBalanceRemovalFFI, TokenBalanceUpsertFFI}; use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ - AccountSpecFFI, AccountTypeTagFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, - LoadWalletListFreeFn, StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI, - UtxoRestoreEntryFFI, WalletRestoreEntryFFI, + AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI, + IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI, + StandardAccountTypeTagFFI, UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, + WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -272,8 +273,10 @@ pub struct PersistenceCallbacks { ) -> i32, >, /// Called with a flat `ContactChangeSet` projection — sent / - /// incoming / established contact requests in `upserts`, plus - /// parallel sent / incoming tombstone arrays. + /// incoming / established contact requests in `upserts`, parallel + /// sent / incoming removal tombstone arrays, plus an `ignored` + /// per-sender ignore-delta array keyed `(owner, sender)` (each row's + /// `is_ignored` bit says persist vs delete — ignore vs un-ignore). /// /// `ContactChangeSet` is a top-level (not per-identity) /// changeset, but the callback is still wallet-scoped via @@ -299,6 +302,8 @@ pub struct PersistenceCallbacks { removed_sent_count: usize, removed_incoming_ptr: *const ContactRequestRemovalFFI, removed_incoming_count: usize, + ignored_ptr: *const ContactIgnoredSenderFFI, + ignored_count: usize, ) -> i32, >, // ── Shielded (Orchard) persistence ───────────────────────────────── @@ -1055,15 +1060,30 @@ impl PlatformWalletPersistence for FFIPersister { )); } for (key, established) in &contacts_cs.established { - upserts.push(ContactRequestFFI::from_outgoing( + // Replicate the relationship's broken-channel flag + // and owner-private metadata (alias/note/hidden — + // contactInfo, M3) onto BOTH the outgoing and + // incoming row — they are properties of the + // established pair, not of one direction, so the + // Swift handler persists them on each + // `(owner, contact, is_outgoing)` row. + upserts.push(ContactRequestFFI::from_established_outgoing( key.owner_id.to_buffer(), key.recipient_id.to_buffer(), &established.outgoing_request, + established.payment_channel_broken, + established.alias.as_deref(), + established.note.as_deref(), + established.is_hidden, )); - upserts.push(ContactRequestFFI::from_incoming( + upserts.push(ContactRequestFFI::from_established_incoming( key.owner_id.to_buffer(), key.recipient_id.to_buffer(), &established.incoming_request, + established.payment_channel_broken, + established.alias.as_deref(), + established.note.as_deref(), + established.is_hidden, )); } let removed_sent: Vec = contacts_cs @@ -1082,7 +1102,26 @@ impl PlatformWalletPersistence for FFIPersister { contact_id: key.sender_id.to_buffer(), }) .collect(); - if !upserts.is_empty() || !removed_sent.is_empty() || !removed_incoming.is_empty() { + // Per-sender ignore deltas, keyed `(owner, sender)`. The + // `ignored` set projects to rows with `is_ignored == true` + // (persist the ignored-sender row); the `unignored` set to + // rows with `is_ignored == false` (delete it). Both ride a + // single array so the host applies a mixed delta in one + // callback. + let ignored: Vec = + contacts_cs + .ignored + .iter() + .map(|(owner, sender)| ContactIgnoredSenderFFI::new(owner, sender, true)) + .chain(contacts_cs.unignored.iter().map(|(owner, sender)| { + ContactIgnoredSenderFFI::new(owner, sender, false) + })) + .collect(); + if !upserts.is_empty() + || !removed_sent.is_empty() + || !removed_incoming.is_empty() + || !ignored.is_empty() + { let result = unsafe { cb( self.callbacks.context, @@ -1105,6 +1144,12 @@ impl PlatformWalletPersistence for FFIPersister { removed_incoming.as_ptr() }, removed_incoming.len(), + if ignored.is_empty() { + std::ptr::null() + } else { + ignored.as_ptr() + }, + ignored.len(), ) }; // Release every heap-allocated payload before the @@ -1459,6 +1504,20 @@ impl PlatformWalletPersistence for FFIPersister { } // Merge into pending changesets. + // + // The `pending` accumulator is long-lived and never replayed to the + // callbacks (only `flush` consumes it), so it must not hold a second + // copy of the verified scalar. The secret was only needed for the + // synchronous `on_persist_identity_keys` dispatch above, which has + // already run. Strip `private_key` to `None` on every identity-key + // upsert before it enters `pending`, keeping the "no secret at rest + // outside the keychain" guarantee true. + let mut changeset = changeset; + if let Some(keys_cs) = changeset.identity_keys.as_mut() { + for entry in keys_cs.upserts.values_mut() { + entry.private_key = None; + } + } let mut pending = self.pending.write(); pending .entry(wallet_id) @@ -2422,6 +2481,22 @@ fn build_address_pools_for_callback( /// Build a single `CoreAddressEntryFFI` from an `AddressInfo`, /// pushing the owned (address, path) c-strings into `owned_strings` /// so they outlive the callback window. +/// +/// Recover the network an `Address` renders for. `Address` no longer exposes its network +/// directly (the prefix is shared across testnet/devnet and legacy-regtest), but for the +/// bech32m platform-payment addresses rendered here the prefix is decisive, so probing +/// yields the correct HRP (mainnet `ds`, testnet/devnet `tb`, regtest `dsrt`). +fn address_display_network(address: &dashcore::Address) -> dashcore::Network { + let unchecked = address.as_unchecked(); + if unchecked.is_valid_for_network(dashcore::Network::Mainnet) { + dashcore::Network::Mainnet + } else if unchecked.is_valid_for_network(dashcore::Network::Testnet) { + dashcore::Network::Testnet + } else { + dashcore::Network::Regtest + } +} + fn build_core_address_entry_ffi( info: &AddressInfo, pool_type_tag: u8, @@ -2433,15 +2508,7 @@ fn build_core_address_entry_ffi( // PlatformAddress conversion fails (only P2PKH / P2SH supported) // fall back to base58check so the address still surfaces. let rendered_address = if is_platform_payment { - let network = if info - .address - .as_unchecked() - .is_valid_for_network(Network::Mainnet) - { - Network::Mainnet - } else { - Network::Testnet - }; + let network = address_display_network(&info.address); let converted: Result = PlatformAddress::try_from(info.address.clone()); converted .map(|p| p.to_bech32m_string(network)) @@ -2654,15 +2721,7 @@ fn build_address_pools_from_derived( // bech32m; everything else base58check (matching // `build_core_address_entry_ffi`'s logic). let rendered_address = if is_platform_payment { - let network = if d - .address - .as_unchecked() - .is_valid_for_network(Network::Mainnet) - { - Network::Mainnet - } else { - Network::Testnet - }; + let network = address_display_network(&d.address); let converted: Result = PlatformAddress::try_from(d.address.clone()); converted @@ -3421,9 +3480,8 @@ fn build_wallet_start_state( /// side), so the restored `Identity.public_keys` map is populated at /// load time. An identity with no persisted keys (e.g. an in-flight /// registration whose key-persist round hasn't completed) loads with -/// an empty map and gets refreshed on the next sync round — same -/// degraded-but-usable behaviour as before this change for that -/// narrow case. +/// an empty map and gets refreshed on the next sync round — +/// degraded-but-usable for that narrow case. /// Rebuild the `unused_asset_locks` map carried on /// [`ClientWalletStartState`] from the `tracked_asset_locks` slice the /// Swift load callback hands back. Mirrors the encoding used by @@ -3626,12 +3684,349 @@ fn build_wallet_identity_bucket( managed.wallet_id = Some(entry.wallet_id); managed.dpns_names = dpns_names; managed.contested_dpns_names = contested_dpns_names; + unsafe { restore_dashpay_contacts(spec, &identifier, &mut managed) }; + unsafe { restore_dashpay_payments(spec, &mut managed) }; + unsafe { restore_dashpay_ignored(spec, &mut managed) }; + unsafe { restore_contact_profiles(spec, &mut managed) }; bucket.insert(spec.identity_index, managed); } Ok(bucket) } +/// Rebuild the per-identity DashPay payment history (`dashpay_payments`) +/// from the persisted SwiftData rows at load (H1). +/// +/// Without this the in-memory map starts empty and only *Received* +/// entries are re-derived from UTXOs by the reconcile sweep, so *Sent* +/// entries (with their user memos) vanish from the authoritative model +/// on every relaunch. Direct map inserts, NO persister round — the rows +/// ARE the persisted state. +/// +/// # Safety +/// +/// `spec.payments` must be either null or point at `spec.payments_count` +/// valid `PaymentRestoreEntryFFI` rows whose `txid`/`memo` c-strings +/// Swift owns for the duration of the load callback. +unsafe fn restore_dashpay_payments(spec: &IdentityRestoreEntryFFI, managed: &mut ManagedIdentity) { + if spec.payments.is_null() || spec.payments_count == 0 { + return; + } + let rows = slice::from_raw_parts(spec.payments, spec.payments_count); + apply_payment_rows(rows, managed); +} + +/// Rebuild the per-identity ignored-sender set (`ignored_senders`) from +/// the persisted rows at load. +/// +/// Without this the ignore set starts empty on every relaunch, so a +/// previously-ignored sender's still-on-platform immutable +/// `contactRequest` documents re-ingest on the next sync sweep and the +/// ignored sender resurfaces. Direct set inserts, NO persister round — +/// the rows ARE the persisted state. Much simpler than the contact-row +/// restore: each row is a bare 32-byte sender id (the host only persists +/// senders that are currently ignored, so un-ignored ones simply don't +/// appear here). +/// +/// # Safety +/// +/// `spec.ignored_senders` must be either null or point at +/// `spec.ignored_senders_count` valid `[u8; 32]` id arrays. +unsafe fn restore_dashpay_ignored(spec: &IdentityRestoreEntryFFI, managed: &mut ManagedIdentity) { + if spec.ignored_senders.is_null() || spec.ignored_senders_count == 0 { + return; + } + let rows = slice::from_raw_parts(spec.ignored_senders, spec.ignored_senders_count); + apply_ignored_rows(rows, managed); +} + +/// Fold a slice of 32-byte sender ids into `managed.ignored_senders`. +/// Split out from [`restore_dashpay_ignored`] so the decode is +/// unit-testable without a full `IdentityRestoreEntryFFI`. +fn apply_ignored_rows(rows: &[[u8; 32]], managed: &mut ManagedIdentity) { + for row in rows { + managed.ignored_senders.insert(Identifier::from(*row)); + } +} + +/// Fold a slice of [`PaymentRestoreEntryFFI`] rows into +/// `managed.dashpay_payments`. Split out from [`restore_dashpay_payments`] +/// so the discriminant mapping + c-string decode is unit-testable +/// without a full `IdentityRestoreEntryFFI`. +/// +/// # Safety +/// Each row's `txid`/`memo` pointers must be null or point at valid +/// NUL-terminated c-strings for the call's duration. +unsafe fn apply_payment_rows(rows: &[PaymentRestoreEntryFFI], managed: &mut ManagedIdentity) { + use platform_wallet::wallet::identity::{PaymentDirection, PaymentEntry, PaymentStatus}; + + for row in rows { + if row.txid.is_null() { + continue; + } + let txid = match std::ffi::CStr::from_ptr(row.txid).to_str() { + Ok(s) => s.to_string(), + Err(_) => continue, + }; + let direction = match row.direction_raw { + 0 => PaymentDirection::Sent, + 1 => PaymentDirection::Received, + other => { + tracing::warn!( + direction = other, + "skipping payment row with unknown direction" + ); + continue; + } + }; + let status = match row.status_raw { + 0 => PaymentStatus::Pending, + 1 => PaymentStatus::Confirmed, + 2 => PaymentStatus::Failed, + other => { + tracing::warn!(status = other, "skipping payment row with unknown status"); + continue; + } + }; + let memo = if row.memo.is_null() { + None + } else { + CStr::from_ptr(row.memo).to_str().ok().map(str::to_string) + }; + managed.dashpay_payments.insert( + txid, + PaymentEntry { + counterparty_id: Identifier::from(row.counterparty_id), + amount_duffs: row.amount_duffs, + memo, + direction, + status, + }, + ); + } +} + +/// Rebuild the per-identity cached **contact** profiles +/// (`contact_profiles`) from the persisted SwiftData rows at load. +/// +/// Without this the contact-profile cache starts empty on every +/// relaunch, so the requests/contacts UI shows raw identity ids until +/// the next profile sweep re-fetches every contact — a visible +/// cold-start flicker plus write amplification. Direct map inserts, NO +/// persister round — the rows ARE the persisted state. Only **present** +/// profiles are persisted, so every restored entry rebuilds as +/// `ContactProfileEntry { profile: Some(..), checked_at_ms }`; the +/// confirmed-absent negative cache rebuilds on the next sweep. +/// +/// # Safety +/// +/// `spec.contact_profiles` must be either null or point at +/// `spec.contact_profiles_count` valid [`ContactProfileRestoreEntryFFI`] +/// rows whose four c-strings Swift owns for the duration of the load +/// callback. +unsafe fn restore_contact_profiles(spec: &IdentityRestoreEntryFFI, managed: &mut ManagedIdentity) { + if spec.contact_profiles.is_null() || spec.contact_profiles_count == 0 { + return; + } + let rows = slice::from_raw_parts(spec.contact_profiles, spec.contact_profiles_count); + apply_contact_profile_rows(rows, managed); +} + +/// Maximum cached `avatarUrl` length — mirrors the +/// `MAX_AVATAR_URL_LEN` gate `platform-wallet`'s profile fetch applies +/// before caching (DIP-15's 2048-char cap). +const MAX_AVATAR_URL_LEN: usize = 2048; + +/// Defensive re-validation of a cached `avatarUrl` at restore. The +/// fetch path already dropped non-`https://` / over-length URLs before +/// caching ([`platform_wallet`]'s `is_valid_avatar_url`), but the URL is +/// attacker-controlled public data and the UI will load it, so we +/// re-apply the same `https://`-only, length-capped rule on the way back +/// in. A URL that fails is dropped to `None` (the rest of the profile is +/// still restored) rather than discarding the whole row. +fn is_valid_avatar_url(url: &str) -> bool { + !url.is_empty() && url.len() <= MAX_AVATAR_URL_LEN && url.starts_with("https://") +} + +/// Fold a slice of [`ContactProfileRestoreEntryFFI`] rows into +/// `managed.contact_profiles`. Split out from +/// [`restore_contact_profiles`] so the c-string decode + avatar-url +/// re-validation is unit-testable without a full +/// [`IdentityRestoreEntryFFI`]. +/// +/// # Safety +/// Each row's four string pointers must be null or point at valid +/// NUL-terminated c-strings for the call's duration. +unsafe fn apply_contact_profile_rows( + rows: &[ContactProfileRestoreEntryFFI], + managed: &mut ManagedIdentity, +) { + use platform_wallet::{ContactProfileEntry, DashPayProfile}; + + let opt_string = |ptr: *const std::os::raw::c_char| -> Option { + if ptr.is_null() { + None + } else { + CStr::from_ptr(ptr).to_str().ok().map(str::to_string) + } + }; + + for row in rows { + let avatar_hash = if row.avatar_hash_present { + Some(row.avatar_hash) + } else { + None + }; + let avatar_fingerprint = if row.avatar_fingerprint_present { + Some(row.avatar_fingerprint) + } else { + None + }; + // Re-validate the public, attacker-controlled avatar URL; drop + // just the URL field (keep the rest of the profile) if it no + // longer passes the `https://` / length rule. + let avatar_url = opt_string(row.avatar_url).filter(|u| is_valid_avatar_url(u)); + + managed.contact_profiles.insert( + Identifier::from(row.contact_id), + ContactProfileEntry { + profile: Some(DashPayProfile { + display_name: opt_string(row.display_name), + bio: opt_string(row.bio), + avatar_url, + avatar_hash, + avatar_fingerprint, + public_message: opt_string(row.public_message), + }), + checked_at_ms: row.checked_at_ms, + }, + ); + } +} + +/// Rebuild the per-identity DashPay contact state from the SwiftData +/// contact rows the load callback hands back: pending sent / incoming +/// requests, and established contacts (a pair of rows per contact — +/// one per direction) with their owner-private metadata +/// (alias / note / hidden — contactInfo, M3) and broken-channel flag. +/// +/// Direct map inserts, NO persister rounds — this runs inside `load()` +/// and the rows ARE the persisted state. Without this restore, +/// contacts only re-derive from chain on the first sync sweep, which +/// (a) leaves the Contacts UI empty on offline launches and (b) wipes +/// contactInfo metadata during the DIP-15 deferred-publish window: +/// the re-establish round emitted `alias = None` over the SwiftData +/// rows (the M3 part-3 relaunch-durability gap). +/// +/// # Safety +/// +/// `spec.contacts` must be either null or point at +/// `spec.contacts_count` valid `ContactRequestFFI` rows whose byte +/// buffers and strings Swift owns for the duration of the load +/// callback. +unsafe fn restore_dashpay_contacts( + spec: &IdentityRestoreEntryFFI, + owner_id: &Identifier, + managed: &mut ManagedIdentity, +) { + use platform_wallet::{ContactRequest, EstablishedContact}; + + if spec.contacts.is_null() || spec.contacts_count == 0 { + return; + } + let rows = slice::from_raw_parts(spec.contacts, spec.contacts_count); + + /// Per-contact accumulator while pairing the direction rows. + #[derive(Default)] + struct PairAccumulator { + outgoing: Option, + incoming: Option, + payment_channel_broken: bool, + alias: Option, + note: Option, + is_hidden: bool, + } + + let opt_string = |ptr: *const std::os::raw::c_char| -> Option { + if ptr.is_null() { + None + } else { + Some(std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()) + } + }; + let opt_bytes = |ptr: *const u8, len: usize| -> Option> { + if ptr.is_null() || len == 0 { + None + } else { + Some(slice::from_raw_parts(ptr, len).to_vec()) + } + }; + + let mut by_contact: BTreeMap<[u8; 32], PairAccumulator> = BTreeMap::new(); + for row in rows { + let contact_id = Identifier::from(row.contact_id); + let (sender_id, recipient_id) = if row.is_outgoing { + (*owner_id, contact_id) + } else { + (contact_id, *owner_id) + }; + let mut request = ContactRequest::new( + sender_id, + recipient_id, + row.sender_key_index, + row.recipient_key_index, + row.account_reference, + opt_bytes(row.encrypted_public_key, row.encrypted_public_key_len).unwrap_or_default(), + row.core_height_created_at, + row.created_at, + ); + request.encrypted_account_label = + opt_bytes(row.encrypted_account_label, row.encrypted_account_label_len); + request.auto_accept_proof = opt_bytes(row.auto_accept_proof, row.auto_accept_proof_len); + + let acc = by_contact.entry(row.contact_id).or_default(); + if row.is_outgoing { + acc.outgoing = Some(request); + } else { + acc.incoming = Some(request); + } + // Relationship-level properties are replicated onto both rows + // by the persist projection; OR / first-non-null is the safe + // re-fold. + acc.payment_channel_broken |= row.payment_channel_broken; + acc.is_hidden |= row.is_hidden; + if acc.alias.is_none() { + acc.alias = opt_string(row.alias); + } + if acc.note.is_none() { + acc.note = opt_string(row.note); + } + } + + for (contact_id_bytes, acc) in by_contact { + let contact_id = Identifier::from(contact_id_bytes); + match (acc.outgoing, acc.incoming) { + (Some(outgoing), Some(incoming)) => { + let mut contact = EstablishedContact::new(contact_id, outgoing, incoming); + contact.alias = acc.alias; + contact.note = acc.note; + contact.is_hidden = acc.is_hidden; + contact.payment_channel_broken = acc.payment_channel_broken; + managed.established_contacts.insert(contact_id, contact); + } + (Some(outgoing), None) => { + managed.sent_contact_requests.insert(contact_id, outgoing); + } + (None, Some(incoming)) => { + managed + .incoming_contact_requests + .insert(contact_id, incoming); + } + (None, None) => unreachable!("accumulator entries always hold at least one row"), + } + } +} + /// Translate the `keys` array hanging off an `IdentityRestoreEntryFFI` /// into a `BTreeMap` ready to drop into /// `IdentityV0.public_keys`. @@ -4209,6 +4604,64 @@ mod tests { ManagedWalletInfo::from_wallet(&wallet, 0) } + /// `account_xpub` must survive the persist→restore byte round-trip — it is + /// the key the seed-binding self-check (`PlatformWallet::verify_seed_binds`) + /// later compares the resolver-derived xpub against, so a corrupted restore + /// would silently make a correct seed fail to bind. This drives the exact + /// production chain: the store side bincode-encodes the account xpub + /// (`build_account_specs_for_callback`), and the restore side decodes it from + /// `AccountSpecFFI.account_xpub_bytes` and rebuilds the account via + /// `Account::from_xpub` (`build_wallet_start_state`). Pins that both ends use + /// the same bincode config and that `Account::from_xpub` preserves the xpub. + /// (Asserted here at the FFI persister layer, where the bytes round-trip + /// actually happens — `load_from_persistor` itself only sees decoded structs.) + #[test] + fn account_xpub_survives_persist_restore_round_trip() { + let mnemonic = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + Language::English, + ) + .expect("static BIP-39 vector must parse"); + let seed = mnemonic.to_seed(""); + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .expect("seeded wallet"); + let wallet_id = wallet.wallet_id; + let source = wallet + .get_bip44_account(0) + .expect("a Default-created wallet has BIP44 account 0"); + let account_type = source.account_type.clone(); + let expected_xpub = source.account_xpub; + + // Store side: encode the xpub exactly as the callback producer does. + let xpub_bytes = + bincode::encode_to_vec(expected_xpub, config::standard()).expect("encode account xpub"); + // The C struct the host hands back on restore. + let spec = build_account_spec_ffi(&account_type, &xpub_bytes); + + // Restore side: reconstruct the account type + decode the xpub exactly as + // `build_wallet_start_state` does, then rebuild via `Account::from_xpub`. + let restored_type = + account_type_from_spec(&spec).expect("account type tag round-trips through the spec"); + let raw = unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; + let (decoded_xpub, _): (ExtendedPubKey, usize) = + bincode::decode_from_slice(raw, config::standard()).expect("decode account xpub"); + assert_eq!( + decoded_xpub, expected_xpub, + "the bincode round-trip must preserve the account xpub byte-for-byte" + ); + let restored = + Account::from_xpub(Some(wallet_id), restored_type, decoded_xpub, Network::Testnet) + .expect("Account::from_xpub on the restored xpub must succeed"); + assert_eq!( + restored.account_xpub, expected_xpub, + "the restored account's xpub must equal the original — the key verify_seed_binds binds against" + ); + } + /// Helper: a minimum valid consensus-encodable transaction — /// version 1, one synthetic input, one zero-value output. The /// restoration helper only cares that the bytes round-trip @@ -4231,4 +4684,213 @@ mod tests { special_transaction_payload: None, } } + + /// **H1 — DashPay payment history is restored at load.** + /// The fold must rebuild `dashpay_payments` (Sent AND Received, with + /// memos) from the persisted rows, mapping the direction/status + /// discriminants and decoding the c-strings. Without this restore step + /// there is no payment restore at all, so the in-memory map starts empty + /// and Sent entries vanish on relaunch. + #[test] + fn restore_payments_fold_rebuilds_sent_and_received() { + use platform_wallet::wallet::identity::{PaymentDirection, PaymentStatus}; + + let owner = IdentityV0 { + id: Identifier::from([0xAA; 32]), + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }; + let mut managed = ManagedIdentity::new(Identity::V0(owner), 0); + + // Keep the CStrings alive for the duration of the call. + let sent_txid = std::ffi::CString::new("aa".repeat(32)).unwrap(); + let sent_memo = std::ffi::CString::new("lunch").unwrap(); + let recv_txid = std::ffi::CString::new("bb".repeat(32)).unwrap(); + + let rows = [ + PaymentRestoreEntryFFI { + txid: sent_txid.as_ptr(), + counterparty_id: [0xBB; 32], + amount_duffs: 1_000_000, + direction_raw: 0, // Sent + status_raw: 0, // Pending + memo: sent_memo.as_ptr(), + }, + PaymentRestoreEntryFFI { + txid: recv_txid.as_ptr(), + counterparty_id: [0xCC; 32], + amount_duffs: 500_000, + direction_raw: 1, // Received + status_raw: 1, // Confirmed + memo: std::ptr::null(), + }, + ]; + + unsafe { apply_payment_rows(&rows, &mut managed) }; + + assert_eq!(managed.dashpay_payments.len(), 2); + let sent = managed + .dashpay_payments + .get(&"aa".repeat(32)) + .expect("sent entry restored"); + assert_eq!(sent.direction, PaymentDirection::Sent); + assert_eq!(sent.status, PaymentStatus::Pending); + assert_eq!(sent.amount_duffs, 1_000_000); + assert_eq!(sent.memo.as_deref(), Some("lunch")); + assert_eq!(sent.counterparty_id, Identifier::from([0xBB; 32])); + + let recv = managed + .dashpay_payments + .get(&"bb".repeat(32)) + .expect("received entry restored"); + assert_eq!(recv.direction, PaymentDirection::Received); + assert_eq!(recv.status, PaymentStatus::Confirmed); + assert!(recv.memo.is_none()); + + // An unknown discriminant is skipped, not panicked. + let bad_txid = std::ffi::CString::new("cc".repeat(32)).unwrap(); + let bad = [PaymentRestoreEntryFFI { + txid: bad_txid.as_ptr(), + counterparty_id: [0xDD; 32], + amount_duffs: 1, + direction_raw: 9, + status_raw: 0, + memo: std::ptr::null(), + }]; + unsafe { apply_payment_rows(&bad, &mut managed) }; + assert_eq!( + managed.dashpay_payments.len(), + 2, + "a row with an unknown direction must be skipped, not inserted" + ); + } + + /// **Cached contact profiles are restored at load.** + /// The fold must rebuild `contact_profiles` (keyed by the contact's + /// identity id) from the persisted rows, decoding the c-strings and + /// the `_present`-gated avatar hash / fingerprint, and re-validating + /// the public avatar URL. Without this restore step there is no + /// contact-profile restore at all, so the cache starts empty on + /// relaunch and the requests/contacts UI shows raw ids until the next + /// sweep re-fetches every contact. + #[test] + fn restore_contact_profiles_fold_rebuilds_cache() { + use crate::wallet_restore_types::ContactProfileRestoreEntryFFI; + + let owner = IdentityV0 { + id: Identifier::from([0xAA; 32]), + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }; + let mut managed = ManagedIdentity::new(Identity::V0(owner), 0); + + // Keep the CStrings alive for the duration of the call. + let display_name = std::ffi::CString::new("Alice").unwrap(); + let public_message = std::ffi::CString::new("gm").unwrap(); + let good_url = std::ffi::CString::new("https://example.com/a.png").unwrap(); + // A non-https URL: must be dropped to None, but the rest of the + // profile (display name) must still be restored. + let bad_url = std::ffi::CString::new("http://evil.example/track.gif").unwrap(); + let other_name = std::ffi::CString::new("Bob").unwrap(); + + let rows = [ + ContactProfileRestoreEntryFFI { + contact_id: [0xBB; 32], + display_name: display_name.as_ptr(), + bio: std::ptr::null(), + avatar_url: good_url.as_ptr(), + avatar_hash: [0x11; 32], + avatar_hash_present: true, + avatar_fingerprint: [0x22; 8], + avatar_fingerprint_present: true, + public_message: public_message.as_ptr(), + checked_at_ms: 1_700_000_000_000, + }, + ContactProfileRestoreEntryFFI { + contact_id: [0xCC; 32], + display_name: other_name.as_ptr(), + bio: std::ptr::null(), + avatar_url: bad_url.as_ptr(), + avatar_hash: [0u8; 32], + avatar_hash_present: false, + avatar_fingerprint: [0u8; 8], + avatar_fingerprint_present: false, + public_message: std::ptr::null(), + checked_at_ms: 1_700_000_000_001, + }, + ]; + + unsafe { apply_contact_profile_rows(&rows, &mut managed) }; + + assert_eq!(managed.contact_profiles.len(), 2); + + let alice = managed + .contact_profiles + .get(&Identifier::from([0xBB; 32])) + .expect("alice contact profile restored"); + assert_eq!(alice.checked_at_ms, 1_700_000_000_000); + let alice_profile = alice.profile.as_ref().expect("present profile"); + assert_eq!(alice_profile.display_name.as_deref(), Some("Alice")); + assert_eq!(alice_profile.public_message.as_deref(), Some("gm")); + assert_eq!( + alice_profile.avatar_url.as_deref(), + Some("https://example.com/a.png") + ); + assert_eq!(alice_profile.avatar_hash, Some([0x11; 32])); + assert_eq!(alice_profile.avatar_fingerprint, Some([0x22; 8])); + assert!(alice_profile.bio.is_none()); + + let bob = managed + .contact_profiles + .get(&Identifier::from([0xCC; 32])) + .expect("bob contact profile restored"); + let bob_profile = bob.profile.as_ref().expect("present profile"); + assert_eq!(bob_profile.display_name.as_deref(), Some("Bob")); + // The non-https avatar URL is dropped on the way back in; the + // rest of the profile survives. + assert!( + bob_profile.avatar_url.is_none(), + "a non-https avatar URL must be dropped at restore" + ); + assert!(bob_profile.avatar_hash.is_none()); + assert!(bob_profile.avatar_fingerprint.is_none()); + } + + /// Regression: ignored senders must be restored at load so a + /// previously-ignored sender does NOT resurface on relaunch. + /// + /// A fresh `ManagedIdentity` ignores nothing — that empty set is + /// exactly the post-relaunch state in which the still-on-platform + /// immutable `contactRequest`s re-ingest on the next sweep. Before + /// `restore_dashpay_ignored`/`apply_ignored_rows` existed, the load + /// path rebuilt contacts + payments but left this set empty; this test + /// pins that the ignored senders are now rehydrated, and that the + /// suppression is per-sender (a bumped-`accountReference` request from + /// the same sender is STILL suppressed). + #[test] + fn restore_ignored_rows_rebuilds_ignore_set() { + let owner = IdentityV0 { + id: Identifier::from([0xAA; 32]), + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }; + let mut managed = ManagedIdentity::new(Identity::V0(owner), 0); + + // Post-relaunch precondition: nothing is ignored yet. + assert!(!managed.is_sender_ignored(&Identifier::from([0xBB; 32]))); + + let rows: [[u8; 32]; 2] = [[0xBB; 32], [0xDD; 32]]; + + apply_ignored_rows(&rows, &mut managed); + + assert_eq!(managed.ignored_senders.len(), 2); + assert!(managed.is_sender_ignored(&Identifier::from([0xBB; 32]))); + assert!(managed.is_sender_ignored(&Identifier::from([0xDD; 32]))); + // Per-sender suppression: the ignored sender is suppressed + // regardless of accountReference (no per-ref discrimination). + assert!(!managed.is_sender_ignored(&Identifier::from([0xEE; 32]))); + } } diff --git a/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs b/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs index 75045ff915e..5e978c24e6b 100644 --- a/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs +++ b/packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs @@ -68,6 +68,18 @@ pub const SIGN_WITH_RESOLVER_ERR_RESOLVER_NOT_FOUND: u8 = 9; /// Resolver callback returned `mnemonic_resolver_result::OTHER`. pub const SIGN_WITH_RESOLVER_ERR_RESOLVER_FAILED: u8 = 10; +/// RAII guard that scrubs an [`ExtendedPrivKey`]'s secret scalar on drop, so an +/// early `return`, `?`, or panic between derivation and use can't leak it (the +/// type has no upstream `Drop`/`Zeroize`). Mirrors `WipingXprv` in +/// `rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`. +struct WipingXprv(ExtendedPrivKey); + +impl Drop for WipingXprv { + fn drop(&mut self) { + self.0.private_key.non_secure_erase(); + } +} + /// Sign `data` with the ECDSA secp256k1 private key derived from /// `(mnemonic-via-resolver, derivation_path)`. Mnemonic, seed and /// derived secret bytes all stay in `Zeroizing` buffers and are @@ -200,27 +212,19 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_resolver_and_path( }; let kw_network: Network = network.into(); - let mut master = match ExtendedPrivKey::new_master(kw_network, seed.as_ref()) { - Ok(m) => m, + // `WipingXprv` scrubs both scalars on drop, covering the early `return` + // below (master is guarded the moment it is built) and any panic between + // here and signing. (Upstream `ExtendedPrivKey` has no `Drop`/`Zeroize`.) + let master = match ExtendedPrivKey::new_master(kw_network, seed.as_ref()) { + Ok(m) => WipingXprv(m), Err(_) => return fail(SIGN_WITH_RESOLVER_ERR_DERIVATION), }; let secp = Secp256k1::new(); - let mut derived = match master.derive_priv(&secp, &path) { - Ok(d) => d, + let derived = match master.0.derive_priv(&secp, &path) { + Ok(d) => WipingXprv(d), Err(_) => return fail(SIGN_WITH_RESOLVER_ERR_DERIVATION), }; - let secret_bytes: Zeroizing<[u8; 32]> = Zeroizing::new(derived.private_key.secret_bytes()); - - // TODO(upstream): `key_wallet::bip32::ExtendedPrivKey` has no - // `Drop` / `Zeroize` impl — the inner `secp256k1::SecretKey` - // scalars on `master` and `derived` would otherwise drop un-wiped. - // Proper fix is a `Zeroize` / `ZeroizeOnDrop` impl in - // `dashpay/rust-dashcore`'s `key-wallet/src/bip32.rs`; until that - // lands, wipe the two SecretKey fields explicitly here. Mirrored - // in the sibling Rust signer at - // `rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs::derive_priv`. - master.private_key.non_secure_erase(); - derived.private_key.non_secure_erase(); + let secret_bytes: Zeroizing<[u8; 32]> = Zeroizing::new(derived.0.private_key.secret_bytes()); // ---- Sign --------------------------------------------------------------- let data_slice: &[u8] = if data_len == 0 { diff --git a/packages/rs-platform-wallet-ffi/src/utils.rs b/packages/rs-platform-wallet-ffi/src/utils.rs index b9e9a999bbf..ee9a3656403 100644 --- a/packages/rs-platform-wallet-ffi/src/utils.rs +++ b/packages/rs-platform-wallet-ffi/src/utils.rs @@ -145,6 +145,53 @@ pub unsafe extern "C" fn platform_wallet_hash160( 0 } +/// Compute the hash160 of the **compressed** secp256k1 public key for a +/// 32-byte ECDSA private scalar — i.e. the on-chain `ECDSA_SECP256K1` +/// public-key hash that scalar would own. +/// +/// Lets the Swift Keychain layer cheaply re-verify, after re-deriving an +/// identity key's private scalar, that the scalar actually reproduces the +/// stored on-chain key's hash before persisting it — a cross-FFI guard +/// against the Rust-side derivation path and the Swift-side re-derivation +/// ever drifting (compute it here rather than pull a secp256k1 + RIPEMD-160 +/// stack into Swift). Compression is network-independent, so no network +/// parameter is needed. +/// +/// # Parameters +/// - `private_key`: pointer to the 32-byte ECDSA secret scalar. +/// - `out_hash`: caller-allocated 20-byte buffer; the hash160 of the +/// compressed pubkey is written here on success. +/// +/// Returns 0 on success, -1 on null pointer or an invalid (out-of-range) +/// secret scalar. +/// +/// # Safety +/// - `private_key` must be a valid `[u8; 32]` buffer for the call. +/// - `out_hash` must be a valid `[u8; 20]` writable buffer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_pubkey_hash_from_private_key( + private_key: *const u8, + out_hash: *mut u8, +) -> i32 { + if private_key.is_null() || out_hash.is_null() { + return -1; + } + use dashcore::hashes::Hash; + use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + let sk_bytes = std::slice::from_raw_parts(private_key, 32); + let secp = Secp256k1::new(); + let secret_key = match SecretKey::from_slice(sk_bytes) { + Ok(sk) => sk, + Err(_) => return -1, + }; + let pubkey = PublicKey::from_secret_key(&secp, &secret_key).serialize(); + let hash = dashcore::hashes::hash160::Hash::hash(&pubkey); + let h: [u8; 20] = hash.to_byte_array(); + std::ptr::copy_nonoverlapping(h.as_ptr(), out_hash, 20); + 0 +} + #[cfg(test)] mod tests { use super::*; @@ -184,6 +231,57 @@ mod tests { assert_eq!(rc, -1); } + #[test] + fn test_pubkey_hash_from_private_key_matches_canonical_derivation() { + use dashcore::hashes::Hash; + use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + // A fixed, in-range scalar. + let mut scalar = [0u8; 32]; + scalar[31] = 1; + + let mut out = [0u8; 20]; + let rc = unsafe { + platform_wallet_pubkey_hash_from_private_key(scalar.as_ptr(), out.as_mut_ptr()) + }; + assert_eq!(rc, 0); + + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&scalar).expect("in-range scalar"); + let pubkey = PublicKey::from_secret_key(&secp, &sk).serialize(); + let expected: [u8; 20] = dashcore::hashes::hash160::Hash::hash(&pubkey).to_byte_array(); + assert_eq!(out, expected); + } + + #[test] + fn test_pubkey_hash_from_private_key_rejects_null() { + let mut out = [0u8; 20]; + let scalar = [1u8; 32]; + assert_eq!( + unsafe { + platform_wallet_pubkey_hash_from_private_key(std::ptr::null(), out.as_mut_ptr()) + }, + -1 + ); + assert_eq!( + unsafe { + platform_wallet_pubkey_hash_from_private_key(scalar.as_ptr(), std::ptr::null_mut()) + }, + -1 + ); + } + + #[test] + fn test_pubkey_hash_from_private_key_rejects_invalid_scalar() { + // All-zero scalar is out of secp256k1's valid range. + let scalar = [0u8; 32]; + let mut out = [0u8; 20]; + let rc = unsafe { + platform_wallet_pubkey_hash_from_private_key(scalar.as_ptr(), out.as_mut_ptr()) + }; + assert_eq!(rc, -1); + } + #[test] fn test_serialize_deserialize_json_bytes() { unsafe { diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index c9ef0019914..5a31076d5f6 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -288,6 +288,125 @@ pub struct IdentityRestoreEntryFFI { /// hasn't completed). pub keys: *const IdentityKeyRestoreFFI, pub keys_count: usize, + /// DashPay contact rows owned by this identity, assembled from the + /// per-identity `PersistentDashpayContactRequest` SwiftData rows. + /// Reuses the persist-side [`crate::contact_persistence::ContactRequestFFI`] + /// shape (Swift-owned for the callback window — the byte buffers + /// and metadata strings ride the load allocation, NOT the Rust + /// destructors). Restores pending sent / incoming requests and + /// established contacts (pairs of rows, both directions) with + /// their owner-private metadata — without this, contacts only + /// re-derive from chain on the first sync sweep and the + /// contactInfo metadata is wiped during the deferred-publish + /// window (the relaunch-durability gap in contact-info persistence). + /// `null` / `0` when the identity has no persisted contact rows. + pub contacts: *const crate::contact_persistence::ContactRequestFFI, + pub contacts_count: usize, + /// DashPay payment-history rows owned by this identity, assembled + /// from the per-identity `PersistentDashpayPayment` SwiftData rows. + /// Restores the `dashpay_payments` map at load — without this the + /// in-memory map starts empty and only *Received* entries are + /// re-derived from UTXOs by the reconcile sweep, so *Sent* entries + /// (with their user-entered memos) silently vanish from the + /// authoritative model on every relaunch (H1). Swift-owned for the + /// callback window; the strings ride the load allocation, NOT the + /// Rust destructors. `null` / `0` when the identity has no payments. + pub payments: *const PaymentRestoreEntryFFI, + pub payments_count: usize, + /// DashPay ignored senders (per-sender mute, local-only) owned by this + /// identity, assembled from the persisted ignored-sender rows. Restores + /// `ManagedIdentity.ignored_senders` at load — **without this the ignore + /// set starts empty on every relaunch, so the still-on-platform + /// immutable `contactRequest` documents of a previously-ignored sender + /// re-ingest on the next sync sweep and the ignored sender resurfaces** + /// (the relaunch-durability gap that mirrors the contacts/payments + /// restore arrays above). Each entry is a bare 32-byte sender id (the + /// host persists only currently-ignored senders, so an un-ignored one + /// simply doesn't appear) — a flat POD array, so nothing rides the load + /// allocation here. `null` / `0` when the identity has ignored no one. + pub ignored_senders: *const [u8; 32], + pub ignored_senders_count: usize, + /// DashPay cached **contact** profiles owned by this identity, + /// assembled from the per-identity `PersistentDashpayContactProfile` + /// SwiftData rows. Restores `ManagedIdentity.contact_profiles` + /// (present entries only) at load — without this the contact-profile + /// cache starts empty on every relaunch and the requests/contacts UI + /// shows raw identity ids until the next profile sweep re-fetches + /// every contact (write amplification + a visible cold-start flicker). + /// Only **present** profiles are persisted/restored; the + /// confirmed-absent negative cache rebuilds harmlessly on the next + /// sweep. Swift-owned for the callback window; the strings ride the + /// load allocation, NOT the Rust destructors. `null` / `0` when the + /// identity has no cached contact profiles. + pub contact_profiles: *const ContactProfileRestoreEntryFFI, + pub contact_profiles_count: usize, +} + +/// One DashPay payment-history row to rehydrate into +/// `ManagedIdentity.dashpay_payments` (keyed by `txid`) at load. +/// +/// `direction_raw` / `status_raw` mirror the `PaymentDirection` / +/// `PaymentStatus` discriminants (direction: 0=Sent, 1=Received; +/// status: 0=Pending, 1=Confirmed, 2=Failed). Swift owns `txid` +/// (always non-null) and the optional `memo` for the callback window. +#[repr(C)] +pub struct PaymentRestoreEntryFFI { + /// NUL-terminated transaction id (hex) — the `dashpay_payments` + /// map key. + pub txid: *const std::os::raw::c_char, + /// The other identity in this payment. + pub counterparty_id: [u8; 32], + /// Amount in duffs (always positive; `direction_raw` carries sign). + pub amount_duffs: u64, + /// `PaymentDirection` discriminant: 0=Sent, 1=Received. + pub direction_raw: u8, + /// `PaymentStatus` discriminant: 0=Pending, 1=Confirmed, 2=Failed. + pub status_raw: u8, + /// NUL-terminated memo, or null when the source `Option` was `None`. + pub memo: *const std::os::raw::c_char, +} + +/// One cached **contact** profile row to rehydrate into +/// `ManagedIdentity.contact_profiles` (keyed by the contact's identity +/// id) at load. Mirrors the persist-side +/// [`crate::identity_persistence::ContactProfileRowFFI`] field-for-field +/// (the leading `contact_id` key, the five public profile fields with +/// their `_present` byte-array flags, and the trailing `checked_at_ms` +/// self-heal timestamp). +/// +/// Only **present** profiles ride this struct — the confirmed-absent +/// negative cache is never persisted, so every restored entry rebuilds +/// as `ContactProfileEntry { profile: Some(..), checked_at_ms }`. Swift +/// owns the four optional c-strings for the callback window; gate the +/// byte-array fields on their paired `_present` flag rather than +/// checking for all-zero (a valid hash/fingerprint value). +#[repr(C)] +pub struct ContactProfileRestoreEntryFFI { + /// The contact's 32-byte identity id — the `contact_profiles` map + /// key. + pub contact_id: [u8; 32], + /// NUL-terminated `displayName`, or null when the source `Option` + /// was `None`. + pub display_name: *const std::os::raw::c_char, + /// NUL-terminated `bio`, or null when `None`. + pub bio: *const std::os::raw::c_char, + /// NUL-terminated `avatarUrl`, or null when `None`. + pub avatar_url: *const std::os::raw::c_char, + /// SHA-256 avatar hash; meaningful only when + /// [`Self::avatar_hash_present`] is `true`. + pub avatar_hash: [u8; 32], + /// `true` iff the source `avatar_hash` was `Some(_)`. + pub avatar_hash_present: bool, + /// DHash avatar fingerprint; meaningful only when + /// [`Self::avatar_fingerprint_present`] is `true`. + pub avatar_fingerprint: [u8; 8], + /// `true` iff the source `avatar_fingerprint` was `Some(_)`. + pub avatar_fingerprint_present: bool, + /// NUL-terminated `publicMessage`, or null when `None`. + pub public_message: *const std::os::raw::c_char, + /// Wall-clock ms of the last fetch attempt — the + /// `ContactProfileEntry::checked_at_ms` self-heal timestamp. + pub checked_at_ms: u64, } /// One unspent UTXO row to rehydrate into a funds-bearing account's diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index 43bf9a0fb0f..68f4e7561fc 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -122,6 +122,11 @@ assert_cmd = "2" predicates = "3" static_assertions = "1" filetime = "0.2" +# Test-only: construct the `Zeroizing<[u8; 32]>` secret on an +# `IdentityKeyEntry` to prove the on-disk wire shape drops it. The +# production `zeroize` dep is gated behind the `secrets` feature, which +# the off-state CI build disables, so the test surface needs its own. +zeroize = { version = "=1.8.2", features = ["derive"] } tracing-test = { version = "0.2", features = ["no-env-filter"] } serial_test = "3" # `default-features = false` so the off-state CI invocation diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index 59a6e45eaea..86b59a31947 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -54,6 +54,8 @@ pub fn migration() -> String { build_check_in(crate::sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS); let contact_state_check = build_check_in(crate::sqlite::schema::contacts::CONTACT_STATE_LABELS); + let pending_contact_crypto_kind_check = + build_check_in(crate::sqlite::schema::pending_contact_crypto::KIND_LABELS); format!( "\ @@ -82,6 +84,17 @@ CREATE TABLE account_address_pools ( FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE ); +CREATE TABLE pending_contact_crypto ( + wallet_id BLOB NOT NULL, + owner_identity_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + kind TEXT NOT NULL CHECK (kind IN {pending_contact_crypto_kind_check}), + payload BLOB NOT NULL, + enqueued_at_ms INTEGER NOT NULL, + PRIMARY KEY (wallet_id, owner_identity_id, contact_id, kind), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + CREATE TABLE core_transactions ( wallet_id BLOB NOT NULL, txid BLOB NOT NULL, @@ -186,11 +199,32 @@ CREATE TABLE contacts ( note TEXT, is_hidden INTEGER, accepted_accounts BLOB, + -- G1c: set when external-account registration permanently fails for a + -- contact (so the sync sweep stops retrying a poisoned channel); + -- cleared on a superseding rotation. Nullable — readers treat NULL as + -- `false`. + payment_channel_broken INTEGER, updated_at INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (wallet_id, owner_id, contact_id), FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE ); +-- Ignored senders (per-sender mute = block, reversible — local-only). Keyed by +-- bare `(wallet_id, owner_id, sender_id)`: ignoring is per-sender, NOT +-- per-request, so it suppresses ALL of a sender's incoming contactRequests +-- (including rotated, bumped-`accountReference` ones) and survives a recurring +-- re-sync. Un-ignore deletes the row so the sender's requests resurface. The +-- sync ingest path consults this table before surfacing a received +-- contactRequest in the main pending list. +CREATE TABLE ignored_senders ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + sender_id BLOB NOT NULL, + ignored_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, sender_id), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE +); + CREATE TABLE platform_addresses ( wallet_id BLOB NOT NULL, account_index INTEGER NOT NULL, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index 1163fe62044..f313b406c73 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -150,4 +150,47 @@ mod tests { "schema-history table is present after creation" ); } + + fn table_exists(conn: &Connection, name: &str) -> bool { + conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + [name], + |_| Ok(()), + ) + .is_ok() + } + + fn column_exists(conn: &Connection, table: &str, column: &str) -> bool { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .unwrap(); + let cols: Vec = stmt + .query_map([], |r| r.get::<_, String>(1)) + .unwrap() + .filter_map(Result::ok) + .collect(); + cols.iter().any(|c| c == column) + } + + /// The initial schema (V001) creates the DashPay sync-correctness + /// objects directly — the `contacts.payment_channel_broken` column and + /// the `ignored_senders` table. The storage crate is pre-release with no + /// product consumers yet (nothing instantiates `SqlitePersister` or runs + /// these migrations), so V001 is edited in place rather than amended by a + /// follow-on migration — no real database has ever applied it. This test + /// pins that the objects exist after the (only) migration runs. + #[test] + fn v001_creates_dashpay_sync_schema() { + let mut conn = Connection::open_in_memory().unwrap(); + run(&mut conn).unwrap(); + + assert!( + table_exists(&conn, "ignored_senders"), + "V001 must create the ignored-senders table" + ); + assert!( + column_exists(&conn, "contacts", "payment_channel_broken"), + "V001 must create the contacts.payment_channel_broken column" + ); + } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 1cecf885308..104af2dbe6b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1060,6 +1060,15 @@ fn apply_changeset_to_tx( if !cs.account_address_pools.is_empty() { schema::accounts::apply_pools(tx, wallet_id, &cs.account_address_pools)?; } + if !cs.pending_contact_crypto_added.is_empty() || !cs.pending_contact_crypto_cleared.is_empty() + { + schema::pending_contact_crypto::apply_pending_contact_crypto( + tx, + wallet_id, + &cs.pending_contact_crypto_added, + &cs.pending_contact_crypto_cleared, + )?; + } if let Some(core) = cs.core.as_ref() { schema::core_state::apply(tx, wallet_id, core)?; } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs index c1314115ebd..8474b5945e2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -199,8 +199,8 @@ pub fn apply( let mut stmt = tx.prepare_cached( "INSERT INTO contacts \ (wallet_id, owner_id, contact_id, state, outgoing_request, incoming_request, \ - alias, note, is_hidden, accepted_accounts) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ + alias, note, is_hidden, accepted_accounts, payment_channel_broken) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \ ON CONFLICT(wallet_id, owner_id, contact_id) DO UPDATE SET \ state = excluded.state, \ outgoing_request = excluded.outgoing_request, \ @@ -208,7 +208,8 @@ pub fn apply( alias = excluded.alias, \ note = excluded.note, \ is_hidden = excluded.is_hidden, \ - accepted_accounts = excluded.accepted_accounts", + accepted_accounts = excluded.accepted_accounts, \ + payment_channel_broken = excluded.payment_channel_broken", )?; for (key, established) in &cs.established { let outgoing = blob::encode(&established.outgoing_request)?; @@ -225,6 +226,42 @@ pub fn apply( established.note, established.is_hidden as i64, accepted, + established.payment_channel_broken as i64, + ])?; + } + } + if !cs.ignored.is_empty() { + // Per-sender ignore (= block, reversible — local-only), keyed by bare + // `(wallet_id, owner_id, sender_id)`. Suppresses ALL of the sender's + // incoming requests (including rotated, bumped-accountReference ones); + // the sync ingest path consults this table before surfacing a received + // request. Insert is idempotent — re-ignoring an already-ignored sender + // is a no-op rather than an error. + let mut stmt = tx.prepare_cached( + "INSERT INTO ignored_senders (wallet_id, owner_id, sender_id) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(wallet_id, owner_id, sender_id) DO NOTHING", + )?; + for (owner_id, sender_id) in &cs.ignored { + stmt.execute(params![ + wallet_id.as_slice(), + owner_id.as_slice(), + sender_id.as_slice(), + ])?; + } + } + if !cs.unignored.is_empty() { + // Un-ignore tombstone: delete the row so the sender's requests resurface + // on the next sweep. Deleting a non-existent row is a harmless no-op. + let mut stmt = tx.prepare_cached( + "DELETE FROM ignored_senders \ + WHERE wallet_id = ?1 AND owner_id = ?2 AND sender_id = ?3", + )?; + for (owner_id, sender_id) in &cs.unignored { + stmt.execute(params![ + wallet_id.as_slice(), + owner_id.as_slice(), + sender_id.as_slice(), ])?; } } @@ -245,7 +282,7 @@ pub(crate) fn load_state( let mut stmt = conn.prepare( "SELECT owner_id, contact_id, state, outgoing_request, incoming_request, \ - alias, note, is_hidden, accepted_accounts \ + alias, note, is_hidden, accepted_accounts, payment_channel_broken \ FROM contacts WHERE wallet_id = ?1", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; @@ -289,6 +326,7 @@ pub(crate) fn load_state( Some(bytes) => blob::decode(&bytes)?, None => Vec::new(), }; + let payment_channel_broken: bool = row.get::<_, Option>(9)?.unwrap_or(0) != 0; state.established.insert( SentContactRequestKey { owner_id, @@ -302,6 +340,7 @@ pub(crate) fn load_state( note, is_hidden, accepted_accounts, + payment_channel_broken, }, ); } @@ -384,4 +423,88 @@ mod tests { "CONTACT_STATE_LABELS ({from_const:?}) drifted from contact_state_db_label codomain ({from_writer:?})" ); } + + /// Ignoring a sender persists one `ignored_senders` row; un-ignoring the + /// same `(owner, sender)` deletes it. This is the local-only suppression + /// the sync ingest path relies on — if the write/delete pairing is wrong, + /// an ignored sender either never gets muted or stays muted forever. + #[test] + fn ignore_then_unignore_round_trips() { + use crate::sqlite::migrations; + use crate::sqlite::schema::wallet_meta; + use dpp::prelude::Identifier; + use platform_wallet::wallet::platform_wallet::WalletId; + use rusqlite::Connection; + use std::collections::BTreeSet; + + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id: WalletId = [7u8; 32]; + wallet_meta::ensure_exists(&conn, &wallet_id).unwrap(); + + let owner = Identifier::from([0xAAu8; 32]); + let sender = Identifier::from([0xBBu8; 32]); + + let count = |conn: &Connection| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM ignored_senders \ + WHERE wallet_id = ?1 AND owner_id = ?2 AND sender_id = ?3", + params![wallet_id.as_slice(), owner.as_slice(), sender.as_slice()], + |r| r.get(0), + ) + .unwrap() + }; + + // Ignore → one row. + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &ContactChangeSet { + ignored: BTreeSet::from([(owner, sender)]), + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + count(&conn), + 1, + "ignore must persist the (owner, sender) row" + ); + + // Re-ignore is idempotent (ON CONFLICT DO NOTHING) → still one row. + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &ContactChangeSet { + ignored: BTreeSet::from([(owner, sender)]), + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + assert_eq!(count(&conn), 1, "re-ignoring the same sender is a no-op"); + + // Un-ignore → row deleted. + { + let tx = conn.transaction().unwrap(); + apply( + &tx, + &wallet_id, + &ContactChangeSet { + unignored: BTreeSet::from([(owner, sender)]), + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + assert_eq!(count(&conn), 0, "un-ignore must delete the row"); + } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index aa74f87b919..7eb45639094 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -211,12 +211,22 @@ fn managed_identity_from_entry( established_contacts: Default::default(), sent_contact_requests: Default::default(), incoming_contact_requests: Default::default(), + // Scalar-snapshot collections ride the identity `entry_blob` (like + // `dashpay_payments` / `dashpay_profile` below), so they restore from + // `entry`. The relational request collections above are loaded + // separately from the `contacts` table and stay defaulted here. + ignored_senders: entry.ignored_senders.clone(), status: entry.status, dpns_names: entry.dpns_names.clone(), contested_dpns_names: entry.contested_dpns_names.clone(), wallet_id: entry.wallet_id.or(Some(*wallet_id)), dashpay_profile: entry.dashpay_profile.clone(), dashpay_payments: entry.dashpay_payments.clone(), + contact_profiles: entry.contact_profiles.clone(), + // High-water sync cursors are in-memory by design: a cold restore + // starts them at `None` so the next sweep does one safe full re-fetch. + high_water_received_ms: None, + high_water_sent_ms: None, } } @@ -248,6 +258,8 @@ pub fn ensure_exists( wallet_id: None, dashpay_profile: None, dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), }; let payload = blob::encode(&stub)?; let wallet_id_param = wallet_id_to_param(wallet_id); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index fc85a19c6a7..81918e3e691 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -74,6 +74,10 @@ impl IdentityKeyWire { public_key_hash: self.public_key_hash, wallet_id: self.wallet_id, derivation_indices: self.derivation_indices, + // The on-disk wire shape never carries key material — a row read + // back from storage is always watch-only at the changeset level + // (the materialized scalar lives only in the client keychain). + private_key: None, }) } } @@ -184,4 +188,49 @@ mod tests { "expected BlobDecode for trailing-byte garbage, got {err:?}" ); } + + /// The on-disk wire shape must never carry the signing scalar. An + /// `IdentityKeyEntry` that holds a live scalar is transcribed + /// field-by-field into `IdentityKeyWire` (which has no scalar field by + /// construction), so a `from_entry` → `into_entry` round-trip drops the + /// scalar to `None`. Pins the "no key material at rest outside the + /// keychain" guarantee so a future "serialize the whole entry straight + /// to the blob" refactor can't start persisting it unnoticed. + #[test] + fn wire_round_trip_drops_signing_scalar() { + let pk = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }); + let entry = IdentityKeyEntry { + identity_id: dpp::prelude::Identifier::from([0xAA; 32]), + key_id: 0, + public_key: pk, + public_key_hash: [0x11; 20], + wallet_id: Some([0x9A; 32]), + derivation_indices: Some(IdentityKeyDerivationIndices { + identity_index: 1, + key_index: 2, + }), + // A live scalar on the in-memory entry. + private_key: Some(zeroize::Zeroizing::new([0xC7; 32])), + }; + + let wire = IdentityKeyWire::from_entry(&entry).expect("encode wire"); + let restored = wire.into_entry().expect("decode wire"); + + assert!( + restored.private_key.is_none(), + "the wire round-trip must drop the signing scalar" + ); + // The breadcrumb metadata still survives the round-trip. + assert_eq!(restored.wallet_id, entry.wallet_id); + assert_eq!(restored.derivation_indices, entry.derivation_indices); + } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index bcd8ef00ab9..a2ae6da308f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -21,6 +21,7 @@ pub mod core_state; pub mod dashpay; pub mod identities; pub mod identity_keys; +pub mod pending_contact_crypto; pub mod platform_addrs; pub mod token_balances; pub mod wallet_meta; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs new file mode 100644 index 00000000000..a2fde0d4c5c --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs @@ -0,0 +1,235 @@ +//! `pending_contact_crypto` deferred-crypto queue writer + reader. +//! +//! The seedless background sweep enqueues contact-crypto ops it can't perform +//! without a signer; they're drained when one is available. The queue is +//! persisted (a restore-from-Keychain is exactly when it's needed) as add/clear +//! deltas on the changeset. Each row stores the bincode-serde +//! [`PendingContactCrypto`] in `payload`; the `(owner, contact, kind)` columns +//! mirror its dedup key for keyed deletes + the CHECK on `kind`. Secret-free — +//! only ciphertext + public key indices ever reach here. + +// `BTreeMap` + `Connection` are used only by the test/helper-gated reader. +#[cfg(test)] +use std::collections::BTreeMap; + +#[cfg(test)] +use rusqlite::Connection; +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::{ + PendingContactCrypto, PendingContactCryptoKey, PendingContactCryptoKind, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +/// TEXT-column domain for `pending_contact_crypto.kind`. Single source of truth +/// shared with the migration's CHECK clause and [`kind_db_label`]; pinned equal +/// to the writer's codomain by `kind_labels_match_enum`. +pub const KIND_LABELS: &[&str] = &[ + "register_receiving", + "register_external", + "contact_info_decrypt", + "auto_accept", +]; + +fn kind_db_label(kind: PendingContactCryptoKind) -> &'static str { + match kind { + PendingContactCryptoKind::RegisterReceiving => "register_receiving", + PendingContactCryptoKind::RegisterExternal => "register_external", + PendingContactCryptoKind::ContactInfoDecrypt => "contact_info_decrypt", + PendingContactCryptoKind::AutoAccept => "auto_accept", + } +} + +/// Apply one round of queue deltas in `tx`: upsert `added` (by the +/// `(owner, contact, kind)` dedup key, latest payload wins) and delete +/// `cleared`. Mirrors `accounts::apply_registrations`' upsert shape. +pub fn apply_pending_contact_crypto( + tx: &Transaction<'_>, + wallet_id: &WalletId, + added: &[PendingContactCrypto], + cleared: &[PendingContactCryptoKey], +) -> Result<(), WalletStorageError> { + if !added.is_empty() { + let mut stmt = tx.prepare_cached( + "INSERT INTO pending_contact_crypto \ + (wallet_id, owner_identity_id, contact_id, kind, payload, enqueued_at_ms) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(wallet_id, owner_identity_id, contact_id, kind) DO UPDATE SET \ + payload = excluded.payload, enqueued_at_ms = excluded.enqueued_at_ms", + )?; + for entry in added { + let payload = blob::encode(entry)?; + let owner = entry.owner_identity_id.to_buffer(); + let contact = entry.contact_id.to_buffer(); + let enqueued = i64::try_from(entry.enqueued_at_ms).unwrap_or(i64::MAX); + stmt.execute(params![ + wallet_id.as_slice(), + owner.as_slice(), + contact.as_slice(), + kind_db_label(entry.op.kind()), + payload, + enqueued, + ])?; + } + } + if !cleared.is_empty() { + let mut stmt = tx.prepare_cached( + "DELETE FROM pending_contact_crypto \ + WHERE wallet_id = ?1 AND owner_identity_id = ?2 AND contact_id = ?3 AND kind = ?4", + )?; + for key in cleared { + let owner = key.owner_identity_id.to_buffer(); + let contact = key.contact_id.to_buffer(); + stmt.execute(params![ + wallet_id.as_slice(), + owner.as_slice(), + contact.as_slice(), + kind_db_label(key.kind), + ])?; + } + } + Ok(()) +} + +/// Every wallet's deferred-crypto queue, grouped by `wallet_id`, decoded from +/// the `payload` blob. +/// +/// The production consumer is the `load()` restore into +/// `PlatformWalletInfo.pending_contact_crypto`, which is blocked on the upstream +/// per-wallet state restore (`LOAD_UNIMPLEMENTED: ClientStartState::wallets` — see +/// `persister.rs`). Until that lands this reader is exercised only by the +/// round-trip test, so it is `cfg(test)`-gated to keep both the lib and the +/// `__test-helpers` builds dead-code-clean; widen to +/// `any(test, feature = "__test-helpers")` when the load restore consumes it. +#[cfg(test)] +pub(crate) fn all_pending_contact_crypto( + conn: &Connection, +) -> Result>, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT wallet_id, payload FROM pending_contact_crypto \ + ORDER BY wallet_id, enqueued_at_ms", + )?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, Vec>(0)?, row.get::<_, Vec>(1)?)) + })?; + let mut out: BTreeMap> = BTreeMap::new(); + for r in rows { + let (wid_bytes, payload) = r?; + let wallet_id = <[u8; 32]>::try_from(wid_bytes.as_slice()).map_err(|_| { + WalletStorageError::InvalidWalletIdLength { + actual: wid_bytes.len(), + } + })?; + let entry: PendingContactCrypto = blob::decode(&payload)?; + out.entry(wallet_id).or_default().push(entry); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `KIND_LABELS` (the migration CHECK domain) must equal the set of labels + /// `kind_db_label` can emit — so a new op kind can't slip past the CHECK. + #[test] + fn kind_labels_match_enum() { + use std::collections::BTreeSet; + let mapped: BTreeSet<&str> = [ + PendingContactCryptoKind::RegisterReceiving, + PendingContactCryptoKind::RegisterExternal, + PendingContactCryptoKind::ContactInfoDecrypt, + PendingContactCryptoKind::AutoAccept, + ] + .into_iter() + .map(kind_db_label) + .collect(); + let labels: BTreeSet<&str> = KIND_LABELS.iter().copied().collect(); + assert_eq!( + mapped, labels, + "KIND_LABELS must equal the kind_db_label codomain" + ); + } + + /// Persist two queue entries, read them back, clear one, read again — + /// proving the add-delta upsert, the keyed clear-delta delete, and the + /// payload round-trip all work against the real migrated schema. + #[test] + fn round_trip_persists_and_clears_queue() { + use crate::sqlite::migrations; + use crate::sqlite::schema::wallet_meta; + use dpp::prelude::Identifier; + use platform_wallet::changeset::PendingContactCryptoOp; + use rusqlite::Connection; + + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + let wallet_id: WalletId = [7u8; 32]; + wallet_meta::ensure_exists(&conn, &wallet_id).unwrap(); + + let owner = Identifier::from([0xAAu8; 32]); + let contact = Identifier::from([0xBBu8; 32]); + let receiving = PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: 1, + }; + let external = PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterExternal { + encrypted_public_key: vec![1, 2, 3], + our_decryption_key_index: 0, + contact_encryption_key_index: 0, + }, + enqueued_at_ms: 2, + }; + + // Persist both add-deltas. + { + let tx = conn.transaction().unwrap(); + apply_pending_contact_crypto( + &tx, + &wallet_id, + &[receiving.clone(), external.clone()], + &[], + ) + .unwrap(); + tx.commit().unwrap(); + } + let loaded = all_pending_contact_crypto(&conn).unwrap(); + assert_eq!( + loaded.get(&wallet_id).map(|v| v.len()), + Some(2), + "both enqueued entries persist and read back" + ); + + // Clear the receiving entry; the external one remains. + { + let tx = conn.transaction().unwrap(); + apply_pending_contact_crypto(&tx, &wallet_id, &[], &[receiving.key()]).unwrap(); + tx.commit().unwrap(); + } + let remaining = all_pending_contact_crypto(&conn) + .unwrap() + .get(&wallet_id) + .cloned() + .unwrap_or_default(); + assert_eq!( + remaining.len(), + 1, + "the clear-delta removes exactly one entry" + ); + assert!( + matches!( + remaining[0].op, + PendingContactCryptoOp::RegisterExternal { .. } + ), + "the external entry survives the receiving-entry clear" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/secrets_scan.rs b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs index 68e0cf48d01..e080fa531b9 100644 --- a/packages/rs-platform-wallet-storage/tests/secrets_scan.rs +++ b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs @@ -42,6 +42,15 @@ const ALLOWLIST: &[&str] = &[ "public material", "do not derive private keys", "private keys are NOT", + // `IdentityKeyEntry.private_key` is the in-memory-only changeset + // field (`#[serde(skip)]`, never written to a column). These three + // expressions construct or inspect that struct in `into_entry` and + // its round-trip regression test — each needle is specific enough + // that it can only match a Rust struct-literal/field access, never a + // persisted column definition. + "private_key: None", + "private_key: Some(zeroize::Zeroizing", + "restored.private_key.is_none()", ]; fn line_is_allowlisted(line: &str) -> bool { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs index e4c6ffbf746..1eedef9a4a8 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs @@ -348,6 +348,8 @@ fn identity_entry(id: u8, idx: Option) -> IdentityEntry { wallet_id: None, dashpay_profile: None, dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), } } @@ -531,7 +533,7 @@ fn contacts_round_trip( /// A fully-populated [`EstablishedContact`] so the round-trip exercises /// every metadata column (`alias`, `note`, `is_hidden`, -/// `accepted_accounts`) plus both request blobs. +/// `accepted_accounts`, `payment_channel_broken`) plus both request blobs. fn established_contact(owner: u8, contact: u8) -> EstablishedContact { EstablishedContact { contact_identity_id: Identifier::from([contact; 32]), @@ -541,6 +543,9 @@ fn established_contact(owner: u8, contact: u8) -> EstablishedContact { note: Some("met at conf".to_string()), is_hidden: true, accepted_accounts: vec![1, 7, 42], + // Non-default so the round-trip test pins the new + // `payment_channel_broken` column through write + read. + payment_channel_broken: true, } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs index 3004b16fc88..fc8247c7994 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -232,6 +232,7 @@ fn tc007_identity_key_entry_roundtrip() { identity_index: 1, key_index: 2, }), + private_key: None, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((identity_id, 7), entry.clone()); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs index 1018974bd56..644a2c5e135 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -314,6 +314,7 @@ fn identity_key_entry_mismatch_rejected() { public_key_hash: [3u8; 20], wallet_id: Some(w), derivation_indices: None, + private_key: None, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((key_identity, 0), entry); @@ -361,6 +362,8 @@ fn identity_entry_id_mismatch_rejected() { wallet_id: None, dashpay_profile: None, dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), }; let mut identities = std::collections::BTreeMap::new(); identities.insert(key_id, entry); diff --git a/packages/rs-platform-wallet/examples/basic_usage.rs b/packages/rs-platform-wallet/examples/basic_usage.rs index 26913d5228a..21bba38472a 100644 --- a/packages/rs-platform-wallet/examples/basic_usage.rs +++ b/packages/rs-platform-wallet/examples/basic_usage.rs @@ -58,7 +58,7 @@ async fn main() -> Result<(), Box> { let wallet = manager .create_wallet_from_seed_bytes( Network::Testnet, - seed_bytes, + &seed_bytes, WalletAccountCreationOptions::Default, None, ) diff --git a/packages/rs-platform-wallet/examples/shielded_sync.rs b/packages/rs-platform-wallet/examples/shielded_sync.rs index e966c7bcea7..d4e84f45234 100644 --- a/packages/rs-platform-wallet/examples/shielded_sync.rs +++ b/packages/rs-platform-wallet/examples/shielded_sync.rs @@ -240,7 +240,7 @@ async fn run_wallet_sync_test(wallet: WalletIndex) { let platform_wallet = manager .create_wallet_from_seed_bytes( network, - transparent_seed, + &transparent_seed, WalletAccountCreationOptions::Default, None, ) diff --git a/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs b/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs index 06a26aaf27e..f015a3613ba 100644 --- a/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs +++ b/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs @@ -234,7 +234,7 @@ async fn main() { let platform_wallet = manager .create_wallet_from_seed_bytes( Network::Devnet, - transparent_seed, + &transparent_seed, WalletAccountCreationOptions::Default, // birth_height_override: skip SPV-tip lookup (no SPV running here) Some(0), diff --git a/packages/rs-platform-wallet/src/address_paths.rs b/packages/rs-platform-wallet/src/address_paths.rs index f12761028e8..83c86e3aec0 100644 --- a/packages/rs-platform-wallet/src/address_paths.rs +++ b/packages/rs-platform-wallet/src/address_paths.rs @@ -23,8 +23,10 @@ //! //! `` comes from //! [`AccountType::derivation_path`](key_wallet::account::AccountType::derivation_path). -//! Network is read directly off `DerivedAddress.address.network()` -//! so callers don't have to thread it. +//! The path's only network-dependent part is the BIP44 coin type +//! (`5'` on mainnet, `1'` otherwise), so resolving mainnet-vs-not off +//! the address is sufficient and callers don't have to thread a +//! network. //! //! Returns `None` only for account variants whose //! `derivation_path()` returns `Err` (some non-Standard variants @@ -42,6 +44,10 @@ use crate::DerivedAddress; /// Render the BIP32 derivation path for a `DerivedAddress` event /// payload. See module-level docs for the path layout rules. pub fn derivation_path_for_derived_address(derived: &DerivedAddress) -> Option { + // `Address` no longer exposes its network directly — its base58/bech32 + // prefix is ambiguous across testnet/devnet/regtest. The derivation path + // only distinguishes mainnet (coin type `5'`) from everything else (`1'`), + // so probe for mainnet and fall back to testnet otherwise. let network = if derived .address .as_unchecked() diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index b4c18917c44..bed1861e9c9 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -50,7 +50,9 @@ use crate::changeset::merge::Merge; use crate::wallet::identity::state::managed_identity::{ BlockTime, DpnsNameInfo, IdentityStatus, ManagedIdentity, }; -use crate::wallet::identity::{ContactRequest, DashPayProfile, EstablishedContact, PaymentEntry}; +use crate::wallet::identity::{ + ContactProfileEntry, ContactRequest, DashPayProfile, EstablishedContact, PaymentEntry, +}; // --------------------------------------------------------------------------- // Core wallet changeset — projection of upstream `WalletEvent` data @@ -306,6 +308,18 @@ pub struct IdentityEntry { /// map via `from_managed`, so merge can use plain extend semantics /// without losing history. pub dashpay_payments: BTreeMap, + /// Cached contact profiles keyed by the contact's identity id. Like + /// `dashpay_payments`, every snapshot carries the full map via + /// `from_managed`, so merge uses last-write-wins per contact id. + pub contact_profiles: BTreeMap, + /// Senders this identity has chosen to **ignore** (per-sender mute, = + /// block, reversible — local-only). Every snapshot carries the full set + /// via `from_managed`, so merge takes the **union** (a member appearing + /// in either side stays ignored; un-ignore is carried by an explicit + /// removal on [`ContactChangeSet::unignored`], not by a shrinking + /// snapshot here — same insert-XOR-tombstone discipline the contact + /// request fields use). + pub ignored_senders: BTreeSet, } impl IdentityEntry { @@ -329,6 +343,8 @@ impl IdentityEntry { wallet_id: managed.wallet_id, dashpay_profile: managed.dashpay_profile.clone(), dashpay_payments: managed.dashpay_payments.clone(), + contact_profiles: managed.contact_profiles.clone(), + ignored_senders: managed.ignored_senders.clone(), } } } @@ -349,19 +365,43 @@ pub struct IdentityKeyDerivationIndices { pub key_index: u32, } +/// A derivation breadcrumb as the raw `(wallet_id, identity_index, +/// key_index)` triple passed to `ManagedIdentity::add_key` / `add_keys`. +/// `Some` lets the client re-derive the private key from the wallet seed; +/// `None` marks a watch-only key. +pub type KeyDerivationBreadcrumb = ([u8; 32], u32, u32); + +/// One public key paired with its derivation breadcrumb and (when the key +/// was reproduced from this wallet's seed) the already-verified 32-byte +/// ECDSA scalar — the unit `ManagedIdentity::add_keys` consumes and +/// `discovery::breadcrumb_decisions` produces. +/// +/// `verified_scalar` carries the secret that discovery already derived and +/// validated against the on-chain key (so the client stores it directly +/// without re-deriving from a mnemonic). It is `Some` only when `breadcrumb` +/// is `Some`; both are `None` for a watch-only key. +pub struct KeyWithBreadcrumb { + /// The DPP public-key record. + pub key: dpp::identity::IdentityPublicKey, + /// Derivation coordinates for re-derivable keys; `None` for watch-only. + pub breadcrumb: Option, + /// The verified 32-byte ECDSA scalar for re-derivable keys; `None` for + /// watch-only. Never serialized — it only transits in-process to the + /// client persister hand-off. + pub verified_scalar: Option>, +} + /// A single identity-key entry in an [`IdentityKeysChangeSet`]. /// -/// Platform-wallet only carries the DPP public-key record and a -/// breadcrumb pointing at the wallet derivation that produced it; -/// private-key bytes live exclusively on the client side (iOS -/// Keychain, Android Keystore, etc.), populated by the client -/// deriving locally from the owning wallet's mnemonic. When -/// `wallet_id` + `derivation_indices` are both set, the client -/// should re-derive the 32-byte scalar at -/// `m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'` and -/// persist it. When either is `None` the key is watch-only from -/// this wallet's point of view. -#[derive(Debug, Clone, PartialEq)] +/// Carries the DPP public-key record, a breadcrumb pointing at the wallet +/// derivation that produced it, and — for keys this wallet's seed +/// reproduced under discovery's verify gate — the already-verified 32-byte +/// ECDSA scalar in [`Self::private_key`]. The client persister stores the +/// scalar directly (no mnemonic read, no re-derivation). When +/// `private_key` is `None` the key is watch-only from this wallet's point +/// of view; `wallet_id` + `derivation_indices` still describe the DIP-9 +/// coordinates at `m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'`. +#[derive(Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeyEntry { /// Owning identity. @@ -381,6 +421,38 @@ pub struct IdentityKeyEntry { /// re-derive the private key. `None` means "view-only, no /// private key recoverable". pub derivation_indices: Option, + /// The already-verified 32-byte ECDSA scalar, carried so the client + /// persister stores it directly instead of re-deriving it from the + /// wallet mnemonic. `None` for watch-only keys. + /// + /// Never serialized: `Zeroizing` implements neither `Serialize` nor + /// `Deserialize`, and a secret must never land in any persisted + /// changeset, so this is `#[serde(skip)]`. A serialize/deserialize + /// round-trip yields `None` — the secret only transits in-process to + /// the same-tick FFI hand-off. + #[cfg_attr(feature = "serde", serde(skip))] + pub private_key: Option>, +} + +/// Hand-written so the secret in [`IdentityKeyEntry::private_key`] is never +/// printed. `IdentityKeyEntry` rides inside `IdentityKeysChangeSet` / +/// `PlatformWalletChangeSet`, which are logged on persist errors, and +/// `Zeroizing`'s derived `Debug` would print the inner bytes. +impl std::fmt::Debug for IdentityKeyEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdentityKeyEntry") + .field("identity_id", &self.identity_id) + .field("key_id", &self.key_id) + .field("public_key", &self.public_key) + .field("public_key_hash", &self.public_key_hash) + .field("wallet_id", &self.wallet_id) + .field("derivation_indices", &self.derivation_indices) + .field( + "private_key", + &self.private_key.as_ref().map(|_| "[redacted]"), + ) + .finish() + } } /// Changes to per-identity key storage. @@ -495,6 +567,21 @@ impl Merge for IdentityChangeSet { .dashpay_payments .insert(tx_id.clone(), payment.clone()); } + // Merge contact profiles (last-write-wins per contact id), + // same policy as `dashpay_payments`. + for (contact_id, profile) in &entry.contact_profiles { + existing + .contact_profiles + .insert(*contact_id, profile.clone()); + } + // Ignored senders: UNION. A sender ignored in either + // snapshot stays ignored; un-ignore is carried by an + // explicit `ContactChangeSet::unignored` removal, so a + // snapshot that no longer lists a sender must NOT silently + // un-ignore them at merge time. + existing + .ignored_senders + .extend(entry.ignored_senders.iter().copied()); }) .or_insert(entry); } @@ -600,6 +687,18 @@ pub struct ContactChangeSet { /// [`SentContactRequestKey`] since from the owner's perspective the /// contact is the "recipient" of the relationship. pub established: BTreeMap, + /// Ignored senders (per-sender mute, = block, reversible — local-only), + /// keyed by `(owner, sender)`. Suppresses ALL of the sender's incoming + /// requests (including rotated, bumped-`accountReference` ones) from the + /// main pending list, and the suppression survives a recurring re-sync. + /// Set union on merge. + pub ignored: BTreeSet<(Identifier, Identifier)>, + /// Senders **un-ignored** in this delta, keyed by `(owner, sender)`. The + /// removal tombstone for [`Self::ignored`] — the persister deletes the + /// ignored-sender row so the sender's requests resurface on the next + /// sweep. Kept as a separate set (rather than a shrinking `ignored` + /// snapshot) so the changeset's insert-XOR-tombstone discipline holds. + pub unignored: BTreeSet<(Identifier, Identifier)>, } impl Merge for ContactChangeSet { @@ -609,6 +708,8 @@ impl Merge for ContactChangeSet { self.incoming_requests.extend(other.incoming_requests); self.removed_incoming.extend(other.removed_incoming); self.established.extend(other.established); + self.ignored.extend(other.ignored); + self.unignored.extend(other.unignored); } fn is_empty(&self) -> bool { @@ -617,6 +718,8 @@ impl Merge for ContactChangeSet { && self.incoming_requests.is_empty() && self.removed_incoming.is_empty() && self.established.is_empty() + && self.ignored.is_empty() + && self.unignored.is_empty() } } @@ -904,6 +1007,128 @@ pub struct AccountAddressPoolEntry { pub addresses: Vec, } +// --------------------------------------------------------------------------- +// Deferred contact-crypto queue (seedless background-sync deferral) +// --------------------------------------------------------------------------- + +/// A DashPay contact-crypto operation that the background sync sweep could not +/// perform because key material wasn't available at the time (watch-only +/// wallet / Keychain signer not unlocked). +/// +/// The sweep runs with no signer; rather than churn (receiving account) or +/// irreversibly break the channel (external account), it **enqueues** the op +/// here and the entry is drained when a signer becomes available (Keychain +/// unlock, or any signer-present DashPay action). The queue carries **only +/// ciphertext + public key indices** — never a secret — so it is safe to +/// persist, which it must be: a restore-from-Keychain is exactly when a +/// discovered contact would otherwise be stranded. +/// +/// One op per `(owner, contact, kind)` — see [`PendingContactCryptoKey`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum PendingContactCryptoOp { + /// Derive our own DashPay receiving xpub (the friendship key) and register + /// the receiving account. No secret payload — the path is built from the + /// `(owner, contact)` identity ids. First-time only; a no-op once the + /// account is persisted. + RegisterReceiving, + /// Decrypt the contact's encrypted xpub via ECDH and register the external + /// (sending) account. Carries the on-chain ciphertext + the already- + /// validated key indices — all public. + RegisterExternal { + /// The contact's DIP-15 `encryptedPublicKey` blob (ciphertext). + encrypted_public_key: Vec, + /// Our decryption key index (validated upstream). + our_decryption_key_index: u32, + /// The contact's encryption key index (validated upstream). + contact_encryption_key_index: u32, + }, + /// Re-fetch + decrypt this identity's contactInfo documents. Idempotent; + /// carries no payload (the drain re-fetches the owned docs). + ContactInfoDecrypt, + /// Verify a DIP-15 `autoAcceptProof` on an inbound contact request and, if + /// valid + unexpired, auto-accept it (send the reciprocal). No payload — the + /// `contact_id` is the request sender; the drain re-loads the request (and + /// its proof) from the incoming-requests map. Verify + accept both need a + /// signer, so this can only run in the signer-present drain, never the sweep. + AutoAccept, +} + +/// The kind discriminant of a [`PendingContactCryptoOp`] — the part of the +/// dedup identity that ignores the (secret-free) payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum PendingContactCryptoKind { + RegisterReceiving, + RegisterExternal, + ContactInfoDecrypt, + AutoAccept, +} + +impl PendingContactCryptoOp { + /// The kind discriminant, for dedup keying. + pub fn kind(&self) -> PendingContactCryptoKind { + match self { + Self::RegisterReceiving => PendingContactCryptoKind::RegisterReceiving, + Self::RegisterExternal { .. } => PendingContactCryptoKind::RegisterExternal, + Self::ContactInfoDecrypt => PendingContactCryptoKind::ContactInfoDecrypt, + Self::AutoAccept => PendingContactCryptoKind::AutoAccept, + } + } +} + +/// One deferred contact-crypto op. The queue holds at most one entry per +/// [`key`](Self::key); re-enqueuing the same `(owner, contact, kind)` is a +/// no-op (the latest payload wins). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PendingContactCrypto { + /// The wallet-owned identity the op is for. + pub owner_identity_id: Identifier, + /// The contact identity the op concerns. + pub contact_id: Identifier, + /// What to do once a signer is available. + pub op: PendingContactCryptoOp, + /// Unix-millis enqueue time — observability / ordering only, NOT part of + /// the dedup identity. + pub enqueued_at_ms: u64, +} + +impl PendingContactCrypto { + /// The dedup identity: `(owner, contact, kind)`. + pub fn key(&self) -> PendingContactCryptoKey { + PendingContactCryptoKey { + owner_identity_id: self.owner_identity_id, + contact_id: self.contact_id, + kind: self.op.kind(), + } + } +} + +/// Dedup / removal identity for a [`PendingContactCrypto`] entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PendingContactCryptoKey { + pub owner_identity_id: Identifier, + pub contact_id: Identifier, + pub kind: PendingContactCryptoKind, +} + +/// Insert `entry` into a deferred-crypto queue, replacing any existing entry +/// with the same [`PendingContactCryptoKey`] (latest payload wins) so the +/// queue holds at most one op per `(owner, contact, kind)`. Used by both the +/// in-memory enqueue and the persisted-queue apply path. +pub fn upsert_pending_contact_crypto( + queue: &mut Vec, + entry: PendingContactCrypto, +) { + if let Some(slot) = queue.iter_mut().find(|e| e.key() == entry.key()) { + *slot = entry; + } else { + queue.push(entry); + } +} + // --------------------------------------------------------------------------- // Top-Level PlatformWalletChangeSet // --------------------------------------------------------------------------- @@ -967,6 +1192,15 @@ pub struct PlatformWalletChangeSet { /// gap-limit population) and on any pool extension / "used" flip. /// See [`AccountAddressPoolEntry`] for the merge policy. pub account_address_pools: Vec, + /// Deferred contact-crypto ops enqueued by the seedless background sweep + /// (key material unavailable). Append-only delta; apply inserts into the + /// persisted queue, deduped by [`PendingContactCryptoKey`]. Secret-free. + /// See [`PendingContactCrypto`]. + pub pending_contact_crypto_added: Vec, + /// Keys of deferred ops to remove (drained successfully, or permanently + /// failed). Append-only delta; apply removes matching `(owner, contact, + /// kind)` from the persisted queue. + pub pending_contact_crypto_cleared: Vec, /// Shielded sub-wallet deltas: per-subwallet decrypted notes, /// spent marks, sync watermarks, nullifier checkpoints. The /// commitment tree itself is **not** in here — it lives on @@ -1069,6 +1303,12 @@ impl Merge for PlatformWalletChangeSet { .extend(other.account_registrations); self.account_address_pools .extend(other.account_address_pools); + // Deferred contact-crypto queue: append-only add/clear deltas; the + // apply side dedups adds and removes cleared keys. + self.pending_contact_crypto_added + .extend(other.pending_contact_crypto_added); + self.pending_contact_crypto_cleared + .extend(other.pending_contact_crypto_cleared); #[cfg(feature = "shielded")] { self.shielded.merge(other.shielded); @@ -1090,7 +1330,9 @@ impl Merge for PlatformWalletChangeSet { .is_none_or(|m| m.is_empty()) && self.wallet_metadata.is_none() && self.account_registrations.is_empty() - && self.account_address_pools.is_empty(); + && self.account_address_pools.is_empty() + && self.pending_contact_crypto_added.is_empty() + && self.pending_contact_crypto_cleared.is_empty(); #[cfg(feature = "shielded")] { core_empty && self.shielded.as_ref().is_none_or(|s| s.is_empty()) @@ -1112,6 +1354,207 @@ mod tests { assert!(cs.is_empty()); } + /// The deferred contact-crypto queue rides the changeset as add/clear + /// deltas: a pending enqueue OR a pending clear must mark the changeset + /// non-empty (so the persist round isn't skipped and the queue survives a + /// restart), merge extends both delta vecs, and the dedup key ignores the + /// (secret-free) payload + timestamp but distinguishes the op kind. + #[test] + fn pending_contact_crypto_queue_deltas_merge_and_dedup_key() { + let owner = Identifier::from([0x11; 32]); + let contact = Identifier::from([0x22; 32]); + + let receiving = PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: 0, + }; + let external = PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterExternal { + encrypted_public_key: vec![1, 2, 3], + our_decryption_key_index: 4, + contact_encryption_key_index: 5, + }, + enqueued_at_ms: 7, + }; + + // A pending enqueue marks the changeset non-empty. + let mut cs = PlatformWalletChangeSet { + pending_contact_crypto_added: vec![receiving.clone()], + ..Default::default() + }; + assert!( + !cs.is_empty(), + "a pending enqueue must mark the changeset non-empty" + ); + + // A clear-only changeset is also non-empty (the removal must persist). + let clear_only = PlatformWalletChangeSet { + pending_contact_crypto_cleared: vec![external.key()], + ..Default::default() + }; + assert!( + !clear_only.is_empty(), + "a pending clear must mark the changeset non-empty" + ); + + // merge extends both delta vecs. + cs.merge(PlatformWalletChangeSet { + pending_contact_crypto_added: vec![external.clone()], + pending_contact_crypto_cleared: vec![receiving.key()], + ..Default::default() + }); + assert_eq!(cs.pending_contact_crypto_added.len(), 2); + assert_eq!(cs.pending_contact_crypto_cleared.len(), 1); + + // Dedup key ignores the payload + timestamp but distinguishes kind. + let external_other_payload = PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterExternal { + encrypted_public_key: vec![9, 9], + our_decryption_key_index: 4, + contact_encryption_key_index: 5, + }, + enqueued_at_ms: 999, + }; + assert_eq!( + external.key(), + external_other_payload.key(), + "same (owner, contact, kind) → same dedup key regardless of payload/timestamp" + ); + assert_ne!( + receiving.key(), + external.key(), + "different op kind → different dedup key" + ); + } + + /// `upsert_pending_contact_crypto` keeps at most one entry per + /// `(owner, contact, kind)`: a duplicate kind replaces in place (latest + /// payload + timestamp win, no growth), while a different kind is a new + /// entry. + #[test] + fn upsert_pending_contact_crypto_dedups_by_key_latest_wins() { + let owner = Identifier::from([1u8; 32]); + let contact = Identifier::from([2u8; 32]); + let mut q: Vec = Vec::new(); + + let recv = PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: 1, + }; + upsert_pending_contact_crypto(&mut q, recv.clone()); + upsert_pending_contact_crypto(&mut q, recv); + assert_eq!( + q.len(), + 1, + "re-enqueuing the same kind must not grow the queue" + ); + + // A different kind is a separate entry. + upsert_pending_contact_crypto( + &mut q, + PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterExternal { + encrypted_public_key: vec![1], + our_decryption_key_index: 0, + contact_encryption_key_index: 0, + }, + enqueued_at_ms: 2, + }, + ); + assert_eq!(q.len(), 2); + + // Same key, newer payload → replaced in place (latest wins, no growth). + upsert_pending_contact_crypto( + &mut q, + PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterExternal { + encrypted_public_key: vec![9, 9], + our_decryption_key_index: 0, + contact_encryption_key_index: 0, + }, + enqueued_at_ms: 3, + }, + ); + assert_eq!(q.len(), 2, "replacing must not grow the queue"); + let stored = q + .iter() + .find(|e| e.op.kind() == PendingContactCryptoKind::RegisterExternal) + .expect("external entry present"); + assert_eq!(stored.enqueued_at_ms, 3, "latest timestamp wins"); + match &stored.op { + PendingContactCryptoOp::RegisterExternal { + encrypted_public_key, + .. + } => assert_eq!(encrypted_public_key, &vec![9, 9], "latest payload wins"), + _ => panic!("expected RegisterExternal"), + } + } + + /// `IdentityKeyEntry`'s hand-written `Debug` must redact the private + /// scalar — the entry rides inside changesets that are logged on + /// persist errors, and `Zeroizing`'s derived `Debug` would print the + /// bytes. A recognizably-patterned secret must not appear in the output. + #[test] + fn identity_key_entry_debug_redacts_private_key() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + + let secret = [0xAB_u8; 32]; + let entry = IdentityKeyEntry { + identity_id: Identifier::from([1u8; 32]), + key_id: 0, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![0x02; 33]), + disabled_at: None, + }), + public_key_hash: [0x11; 20], + wallet_id: Some([0x9A; 32]), + derivation_indices: Some(IdentityKeyDerivationIndices { + identity_index: 1, + key_index: 0, + }), + private_key: Some(zeroize::Zeroizing::new(secret)), + }; + + let rendered = format!("{:?}", entry); + // The secret never appears, in any common byte rendering. + assert!(!rendered.contains("171, 171"), "no decimal byte run"); + assert!(!rendered.contains("ab, ab"), "no hex byte run"); + assert!(!rendered.contains("[171"), "no decimal array open"); + // Presence is still indicated, just redacted. + assert!( + rendered.contains("[redacted]"), + "private_key must render as redacted, got: {rendered}" + ); + + // A watch-only entry renders the absence as `None` (no redaction + // marker), confirming the field is faithfully Optional. + let watch_only = IdentityKeyEntry { + private_key: None, + ..entry + }; + let rendered = format!("{:?}", watch_only); + assert!(rendered.contains("private_key: None")); + } + #[test] fn test_platform_address_changeset_merge() { let wallet_id: WalletId = [9u8; 32]; diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 46945667ef8..3cf5dd28f13 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -214,7 +214,7 @@ async fn build_core_changeset( // persistence, not a re-application. // // `ChainLockProcessed` fires every time the wallet's - // `last_applied_chain_lock` advances (dashpay/rust-dashcore#769), + // `last_applied_chain_lock` advances, // even when no record was promoted — so a quiescent wallet's // boundary advance is no longer invisible to this bridge. // The earlier `TransactionsChainlocked`-only signal had a diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index dc76ddd39ac..cbd5f53a98b 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -25,9 +25,11 @@ pub mod shielded_sync_start_state; pub mod traits; pub use changeset::{ - AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, AssetLockEntry, - ContactChangeSet, ContactRequestEntry, CoreChangeSet, IdentityChangeSet, IdentityEntry, - IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, + upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry, + AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, + IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, + IdentityKeysChangeSet, KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, + PendingContactCryptoKey, PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, }; diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index c94cb7093d1..2e800f225d5 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -28,6 +28,16 @@ pub enum PlatformWalletError { #[error("Invalid identity data: {0}")] InvalidIdentityData(String), + #[error("Failed to persist state: {0}")] + /// A persister `store(...)` round failed. Returned (not swallowed) by + /// user-initiated writes whose loss leaves a silent, non-self-healing + /// broken state — e.g. a reject tombstone that, if not persisted, lets + /// the rejected contact resurrect on the next launch. The in-memory + /// mutation has already happened for this session; the error tells the + /// caller (FFI → UI) to surface the failure and retry rather than + /// reporting a success that didn't reach disk. + Persistence(String), + #[error("Contact request not found: {0}")] ContactRequestNotFound(Identifier), @@ -143,6 +153,20 @@ pub enum PlatformWalletError { #[error("Wallet is locked — unlock it before performing this operation")] WalletLocked, + #[error( + "Signer does not bind to wallet {wallet_id}: it derives a different \ + BIP44 account-0 xpub (refusing to sign with the wrong seed)" + )] + /// The host signer derives a BIP44 account-0 extended public key that does + /// not equal this wallet's persisted account xpub — the signer resolves a + /// different seed than the one that owns the wallet (e.g. a mis-mapped + /// Keychain slot). The operation is refused so a wrong seed can never sign + /// for this wallet. Surfaced by [`crate::PlatformWallet::verify_seed_binds`]. + SeedMismatch { + /// Hex of the wallet id whose binding check failed. + wallet_id: String, + }, + #[error("SPV is already running — stop it before starting again")] SpvAlreadyRunning, diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 289a71378fd..516d9108a3d 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -35,6 +35,10 @@ pub use key_wallet_manager::DerivedAddress; pub use address_paths::{ derivation_path_for_derived_address, derivation_path_string_for_derived_address, }; +pub use manager::dashpay_sync::{ + DashPaySyncManager, DashPaySyncSummary, WalletDashPaySyncOutcome, + DEFAULT_SYNC_INTERVAL_SECS as DASHPAY_SYNC_DEFAULT_INTERVAL_SECS, +}; pub use manager::identity_sync::{ IdentitySyncManager, IdentityTokenSyncInfo, IdentityTokenSyncState, DEFAULT_SYNC_INTERVAL_SECS as IDENTITY_SYNC_DEFAULT_INTERVAL_SECS, @@ -55,14 +59,15 @@ pub use wallet::core::WalletBalance; // domain (they live under `identity::types::dashpay::*` and // `identity::crypto::*` internally). pub use wallet::identity::network::{ - derive_identity_auth_keypair, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, + derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, + ContactInfoPublishOutcome, ContactInfoSealed, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use wallet::identity::{ calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, - derive_contact_payment_addresses, derive_contact_xpub, BlockTime, ContactRequest, - ContactXpubData, DashPayProfile, DpnsNameInfo, EstablishedContact, IdentityLocation, - IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PrivateKeyData, ProfileUpdate, - RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, + derive_contact_payment_addresses, derive_contact_xpub, unmask_account_reference, BlockTime, + ContactProfileEntry, ContactRequest, ContactXpubData, DashPayProfile, DpnsNameInfo, + EstablishedContact, IdentityLocation, IdentityManager, IdentityStatus, KeyStorage, + ManagedIdentity, PrivateKeyData, ProfileUpdate, RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, }; pub use wallet::platform_wallet::PlatformWalletInfo; pub use wallet::PlatformAddressTag; diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 7bf901bccf4..3ee08ea9135 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -11,6 +11,7 @@ use key_wallet::utxo::Utxo; use key_wallet::WalletCoreBalance; use crate::changeset::PlatformWalletPersistence; +use crate::manager::dashpay_sync::DashPaySyncManager; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; #[cfg(feature = "shielded")] @@ -285,6 +286,18 @@ impl PlatformWalletManager

{ Arc::clone(&self.identity_sync_manager) } + /// Access the recurring DashPay (contact-request + profile) sync + /// coordinator. + pub fn dashpay_sync(&self) -> &DashPaySyncManager { + &self.dashpay_sync_manager + } + + /// Clone the `Arc` so callers (e.g. FFI) can + /// invoke [`DashPaySyncManager::start`] which takes `&Arc`. + pub fn dashpay_sync_arc(&self) -> Arc { + Arc::clone(&self.dashpay_sync_manager) + } + /// Access the shielded sync coordinator. #[cfg(feature = "shielded")] pub fn shielded_sync(&self) -> &ShieldedSyncManager { diff --git a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs new file mode 100644 index 00000000000..02f7cdfba1c --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -0,0 +1,797 @@ +//! Periodic DashPay (contact-request + profile) sync coordinator. +//! +//! Folds the DashPay refresh into the recurring background loop, alongside +//! the platform-address, identity-token, and shielded coordinators. Before +//! this, contact requests and DashPay profiles only refreshed when the host +//! explicitly called the FFI sync entry point; there was no background DashPay +//! refresh at all. +//! +//! **Wallet-driven, not registry-driven — by design.** This coordinator is a +//! sibling of [`PlatformAddressSyncManager`](super::platform_address_sync::PlatformAddressSyncManager): +//! it holds the same `wallets` map, snapshots the wallet `Arc`s under a read +//! guard each sweep, and refreshes **every** wallet. It deliberately does NOT +//! extend [`IdentitySyncManager`](super::identity_sync::IdentitySyncManager): +//! that one is token-registry-driven and skips identities with no watched +//! tokens, so a DashPay-only identity would never sync under its gating. +//! +//! **The DashPay sync orchestration lives here**, in the coordinator +//! ([`DashPaySyncManager::sync_wallet_dashpay`]): the per-wallet refresh +//! sequences the six DashPay steps (contact requests → own profiles → contact +//! profiles → contactInfo → incoming/sent payment reconciles). Each step is an +//! `IdentityWallet` domain operation (which also has standalone on-demand FFI +//! callers); the coordinator owns only the *sequencing* and the log-and-continue +//! policy. +//! +//! Each pass: +//! 1. Snapshots the wallet map (short read lock, no await while held). +//! 2. Runs [`sync_wallet_dashpay`](DashPaySyncManager::sync_wallet_dashpay) per wallet. +//! 3. Stores the pass timestamp. +//! +//! **Error semantics: log-and-continue per wallet.** A failing per-wallet +//! refresh is logged and recorded in the pass summary; it never aborts the +//! sweep across the other wallets. Within a wallet the six steps run +//! independently — one step's failure doesn't skip the rest — and the +//! per-*identity* continue (so one identity's fetch failure doesn't abort the +//! others within a step) lives inside the steps themselves. +//! +//! `sync_now` is re-entrant-safe: if a pass is already running, calling +//! `sync_now` again returns an empty summary immediately (the caller +//! can check `is_syncing()` to distinguish). Shutdown drains an +//! in-flight pass via [`quiesce`](DashPaySyncManager::quiesce), exactly +//! like the address-sync coordinator. +//! +//! Not auto-started. Call [`DashPaySyncManager::start`] once the +//! wallets are registered and the SDK is connected. The on-demand FFI +//! entry points stay available for pull-to-refresh. + +use std::collections::BTreeMap; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex as StdMutex, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +use crate::error::PlatformWalletError; +use crate::wallet::platform_wallet::WalletId; +use crate::wallet::PlatformWallet; + +/// Default cadence for the DashPay sync loop. +/// +/// Matches Android's `PlatformSyncService` 15s ticker so new contact +/// requests, profiles, and payments surface within ~15s. The fetch is +/// incremental (high-water cursor + overlap) and profiles are throttled by +/// their own refresh window, so the tighter cadence does not multiply DAPI +/// traffic by 4. Tunable at runtime via [`DashPaySyncManager::set_interval`]. +pub const DEFAULT_SYNC_INTERVAL_SECS: u64 = 15; + +/// Outcome of syncing a single wallet's DashPay state in a pass. +#[derive(Debug)] +pub enum WalletDashPaySyncOutcome { + /// `dashpay_sync()` completed for this wallet. + Ok, + /// `dashpay_sync()` returned an error message (logged, non-fatal to + /// the rest of the pass). + Err(String), +} + +impl WalletDashPaySyncOutcome { + pub fn is_ok(&self) -> bool { + matches!(self, WalletDashPaySyncOutcome::Ok) + } +} + +/// Summary of one full DashPay sync pass across every registered wallet. +#[derive(Debug, Default)] +pub struct DashPaySyncSummary { + /// Per-wallet outcomes keyed by `WalletId`. + pub wallet_results: BTreeMap, + /// Unix seconds at which the pass completed. `0` means "no pass ran" + /// (e.g. a concurrent pass was already in flight and we skipped). + pub sync_unix_seconds: u64, +} + +impl DashPaySyncSummary { + pub fn is_empty(&self) -> bool { + self.wallet_results.is_empty() + } + + pub fn success_count(&self) -> usize { + self.wallet_results.values().filter(|o| o.is_ok()).count() + } + + pub fn error_count(&self) -> usize { + self.wallet_results.len() - self.success_count() + } +} + +/// Periodic DashPay (contact-request + profile) sync coordinator. +/// +/// Holds a handle to the same `wallets` map owned by +/// [`PlatformWalletManager`](super::PlatformWalletManager) (via `Arc`), +/// so wallets added after `start` are picked up on the next tick +/// without any re-registration — and crucially without consulting the +/// token registry, so DashPay-only identities are never skipped. +pub struct DashPaySyncManager { + wallets: Arc>>>, + /// Cancel token for the background loop, if running. + background_cancel: StdMutex>, + interval_secs: AtomicU64, + is_syncing: AtomicBool, + /// Set by [`quiesce`](Self::quiesce) to gate new passes while it + /// drains an in-flight one. `sync_now` bails (after taking the + /// `is_syncing` slot) when this is set, so once `quiesce` observes + /// `is_syncing == false` no further pass can start — giving shutdown + /// a real "no more host-visible persister stores" barrier that + /// cancel-only [`stop`](Self::stop) does not provide. + quiescing: AtomicBool, + /// Monotonic id bumped on every [`start`](Self::start). The background + /// loop captures its generation at install time and clears the stored + /// cancel token on exit **only if its generation is still current** + /// (see [`clear_cancel_if_current`](Self::clear_cancel_if_current)) — + /// the guard that prevents a stale, draining loop from nulling a newer + /// loop's token after a quick `stop()`+`start()`. + loop_generation: AtomicU64, + /// Unix seconds of the last completed pass. `0` = never. + last_sync_unix: AtomicU64, +} + +impl DashPaySyncManager { + pub fn new(wallets: Arc>>>) -> Self { + Self { + wallets, + background_cancel: StdMutex::new(None), + interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), + is_syncing: AtomicBool::new(false), + quiescing: AtomicBool::new(false), + loop_generation: AtomicU64::new(0), + last_sync_unix: AtomicU64::new(0), + } + } + + /// Set the polling interval. Clamped to a minimum of 1s. + /// + /// The running loop picks this up on its next sleep. + pub fn set_interval(&self, interval: Duration) { + let secs = interval.as_secs().max(1); + self.interval_secs.store(secs, Ordering::Release); + } + + /// Current polling interval. + pub fn interval(&self) -> Duration { + Duration::from_secs(self.interval_secs.load(Ordering::Acquire)) + } + + /// Whether the background loop is currently running. + pub fn is_running(&self) -> bool { + self.background_cancel + .lock() + .map(|g| g.is_some()) + .unwrap_or(false) + } + + /// Whether a sync pass is in flight right now. + pub fn is_syncing(&self) -> bool { + self.is_syncing.load(Ordering::Acquire) + } + + /// Unix seconds of the last completed pass, or `None` if no pass + /// has ever completed. + pub fn last_sync_unix_seconds(&self) -> Option { + match self.last_sync_unix.load(Ordering::Acquire) { + 0 => None, + n => Some(n), + } + } + + /// Start the background sync loop. Idempotent — calling while + /// already running is a no-op. + /// + /// The loop runs on a dedicated OS thread, not on a tokio worker, + /// because the SDK futures driven by `dashpay_sync` are `!Send` (the + /// gRPC client state inside the SDK isn't `Send + Sync`), so they + /// can't ride on `tokio::spawn`, which demands `Future: Send + + /// 'static`. We use [`tokio::runtime::Handle::block_on`] so the + /// future still has access to the main runtime's reactor for network + /// I/O — only the polling thread is dedicated. Mirrors the address- + /// and identity-sync coordinators. + /// + /// The first pass runs immediately; subsequent passes fire every + /// [`interval`](Self::interval). + pub fn start(self: Arc) { + let Some((cancel, my_generation)) = self.install_cancel() else { + return; + }; + + let handle = tokio::runtime::Handle::current(); + let this = self; + std::thread::Builder::new() + .name("dashpay-sync".into()) + // DashPay sync verifies GroveDB *document-query* proofs + // (contactRequest / profile fetches), whose recursive + // `verify_layer_proof_v1` descent overflows the platform + // default thread stack (SIGBUS on the stack guard, observed + // on-device 2026-06-12). The sibling sync threads survive on + // the default only because their proofs are shallower; match + // the FFI worker convention (`runtime.rs` WORKER_STACK_BYTES) + // since `Handle::block_on` polls the future on THIS thread. + .stack_size(8 * 1024 * 1024) + .spawn(move || { + handle.block_on(async move { + loop { + if cancel.is_cancelled() { + break; + } + + this.sync_now().await; + + let interval = this.interval(); + tokio::select! { + _ = tokio::time::sleep(interval) => {} + _ = cancel.cancelled() => break, + } + } + + this.clear_cancel_if_current(my_generation); + }); + }) + .expect("failed to spawn dashpay-sync thread"); + } + + /// Install a fresh cancel token for a new background loop, returning + /// the token (for the loop to watch) and its **generation** (for the + /// loop to pass to [`clear_cancel_if_current`](Self::clear_cancel_if_current) + /// on exit). Returns `None` if a loop is already running — preserving + /// `start`'s idempotency. + /// + /// The generation bump happens under the same `background_cancel` lock + /// that stores the token, so a draining older loop reading the + /// generation under that lock always observes whether a newer loop has + /// since replaced it. + fn install_cancel(&self) -> Option<(CancellationToken, u64)> { + let mut guard = self.background_cancel.lock().expect("bg_cancel poisoned"); + if guard.is_some() { + return None; + } + let cancel = CancellationToken::new(); + *guard = Some(cancel.clone()); + let generation = self + .loop_generation + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1); + Some((cancel, generation)) + } + + /// Clear the stored cancel token **only if it still belongs to the + /// loop identified by `my_generation`** — i.e. no later `start()` has + /// installed a replacement. + /// + /// Without this guard a `stop()` + quick `start()` is a use-after-free + /// hazard: `stop()` takes + cancels loop A's token and `start()` + /// installs loop B's token, but loop A keeps draining its in-flight + /// pass. When loop A finally exits, an unconditional `*guard = None` + /// would null **loop B's** live token, leaving loop B uncancellable — + /// a later shutdown `stop()`/`quiesce()` silently no-ops while loop B + /// keeps calling `persister.store(...)` through a freed FFI context. + fn clear_cancel_if_current(&self, my_generation: u64) { + let mut guard = self.background_cancel.lock().expect("bg_cancel poisoned"); + if self.loop_generation.load(Ordering::Acquire) == my_generation { + *guard = None; + } + } + + /// Stop the background sync loop. No-op if not running. + /// + /// **Cancel-only**: requests cancellation and returns immediately. A + /// pass already inside `sync_now` keeps running to completion, + /// including its per-wallet persister fan-out. For a real "nothing + /// is running and nothing more will be persisted" barrier — required + /// by manager shutdown so the host can free the persister context — + /// use [`quiesce`](Self::quiesce). + pub fn stop(&self) { + if let Some(token) = self + .background_cancel + .lock() + .expect("bg_cancel poisoned") + .take() + { + token.cancel(); + } + } + + /// Cancel the background loop **and wait for any in-flight sync pass + /// to fully drain** before returning — a real quiescence barrier, + /// unlike cancel-only [`stop`](Self::stop). + /// + /// After this returns, no sync pass is running and none can start + /// until the next [`start`](Self::start) / `sync_now`, so a caller + /// that immediately tears the manager down (and frees the host-owned + /// persister context the FFI handed to us) cannot be raced by a pass + /// that calls `persister.store(...)` through a now-dangling pointer. + /// + /// Mechanism: set the `quiescing` gate so any pass that hasn't yet + /// taken the `is_syncing` slot bails, cancel the loop, then wait for + /// `is_syncing` to clear. `is_syncing` is held for the whole pass + /// including the per-wallet persister fan-out (`sync_now` clears it + /// only after every wallet's `dashpay_sync` completes), so its + /// falling edge (with the gate up) is a sound "fully drained" + /// signal. The gate is reopened before returning so a later + /// start/sync works normally. + pub async fn quiesce(&self) { + self.quiescing.store(true, Ordering::Release); + self.stop(); + while self.is_syncing.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(20)).await; + } + self.quiescing.store(false, Ordering::Release); + } + + /// Run one DashPay sync pass across every registered wallet. + /// + /// If a pass is already in flight, returns an empty summary and + /// skips — the caller can inspect [`is_syncing`] to distinguish. + /// + /// Iterates **every** wallet in the snapshot (token registry is not + /// consulted), calling `dashpay_sync()` per wallet. Errors are + /// logged and recorded in the summary but never abort the sweep. + pub async fn sync_now(&self) -> DashPaySyncSummary { + if self + .is_syncing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return DashPaySyncSummary::default(); + } + + // A `quiesce()` may have raised the gate between our CAS and + // here; if so, release the slot and bail without running a pass + // so the drain can complete and shutdown gets a true barrier + // (no further `persister.store(...)` after quiesce returns). + if self.quiescing.load(Ordering::Acquire) { + self.is_syncing.store(false, Ordering::Release); + return DashPaySyncSummary::default(); + } + + let snapshot: Vec<(WalletId, Arc)> = { + let wallets = self.wallets.read().await; + wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() + }; + + let mut summary = DashPaySyncSummary::default(); + for (wallet_id, wallet) in snapshot { + // Log-and-continue per wallet: one wallet's failure must not + // abort DashPay sync for the others. + let outcome = match self.sync_wallet_dashpay(&wallet).await { + Ok(()) => WalletDashPaySyncOutcome::Ok, + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DashPay sync failed for wallet; continuing with the rest" + ); + WalletDashPaySyncOutcome::Err(e.to_string()) + } + }; + summary.wallet_results.insert(wallet_id, outcome); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + summary.sync_unix_seconds = now; + self.last_sync_unix.store(now, Ordering::Release); + + self.is_syncing.store(false, Ordering::Release); + + summary + } + + /// Run one wallet's comprehensive DashPay refresh. The orchestration lives + /// here in the sync coordinator; each step is an `IdentityWallet` domain + /// operation (each also has its own standalone on-demand FFI caller). + /// + /// The six steps run **independently** (log-and-continue) so a failure in + /// one does not skip the others. The two network *fetch* steps + /// (`sync_contact_requests`, `sync_profiles`) surface their first error so + /// the sweep can record this wallet as failed; the remaining steps + /// (contact profiles, contactInfo, the two payment reconciles) are + /// display- or local-only and never fail the pass. Contact requests run + /// first so freshly established contacts' accounts are registered before + /// the incoming-payment reconcile. + async fn sync_wallet_dashpay( + &self, + wallet: &Arc, + ) -> Result<(), PlatformWalletError> { + let identity = wallet.identity(); + let wallet_id = wallet.wallet_id(); + + // Contact requests first — may establish new contacts. + let contact_result = identity.sync_contact_requests().await; + if let Err(e) = &contact_result { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DashPay contact-request sync failed; continuing to profile sync" + ); + } + + // Own-identity profiles — attempted even if the contact step failed. + let profile_result = identity.sync_profiles().await; + if let Err(e) = &profile_result { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DashPay profile sync failed" + ); + } + + // Contact profiles (established contacts + pending senders) for the UI. + // Distinct target set/cache from own profiles; display-only. + if let Err(e) = identity.sync_contact_profiles().await { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DashPay contact-profile sync failed" + ); + } + + // contactInfo (alias/note/hidden) — cross-device metadata. + if let Err(e) = identity.sync_contact_infos().await { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DashPay contactInfo sync failed" + ); + } + + // Local-only: derive missing `Received` entries from receival-account + // UTXOs. After the contact step so newly established accounts exist. + if let Err(e) = identity.reconcile_incoming_payments().await { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DashPay incoming-payment reconcile failed" + ); + } + + // Local-only: confirm `Pending` `Sent` payments the persisted core + // record reports final (mined or InstantSend-locked). + if let Err(e) = identity.reconcile_sent_payments().await { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DashPay sent-payment reconcile failed" + ); + } + + // Surface the first fetch error (if any); both fetch steps have run. + contact_result?; + profile_result?; + Ok(()) + } +} + +impl std::fmt::Debug for DashPaySyncManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DashPaySyncManager") + .field("is_running", &self.is_running()) + .field("is_syncing", &self.is_syncing()) + .field("interval_secs", &self.interval_secs.load(Ordering::Acquire)) + .field("last_sync_unix", &self.last_sync_unix_seconds()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::PlatformWalletManager; + + // Canonical all-`abandon` BIP-39 test vector. Deterministic, so the + // wallet id is reproducible across runs. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + /// No-op persister: these tests don't need the real persistence + /// pipeline, just a handle satisfying the manager constructor. + struct NoopPersister; + + impl PlatformWalletPersistence for NoopPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + /// Build a manager over a mock SDK. None of the tests below reach + /// the network: the wallets they register carry zero identities, so + /// each `dashpay_sync()` iterates an empty identity set and returns + /// `Ok(())` without issuing a query. + fn make_manager() -> Arc> { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(NoopPersister); + let event_handler: Arc = Arc::new(NoopEventHandler); + Arc::new(PlatformWalletManager::new(sdk, persister, event_handler)) + } + + /// Register a fresh wallet on the manager and return its id. + /// `Some(0)` birth height skips the SPV-tip lookup so the test never + /// consults SPV. A fresh wallet carries no managed identities — so + /// it carries **zero watched tokens** in the + /// [`IdentitySyncManager`](crate::manager::identity_sync::IdentitySyncManager) + /// registry, which is exactly the case that registry-driven DashPay + /// sync would skip. + async fn register_test_wallet(manager: &Arc>) -> WalletId { + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let seed_bytes = mnemonic.to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed_bytes, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet registration should succeed"); + wallet.wallet_id() + } + + /// **The load-bearing assertion.** A recurring DashPay sync pass + /// must drive `dashpay_sync()` for **every** registered wallet — + /// including wallets whose identities watch **zero tokens**. + /// + /// `IdentitySyncManager`'s registry skips identities with empty token + /// lists (`identity_sync.rs` filters `!row.tokens.is_empty()`), so a + /// DashPay coordinator driven off that registry would never sync a + /// token-less wallet. This test pins that the sweep is **wallet-driven, + /// not registry-driven**: the wallet is present in the `wallets` map + /// but absent from the token registry, and the sweep must still visit + /// it. + #[tokio::test] + async fn recurring_pass_syncs_every_wallet_including_zero_token_identities() { + let manager = make_manager(); + let wallet_id = register_test_wallet(&manager).await; + + // Precondition: the token registry is empty (the wallet has no + // identities, hence no watched tokens). A registry-driven sweep + // would have nothing to iterate and would skip this wallet. + assert_eq!( + manager.identity_sync().try_queue_depth().unwrap_or(0), + 0, + "token registry must be empty — this is the case registry-driven DashPay would skip" + ); + + // Run one recurring DashPay sweep. + let summary = manager.dashpay_sync().sync_now().await; + + // The wallet was swept despite watching zero tokens. + assert!( + summary.wallet_results.contains_key(&wallet_id), + "recurring DashPay sweep must visit every wallet, including zero-token ones" + ); + assert_eq!( + summary.success_count(), + 1, + "the wallet's sync should succeed" + ); + assert_eq!(summary.error_count(), 0); + assert!( + manager.dashpay_sync().last_sync_unix_seconds().is_some(), + "a completed pass stamps last_sync_unix" + ); + } + + /// `sync_now` is re-entrant-safe: a second concurrent call returns an + /// empty summary and does no work while a pass is in flight. We drive + /// the real `is_syncing` lifecycle directly (the pass body itself is + /// network-bound and not easily held open in a unit test). + #[tokio::test] + async fn sync_now_is_reentrant_safe() { + let manager = make_manager(); + let mgr = manager.dashpay_sync_arc(); + + // Take the `is_syncing` slot exactly as a real pass does, so the + // concurrent `sync_now` below observes a pass in flight. + assert!( + mgr.is_syncing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + "test should own the is_syncing slot" + ); + + // While the slot is held, `sync_now` must bail with an empty + // summary rather than running a second overlapping pass. + let summary = mgr.sync_now().await; + assert!( + summary.is_empty(), + "re-entrant sync_now must return an empty summary" + ); + + // Release the slot — a subsequent pass works normally. + mgr.is_syncing.store(false, Ordering::Release); + } + + /// `quiesce()` must not return while a pass is in flight, and must + /// return promptly once the pass drains — the shutdown barrier that + /// guarantees no further `dashpay_sync()`/persister fan-out runs + /// after the manager is torn down. + /// + /// Drives the real `is_syncing` lifecycle: a background task takes the + /// slot via the same `compare_exchange` the real `sync_now` uses, + /// holds it across a sleep (standing in for the pass body), then + /// clears it. We assert `quiesce()` is still pending while the flag is + /// held and completes after it falls. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn quiesce_blocks_until_in_flight_pass_drains() { + let manager = make_manager(); + let mgr = manager.dashpay_sync_arc(); + + let holder = Arc::clone(&mgr); + let pass = tokio::spawn(async move { + assert!( + holder + .is_syncing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + "test should own the is_syncing slot" + ); + tokio::time::sleep(Duration::from_millis(200)).await; + holder.is_syncing.store(false, Ordering::Release); + }); + + while !mgr.is_syncing() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + + let quiesce_fut = mgr.quiesce(); + tokio::pin!(quiesce_fut); + + // While the pass holds the flag, quiesce must stay pending. + tokio::select! { + _ = &mut quiesce_fut => panic!("quiesce returned while a pass was in flight"), + _ = tokio::time::sleep(Duration::from_millis(50)) => {} + } + assert!(mgr.is_syncing(), "pass should still be in flight"); + + // Once the pass drains, quiesce must return. + tokio::time::timeout(Duration::from_secs(2), &mut quiesce_fut) + .await + .expect("quiesce did not return after the pass drained"); + + assert!(!mgr.quiescing.load(Ordering::Acquire)); + assert!(!mgr.is_syncing()); + pass.await.unwrap(); + } + + /// A `sync_now()` invoked while `quiescing` is set must bail without + /// running the pass — the gate that prevents a pass slipping in + /// between `quiesce`'s `stop()` and its drain. + #[tokio::test] + async fn sync_now_bails_when_quiescing() { + let manager = make_manager(); + let _wallet_id = register_test_wallet(&manager).await; + let mgr = manager.dashpay_sync_arc(); + + // Raise the gate as `quiesce()` would. + mgr.quiescing.store(true, Ordering::Release); + + let summary = mgr.sync_now().await; + + // Empty summary (the registered wallet was NOT swept), slot + // released so a later (post-quiesce) pass can still run. + assert!(summary.is_empty()); + assert!(!mgr.is_syncing()); + } + + /// Regression: a stale, draining loop's cleanup must **not** clobber a + /// newer loop's cancel token. + /// + /// The failure this pins is a use-after-free across the FFI persister. + /// `stop()` is cancel-only — it takes + cancels loop A's token but loop + /// A keeps draining its in-flight pass. A quick `start()` then installs + /// loop B's token. When loop A *finally* exits, the old code ran an + /// unconditional `*guard = None`, nulling **loop B's live token** — + /// after which `is_running()` lies (`false` while B runs) and a + /// shutdown `stop()`/`quiesce()` silently no-ops while loop B keeps + /// fanning out `persister.store(...)` through a freed context. + /// + /// We drive the token lifecycle directly (`install_cancel` / + /// `clear_cancel_if_current`) rather than spawning the real loop: the + /// loop runs on an OS thread under `Handle::block_on`, so its exit + /// timing can't be pinned deterministically. With the generation guard + /// removed (`*guard = None` unconditional) this test fails on the + /// final assertions; with the guard it passes. + #[tokio::test] + async fn stale_loop_cleanup_does_not_clobber_newer_loop_token() { + let manager = make_manager(); + let mgr = manager.dashpay_sync_arc(); + + // Loop A starts: installs token_A at generation G_A. + let (token_a, gen_a) = mgr.install_cancel().expect("first install starts a loop"); + assert!(mgr.is_running()); + + // Shutdown of loop A: stop() cancels + takes token_A immediately + // (cancel-only), but loop A is still "draining" — its cleanup has + // not run yet. + mgr.stop(); + assert!(token_a.is_cancelled()); + assert!( + !mgr.is_running(), + "stop() clears the stored token immediately" + ); + + // Loop B starts BEFORE loop A's cleanup runs: installs token_B at a + // newer generation G_B. + let (token_b, _gen_b) = mgr + .install_cancel() + .expect("second install starts a new loop"); + assert!(mgr.is_running()); + + // Loop A FINALLY drains and runs its cleanup with its own (now + // stale) generation. The guard must make this a no-op; the old + // unconditional clear would null loop B's token here. + mgr.clear_cancel_if_current(gen_a); + + // Loop B's token must still be installed and uncancelled. + assert!( + mgr.is_running(), + "stale loop A cleanup must not clobber loop B's live token" + ); + assert!(!token_b.is_cancelled()); + + // …and a real shutdown can still cancel loop B. + mgr.stop(); + assert!( + token_b.is_cancelled(), + "loop B must remain cancellable after the stale cleanup" + ); + assert!(!mgr.is_running()); + } + + /// `set_interval` clamps to >=1s and round-trips through `interval`. + /// The default matches the documented constant. + #[tokio::test] + async fn interval_round_trip() { + let manager = make_manager(); + let mgr = manager.dashpay_sync_arc(); + + assert_eq!( + mgr.interval(), + Duration::from_secs(DEFAULT_SYNC_INTERVAL_SECS) + ); + + mgr.set_interval(Duration::from_secs(0)); + assert_eq!(mgr.interval(), Duration::from_secs(1)); + + mgr.set_interval(Duration::from_secs(120)); + assert_eq!(mgr.interval(), Duration::from_secs(120)); + } +} diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 8e7af9be1c7..3656341492d 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -99,6 +99,9 @@ impl PlatformWalletManager

{ balance: Arc::clone(&balance), identity_manager: IdentityManager::from(identity_manager), tracked_asset_locks, + // Restored from the persisted queue once the storage layer + // carries it; empty until then. + pending_contact_crypto: Vec::new(), }; // Insert into `wallet_manager` first so we have a wallet diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 3d04ca086d0..968b15b6a41 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -1,6 +1,7 @@ //! Multi-wallet manager with SPV coordination. pub mod accessors; +pub mod dashpay_sync; pub mod identity_sync; mod load; pub mod platform_address_sync; @@ -18,6 +19,7 @@ use key_wallet_manager::WalletManager; use crate::changeset::{spawn_wallet_event_adapter, PlatformWalletPersistence}; use crate::events::{PlatformEventHandler, PlatformEventManager}; +use crate::manager::dashpay_sync::DashPaySyncManager; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; #[cfg(feature = "shielded")] @@ -25,6 +27,7 @@ use crate::manager::shielded_sync::ShieldedSyncManager; use crate::spv::SpvRuntime; use crate::wallet::asset_lock::LockNotifyHandler; use crate::wallet::core::BalanceUpdateHandler; +use crate::wallet::identity::network::DashPayPaymentHandler; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; @@ -52,6 +55,14 @@ pub struct PlatformWalletManager { /// wallet. Not auto-started — call `start` after wallets are /// registered. See [`IdentitySyncManager`]. pub(super) identity_sync_manager: Arc>, + /// Periodic DashPay sync coordinator. Drives `dashpay_sync()` + /// (contact requests + profiles) on **every** registered wallet + /// each sweep — wallet-driven, not token-registry-driven, so + /// DashPay-only identities are never skipped. Shares the same + /// `wallets` map as [`PlatformAddressSyncManager`]. Not + /// auto-started — call `start` after wallets are registered. See + /// [`DashPaySyncManager`]. + pub(super) dashpay_sync_manager: Arc, /// Periodic shielded (Orchard) note sync coordinator (spends are /// detected during the note scan, no separate nullifier pass). /// Iterates every wallet that has been bound via @@ -122,10 +133,20 @@ impl PlatformWalletManager

{ // with SPV's write lock. let lock_handler = Arc::new(LockNotifyHandler::new(Arc::clone(&lock_notify))); let balance_handler = Arc::new(BalanceUpdateHandler::new(Arc::clone(&wallets))); + // DashPayPaymentHandler records incoming DashPay payments and + // confirms sent ones off the wallet-event fan-out, keeping that + // domain logic out of the generic core-changeset bridge. It holds + // the wallet-manager (for the in-memory payment state it mutates) + // and the persister (to write the resulting payment rows). + let dashpay_payment_handler = Arc::new(DashPayPaymentHandler::new( + Arc::clone(&wallet_manager), + Arc::clone(&persister) as Arc, + )); let event_manager = Arc::new(PlatformEventManager::new(vec![ app_handler, lock_handler, balance_handler, + dashpay_payment_handler, ])); let spv = Arc::new(SpvRuntime::new( @@ -140,6 +161,9 @@ impl PlatformWalletManager

{ Arc::clone(&sdk), Arc::clone(&persister), )); + // DashPay sync shares the `wallets` map (not the token + // registry) so DashPay-only identities sync on every sweep. + let dashpay_sync = Arc::new(DashPaySyncManager::new(Arc::clone(&wallets))); #[cfg(feature = "shielded")] let shielded_coordinator: Arc< RwLock>>, @@ -157,6 +181,7 @@ impl PlatformWalletManager

{ spv_manager: spv, platform_address_sync_manager: platform_address_sync, identity_sync_manager: identity_sync, + dashpay_sync_manager: dashpay_sync, #[cfg(feature = "shielded")] shielded_sync_manager: shielded_sync, #[cfg(feature = "shielded")] @@ -293,9 +318,10 @@ impl PlatformWalletManager

{ /// /// **Quiesces** the periodic coordinators /// (`PlatformAddressSyncManager`, `IdentitySyncManager`, - /// `ShieldedSyncManager`) — cancelling each loop *and draining any - /// in-flight pass to completion*, including its persister / - /// host-callback fan-out — then drains the wallet-event adapter task. + /// `DashPaySyncManager`, `ShieldedSyncManager`) — cancelling each + /// loop *and draining any in-flight pass to completion*, including + /// its persister / host-callback fan-out — then drains the + /// wallet-event adapter task. /// Idempotent. Call before dropping the manager when a clean /// shutdown is required (e.g. on app termination); a dirty drop /// simply leaks the tasks until the runtime exits. @@ -311,6 +337,7 @@ impl PlatformWalletManager

{ pub async fn shutdown(&self) { self.platform_address_sync_manager.quiesce().await; self.identity_sync_manager.quiesce().await; + self.dashpay_sync_manager.quiesce().await; #[cfg(feature = "shielded")] self.shielded_sync_manager.quiesce().await; diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 25ee5df914a..51063136eeb 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -100,11 +100,18 @@ impl PlatformWalletManager

{ pub async fn create_wallet_from_seed_bytes( &self, network: Network, - seed_bytes: [u8; 64], + // By reference: the named stack copy we hold of the master secret is + // wrapped in `Zeroizing` and scrubbed on drop. (`[u8; 64]` is `Copy`, + // so a transient by-value copy still + // crosses into key-wallet's `from_seed_bytes`, which consumes it into + // its own zeroizing `Seed`; fully eliminating that residual copy + // needs `from_seed_bytes` to take `&[u8; 64]` upstream in key-wallet.) + seed_bytes: &[u8; 64], accounts: WalletAccountCreationOptions, birth_height_override: Option, ) -> Result, PlatformWalletError> { - let wallet = Wallet::from_seed_bytes(seed_bytes, network, accounts).map_err(|e| { + let seed = zeroize::Zeroizing::new(*seed_bytes); + let wallet = Wallet::from_seed_bytes(*seed, network, accounts).map_err(|e| { PlatformWalletError::WalletCreation(format!( "Failed to create wallet from seed bytes: {}", e @@ -239,6 +246,7 @@ impl PlatformWalletManager

{ balance: Arc::clone(&balance), identity_manager: crate::wallet::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + pending_contact_crypto: Vec::new(), }; wallet.downgrade_to_external_signable(); @@ -668,7 +676,7 @@ mod register_wallet_duplicate_tests { manager .create_wallet_from_seed_bytes( network, - seed_bytes, + &seed_bytes, WalletAccountCreationOptions::Default, Some(0), ) @@ -681,7 +689,7 @@ mod register_wallet_duplicate_tests { let err = manager .create_wallet_from_seed_bytes( network, - seed_bytes, + &seed_bytes, WalletAccountCreationOptions::Default, Some(0), ) diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 8c690543ee0..a1684b0b5aa 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -108,6 +108,12 @@ impl PlatformWalletInfo { wallet_metadata: _, account_registrations: _, account_address_pools: _, + // The deferred contact-crypto queue is persistence-only here too: + // the in-memory queue is mutated directly at the enqueue (sweep) + // and drain (signer-present) sites, and restored at load via the + // start-state path. No changeset-replay hook in apply. + pending_contact_crypto_added: _, + pending_contact_crypto_cleared: _, // Shielded deltas are owned by `ShieldedWallet` (which // mutates its store directly during sync / spend); the // canonical in-memory state lives there and the @@ -184,6 +190,8 @@ impl PlatformWalletInfo { incoming_requests, removed_incoming, established, + ignored, + unignored, } = contact_cs; for (key, entry) in sent_requests { @@ -235,6 +243,28 @@ impl PlatformWalletInfo { ), } } + // Ignored senders (per-sender mute, local-only). Restore the + // in-memory suppression set so the sync ingest path won't + // resurrect an ignored sender's requests after a restart. + // `unignored` is applied AFTER `ignored` so an un-ignore in the + // same delta wins (the sender ends up not ignored). Orphan + // owners are logged and skipped. + for (owner_id, sender_id) in ignored { + match self.identity_manager.managed_identity_mut(&owner_id) { + Some(managed) => { + managed.ignored_senders.insert(sender_id); + } + None => tracing::warn!( + owner = %owner_id, + "skipping ignored sender during apply: owner identity not in wallet" + ), + } + } + for (owner_id, sender_id) in unignored { + if let Some(managed) = self.identity_manager.managed_identity_mut(&owner_id) { + managed.ignored_senders.remove(&sender_id); + } + } } // 3b. DashPay profile/payment overlays. Applied AFTER identities @@ -387,6 +417,7 @@ mod tests { balance: std::sync::Arc::new(WalletBalance::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + pending_contact_crypto: Vec::new(), } } @@ -1412,7 +1443,8 @@ mod tests { .identity_manager .managed_identity_mut(&owner) .expect("a managed") - .record_dashpay_payment(tx_id.clone(), payment.clone(), &p); + .record_dashpay_payment(tx_id.clone(), payment.clone(), &p) + .expect("record"); // Build the replay changeset from A's mutated state. let managed = info_a.identity_manager.managed_identity(&owner).expect("a"); @@ -1458,7 +1490,8 @@ mod tests { .identity_manager .managed_identity_mut(&owner) .expect("a managed") - .record_dashpay_payment(tx_id.clone(), pending, &p); + .record_dashpay_payment(tx_id.clone(), pending, &p) + .expect("record"); let managed = info_a.identity_manager.managed_identity(&owner).expect("a"); let mut id_cs = IdentityChangeSet::default(); id_cs @@ -1475,7 +1508,8 @@ mod tests { .identity_manager .managed_identity_mut(&owner) .expect("a managed") - .record_dashpay_payment(tx_id.clone(), confirmed.clone(), &p); + .record_dashpay_payment(tx_id.clone(), confirmed.clone(), &p) + .expect("record"); let managed = info_a.identity_manager.managed_identity(&owner).expect("a"); let mut id_cs = IdentityChangeSet::default(); id_cs @@ -1496,6 +1530,103 @@ mod tests { ); } + /// A cached contact profile round-trips through the changeset + /// (snapshot → apply), and a later update overwrites it (full-replace, + /// last-write-wins per contact id) — so contact names/avatars survive + /// relaunch instead of vanishing. + #[test] + fn round_trip_contact_profile_persists_and_overwrites() { + use crate::wallet::identity::{ContactProfileEntry, DashPayProfile}; + + let wallet_a = build_test_wallet(); + let mut info_a = empty_info(&wallet_a); + let mut wallet_b = build_test_wallet(); + let mut info_b = empty_info(&wallet_b); + let p = noop_persister(); + + for info in [&mut info_a, &mut info_b] { + info.identity_manager + .add_identity(make_test_identity(1, 1), 0, ROUND_TRIP_WALLET_ID, &p) + .expect("add"); + } + let owner = Identifier::from([1u8; 32]); + let contact = Identifier::from([2u8; 32]); + + // Cache a contact profile on A, snapshot, apply to B. + info_a + .identity_manager + .managed_identity_mut(&owner) + .expect("a managed") + .contact_profiles + .insert( + contact, + ContactProfileEntry { + profile: Some(DashPayProfile { + display_name: Some("Bob".into()), + avatar_url: Some("https://x/b.png".into()), + ..Default::default() + }), + checked_at_ms: 100, + }, + ); + let managed = info_a.identity_manager.managed_identity(&owner).expect("a"); + let mut id_cs = IdentityChangeSet::default(); + id_cs + .identities + .insert(owner, IdentityEntry::from_managed(managed)); + info_b + .apply_changeset(&mut wallet_b, wrap_id(id_cs)) + .expect("apply profile"); + + let b_managed = info_b.identity_manager.managed_identity(&owner).expect("b"); + assert_eq!( + b_managed + .contact_profiles + .get(&contact) + .and_then(|e| e.profile.as_ref()) + .and_then(|pr| pr.display_name.as_deref()), + Some("Bob"), + "contact profile must survive the changeset round-trip" + ); + + // Contact updated their profile (removed the avatar) → overwrite. + info_a + .identity_manager + .managed_identity_mut(&owner) + .expect("a managed") + .contact_profiles + .insert( + contact, + ContactProfileEntry { + profile: Some(DashPayProfile { + display_name: Some("Bob".into()), + avatar_url: None, + ..Default::default() + }), + checked_at_ms: 200, + }, + ); + let managed = info_a.identity_manager.managed_identity(&owner).expect("a"); + let mut id_cs = IdentityChangeSet::default(); + id_cs + .identities + .insert(owner, IdentityEntry::from_managed(managed)); + info_b + .apply_changeset(&mut wallet_b, wrap_id(id_cs)) + .expect("apply updated profile"); + + let b_managed = info_b.identity_manager.managed_identity(&owner).expect("b"); + assert_eq!( + b_managed + .contact_profiles + .get(&contact) + .and_then(|e| e.profile.as_ref()) + .and_then(|pr| pr.avatar_url.clone()), + None, + "a removed avatar must be cleared on the apply side (full-replace)" + ); + } + #[test] fn round_trip_clear_dashpay_profile() { use crate::wallet::identity::DashPayProfile; diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs index c30100487dc..26fca3a3c56 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs @@ -36,10 +36,32 @@ use crate::error::PlatformWalletError; /// DashPay auto-accept feature index per DIP-15. const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; -// TODO: Where and how we use these helpers? +/// Default lifetime of a generated auto-accept QR, in seconds (1 hour). +/// +/// DIP-15 mandates only that the proof's 4-byte timestamp *is* an expiry (and the +/// hardened derivation index); it does not prescribe a value. We pick a short +/// default because the QR carries a usable (bearer) private key and auto-accept +/// is always-on (no off-switch) — see the security notes in +/// `docs/dashpay/QR_AUTO_ACCEPT_SPEC.md` §6. +pub const AUTO_ACCEPT_TTL_SECS: u32 = 3600; + +/// `key type` byte for an ECDSA_SECP256K1 auto-accept key/proof (DIP-15). +const KEY_TYPE_ECDSA: u8 = 0x00; +/// `key size` byte for a 32-byte secp256k1 private key in the `dapk` blob. +const ECDSA_KEY_SIZE: u8 = 0x20; +/// `signature size` byte for a 64-byte compact ECDSA signature in the proof. +const ECDSA_SIG_SIZE: u8 = 0x40; +/// Length of the ECDSA `dapk` key blob: `type(1)+timestamp(4)+size(1)+key(32)`. +const KEY_BLOB_LEN: usize = 38; // --------------------------------------------------------------------------- // Helpers +// +// Role mapping (DIP-15): throughout this module `sender_id` is the contact +// request's `$ownerId` — i.e. the **scanner** who sends the request — and +// `recipient_id` is `toUserId`, the **QR owner** who auto-accepts. The scanner +// signs with the owner's handed-out key; the owner verifies against its own +// re-derived key. Inverting these silently breaks verification. // --------------------------------------------------------------------------- /// Build the SHA-256 message that is signed / verified. @@ -57,25 +79,34 @@ fn build_message_hash( sha256::Hash::from_engine(engine).to_byte_array() } -/// Derive the auto-accept private key at `m/9'/coin'/16'/timestamp'`. -pub fn derive_auto_accept_private_key( - wallet: &Wallet, +/// The DIP-15 auto-accept derivation path `m/9'/coin'/16'/expiry'` (all +/// hardened). The owner derives the key here (for the QR / verify); `expiry` +/// is the hardened leaf, so it must be ≤ 2^31−1 (rejected otherwise). +pub fn auto_accept_derivation_path( network: Network, - timestamp: u32, -) -> Result { + expiry: u32, +) -> Result { let coin_type: u32 = match network { Network::Mainnet => 5, _ => 1, }; - - let path = DerivationPath::from(vec![ + Ok(DerivationPath::from(vec![ ChildNumber::from_hardened_idx(9).expect("valid"), ChildNumber::from_hardened_idx(coin_type).expect("valid"), ChildNumber::from_hardened_idx(DASHPAY_AUTO_ACCEPT_FEATURE).expect("valid"), - ChildNumber::from_hardened_idx(timestamp).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!("Invalid timestamp index: {}", e)) + ChildNumber::from_hardened_idx(expiry).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid expiry index: {}", e)) })?, - ]); + ])) +} + +/// Derive the auto-accept private key at `m/9'/coin'/16'/timestamp'`. +pub fn derive_auto_accept_private_key( + wallet: &Wallet, + network: Network, + timestamp: u32, +) -> Result { + let path = auto_accept_derivation_path(network, timestamp)?; let ext_priv = wallet.derive_extended_private_key(&path).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!("Failed to derive auto-accept key: {}", e)) @@ -95,114 +126,248 @@ pub fn derive_auto_accept_private_key( // Public API // --------------------------------------------------------------------------- -/// Generate an auto-accept proof. -/// -/// Derives the ephemeral key at `m/9'/coin'/16'/timestamp'`, then signs -/// `SHA256(sender_id || recipient_id || account_reference)` using compact -/// ECDSA. +/// Sign an auto-accept proof with the `secret_key` handed out in the QR. /// -/// # Arguments -/// -/// * `wallet` - The HD wallet containing the master key. -/// * `network` - Network for coin-type selection. -/// * `sender_id` - The identity creating the QR (proof creator). -/// * `recipient_id` - The identity that will consume the QR. -/// * `account_reference` - Account reference to bind in the proof. -/// * `timestamp` - Derivation index (typically an expiry timestamp). +/// This is the **scanner's** side: the scanner decodes the owner's auto-accept +/// private key from the `dapk` blob and signs +/// `SHA256(sender_id || recipient_id || account_reference)` (compact ECDSA), +/// binding the proof to *this* sender so a leaked key can't be replayed by a +/// different sender. `timestamp` is the expiry from the key blob, written into +/// the proof header so the owner can re-derive the key to verify. /// /// # Returns -/// /// A 70-byte proof: `key_type(1) + timestamp(4 BE) + sig_size(1) + signature(64)`. -pub fn generate_auto_accept_proof( - wallet: &Wallet, - network: Network, +pub fn sign_auto_accept_proof( + secret_key: &SecretKey, sender_id: &Identifier, recipient_id: &Identifier, account_reference: u32, timestamp: u32, -) -> Result, PlatformWalletError> { - let secret_key = derive_auto_accept_private_key(wallet, network, timestamp)?; - +) -> Vec { let msg_hash = build_message_hash(sender_id, recipient_id, account_reference); let message = Message::from_digest(msg_hash); let secp = Secp256k1::new(); - let signature = secp.sign_ecdsa(&message, &secret_key); + let signature = secp.sign_ecdsa(&message, secret_key); let sig_bytes = signature.serialize_compact(); - // Build proof bytes. - let mut proof = Vec::with_capacity(70); - proof.push(0x00); // key_type: ECDSA_SECP256K1 + let mut proof = Vec::with_capacity(1 + 4 + 1 + 64); + proof.push(KEY_TYPE_ECDSA); proof.extend_from_slice(×tamp.to_be_bytes()); // 4 bytes BE - proof.push(0x40); // sig_size: 64 + proof.push(ECDSA_SIG_SIZE); proof.extend_from_slice(&sig_bytes); // 64 bytes compact ECDSA - - Ok(proof) + proof } -/// Verify an auto-accept proof. -/// -/// Parses the proof bytes, reconstructs the expected public key by deriving -/// from the wallet at `m/9'/coin'/16'/timestamp'`, and checks the ECDSA -/// signature. -/// -/// # Note +/// Generate an auto-accept proof by deriving the owner's key from `wallet` and +/// signing — i.e. `derive_auto_accept_private_key` + [`sign_auto_accept_proof`]. /// -/// This verification requires access to the same wallet that generated the -/// proof, because the public key is derived from the wallet seed. If you only -/// have the proof and a standalone public key, use -/// [`verify_auto_accept_proof_with_pubkey`] instead (if available). +/// In the real QR flow the owner does *not* call this (it doesn't know the +/// scanner's id at QR-create time); it derives the key for the `dapk` blob and +/// the scanner signs. This helper is kept for owner-side tests / a self-check. /// -/// For a standalone (no-wallet) verification, the caller must derive or know -/// the public key externally. This function performs the full derivation. -pub fn verify_auto_accept_proof( +/// # Returns +/// A 70-byte proof: `key_type(1) + timestamp(4 BE) + sig_size(1) + signature(64)`. +pub fn generate_auto_accept_proof( wallet: &Wallet, network: Network, - proof_bytes: &[u8], sender_id: &Identifier, recipient_id: &Identifier, account_reference: u32, -) -> Result { - // Parse proof header. + timestamp: u32, +) -> Result, PlatformWalletError> { + let secret_key = derive_auto_accept_private_key(wallet, network, timestamp)?; + Ok(sign_auto_accept_proof( + &secret_key, + sender_id, + recipient_id, + account_reference, + timestamp, + )) +} + +/// The expiry timestamp embedded in a proof header (the `key index` field), or +/// `None` if the proof is too short. This is the *same* value that selects the +/// derivation index used to verify, so a forged future expiry derives a +/// different key and fails the signature check — the expiry can't be lied about +/// independently of the signature. +pub fn auto_accept_proof_expiry(proof_bytes: &[u8]) -> Option { if proof_bytes.len() < 6 { - return Ok(false); + return None; } - - let _key_type = proof_bytes[0]; - let timestamp = u32::from_be_bytes([ + Some(u32::from_be_bytes([ proof_bytes[1], proof_bytes[2], proof_bytes[3], proof_bytes[4], - ]); - let sig_len = proof_bytes[5] as usize; + ])) +} +/// Verify an auto-accept proof against a known auto-accept **public** key. +/// +/// This is the seedless verify path: the owner (recipient) re-derives its own +/// auto-accept public key at `m/9'/coin'/16'/expiry'` — through the Keychain +/// resolver, never a resident seed — and passes it here. Pure: parses the proof, +/// reconstructs `SHA256(sender_id || recipient_id || account_reference)`, and +/// checks the compact ECDSA signature. Returns `false` (never errors) on any +/// malformed input. +/// +/// Does **not** check expiry — callers acting on the proof MUST also compare +/// [`auto_accept_proof_expiry`] against the current time. (Keeping the crypto +/// check clock-free makes it deterministically testable; the acceptance path +/// pairs the two.) +pub fn verify_auto_accept_proof_with_pubkey( + pubkey: &dashcore::secp256k1::PublicKey, + proof_bytes: &[u8], + sender_id: &Identifier, + recipient_id: &Identifier, + account_reference: u32, +) -> bool { + if proof_bytes.len() < 6 { + return false; + } + let sig_len = proof_bytes[5] as usize; if sig_len != 64 || proof_bytes.len() < 6 + sig_len { - return Ok(false); + return false; } - let signature_bytes = &proof_bytes[6..6 + sig_len]; - // Derive the expected public key from the wallet. - let secret_key = derive_auto_accept_private_key(wallet, network, timestamp)?; - let secp = Secp256k1::new(); - let pubkey = dashcore::secp256k1::PublicKey::from_secret_key(&secp, &secret_key); - - // Reconstruct the message. let msg_hash = build_message_hash(sender_id, recipient_id, account_reference); let message = Message::from_digest(msg_hash); - // Parse the signature. let signature = match Signature::from_compact(signature_bytes) { Ok(s) => s, - Err(_) => return Ok(false), + Err(_) => return false, }; - // Verify. - match secp.verify_ecdsa(&message, &signature, &pubkey) { - Ok(()) => Ok(true), - Err(_) => Ok(false), + Secp256k1::new() + .verify_ecdsa(&message, &signature, pubkey) + .is_ok() +} + +/// Verify an auto-accept proof by re-deriving the expected key from `wallet`. +/// +/// Owner-side convenience that derives the auto-accept key at +/// `m/9'/coin'/16'/expiry'` from a resident `Wallet` and delegates to +/// [`verify_auto_accept_proof_with_pubkey`]. **Seedless wallets cannot use this** +/// (there is no resident `Wallet`); the drain derives the public key via the +/// `ContactCryptoProvider` and calls the pubkey variant directly. Kept for +/// owner-side tests. Does not check expiry. +pub fn verify_auto_accept_proof( + wallet: &Wallet, + network: Network, + proof_bytes: &[u8], + sender_id: &Identifier, + recipient_id: &Identifier, + account_reference: u32, +) -> Result { + let Some(timestamp) = auto_accept_proof_expiry(proof_bytes) else { + return Ok(false); + }; + let secret_key = derive_auto_accept_private_key(wallet, network, timestamp)?; + let pubkey = + dashcore::secp256k1::PublicKey::from_secret_key(&Secp256k1::new(), &secret_key); + Ok(verify_auto_accept_proof_with_pubkey( + &pubkey, + proof_bytes, + sender_id, + recipient_id, + account_reference, + )) +} + +// --------------------------------------------------------------------------- +// QR `dapk` key blob + `dash:?du=…&dapk=…` URI codecs +// --------------------------------------------------------------------------- + +fn invalid(msg: impl Into) -> PlatformWalletError { + PlatformWalletError::InvalidIdentityData(msg.into()) +} + +/// Encode the DIP-15 `dapk` key blob: +/// `key_type(1) | expiry(4 BE) | key_size(1) | key(32)` (38 bytes for ECDSA). +/// +/// The blob carries the auto-accept **private** key — a deliberate, expiry- +/// bounded bearer credential the owner shares in the QR so any scanner can +/// produce a per-sender-bound proof (DIP-15). Scoped to auto-accept only. +pub fn encode_auto_accept_key_blob(secret_key: &SecretKey, expiry: u32) -> Vec { + let mut blob = Vec::with_capacity(KEY_BLOB_LEN); + blob.push(KEY_TYPE_ECDSA); + blob.extend_from_slice(&expiry.to_be_bytes()); + blob.push(ECDSA_KEY_SIZE); + blob.extend_from_slice(&secret_key.secret_bytes()); + blob +} + +/// Decode a DIP-15 ECDSA `dapk` key blob into `(private key, expiry)`. +/// +/// # Errors +/// Rejects a blob that is not exactly 38 bytes, has a non-ECDSA key type, +/// a key size other than 32, or an invalid scalar. +pub fn decode_auto_accept_key_blob( + blob: &[u8], +) -> Result<(SecretKey, u32), PlatformWalletError> { + if blob.len() != KEY_BLOB_LEN { + return Err(invalid(format!( + "auto-accept key blob must be {KEY_BLOB_LEN} bytes, got {}", + blob.len() + ))); } + if blob[0] != KEY_TYPE_ECDSA { + return Err(invalid("unsupported auto-accept key type (expected ECDSA)")); + } + let expiry = u32::from_be_bytes([blob[1], blob[2], blob[3], blob[4]]); + if blob[5] != ECDSA_KEY_SIZE { + return Err(invalid("auto-accept key size must be 32")); + } + let secret_key = SecretKey::from_slice(&blob[6..KEY_BLOB_LEN]) + .map_err(|e| invalid(format!("invalid auto-accept private key: {e}")))?; + Ok((secret_key, expiry)) +} + +/// Build a DIP-15 contact URI: `dash:?du=&dapk=`. +pub fn encode_dashpay_contact_uri(username: &str, key_blob: &[u8]) -> String { + format!( + "dash:?du={}&dapk={}", + username, + bs58::encode(key_blob).into_string() + ) +} + +/// Parse a DIP-15 contact URI into `(username, key_blob)`. +/// +/// Accepts the contact-only form `dash:?du=…&dapk=…` (and tolerates a leading +/// address before the `?`, per the merchant variant — ignored here). Both +/// `du` and `dapk` are required; `dapk` is base58. +/// +/// # Errors +/// Rejects a non-`dash:` scheme or a URI missing either parameter / with a +/// non-base58 `dapk`. +pub fn parse_dashpay_contact_uri(uri: &str) -> Result<(String, Vec), PlatformWalletError> { + let rest = uri + .strip_prefix("dash:") + .ok_or_else(|| invalid("not a dash: URI"))?; + // Query is everything after the first '?'; an address may precede it. + let query = rest.split_once('?').map(|(_, q)| q).unwrap_or(rest); + + let mut username: Option = None; + let mut dapk: Option = None; + for pair in query.split('&') { + if let Some((k, v)) = pair.split_once('=') { + match k { + "du" => username = Some(v.to_string()), + "dapk" => dapk = Some(v.to_string()), + _ => {} + } + } + } + + let username = username.ok_or_else(|| invalid("contact URI missing du (username)"))?; + let dapk = dapk.ok_or_else(|| invalid("contact URI missing dapk (key)"))?; + let key_blob = bs58::decode(&dapk) + .into_vec() + .map_err(|e| invalid(format!("dapk is not valid base58: {e}")))?; + Ok((username, key_blob)) } // --------------------------------------------------------------------------- @@ -372,4 +537,103 @@ mod tests { assert_ne!(proof1, proof2); } + + /// The real cross-actor flow: the owner derives the key + shares it in the + /// blob; the **scanner** decodes the handed key and signs over its own id; + /// the owner verifies against its **own re-derived public key** (the seedless + /// drain path, which gets the pubkey via `provider.receiving_xpub`). Pins + /// that the scanner-signs / owner-verifies-with-pubkey split round-trips and + /// that the per-sender binding holds. + #[test] + fn cross_actor_sign_then_verify_with_pubkey() { + let wallet = test_wallet(); // the owner's wallet + let (scanner, owner) = test_ids(); + let expiry = 1_700_000_000u32; + let account_ref = 7u32; + + let owner_key = + derive_auto_accept_private_key(&wallet, Network::Testnet, expiry).expect("derive"); + let blob = encode_auto_accept_key_blob(&owner_key, expiry); + let (handed_key, decoded_expiry) = + decode_auto_accept_key_blob(&blob).expect("decode blob"); + assert_eq!(decoded_expiry, expiry); + + let proof = sign_auto_accept_proof(&handed_key, &scanner, &owner, account_ref, expiry); + assert_eq!(proof.len(), 70); + assert_eq!(auto_accept_proof_expiry(&proof), Some(expiry)); + + let pubkey = + dashcore::secp256k1::PublicKey::from_secret_key(&Secp256k1::new(), &owner_key); + assert!( + verify_auto_accept_proof_with_pubkey(&pubkey, &proof, &scanner, &owner, account_ref), + "owner verifies the scanner's proof against its own re-derived pubkey" + ); + + // Per-sender / per-account binding: a different sender or account fails. + let other = Identifier::from([0x33u8; 32]); + assert!(!verify_auto_accept_proof_with_pubkey( + &pubkey, &proof, &other, &owner, account_ref + )); + assert!(!verify_auto_accept_proof_with_pubkey( + &pubkey, &proof, &scanner, &owner, 999 + )); + } + + #[test] + fn key_blob_round_trip_and_rejects_malformed() { + let key = SecretKey::from_slice(&[0x07u8; 32]).unwrap(); + let blob = encode_auto_accept_key_blob(&key, 12345); + assert_eq!(blob.len(), 38); + assert_eq!(blob[0], 0x00); // key type + assert_eq!(blob[5], 0x20); // key size + + let (k2, e2) = decode_auto_accept_key_blob(&blob).expect("decode"); + assert_eq!(k2.secret_bytes(), key.secret_bytes()); + assert_eq!(e2, 12345); + + assert!(decode_auto_accept_key_blob(&blob[..37]).is_err(), "short"); + let mut bad_type = blob.clone(); + bad_type[0] = 0x01; + assert!(decode_auto_accept_key_blob(&bad_type).is_err(), "key type"); + let mut bad_size = blob.clone(); + bad_size[5] = 0x10; + assert!(decode_auto_accept_key_blob(&bad_size).is_err(), "key size"); + } + + #[test] + fn uri_round_trip_and_rejects_malformed() { + let key = SecretKey::from_slice(&[0x09u8; 32]).unwrap(); + let blob = encode_auto_accept_key_blob(&key, 999); + let uri = encode_dashpay_contact_uri("bobspizza", &blob); + assert!(uri.starts_with("dash:?du=bobspizza&dapk=")); + + let (u, b) = parse_dashpay_contact_uri(&uri).expect("parse"); + assert_eq!(u, "bobspizza"); + assert_eq!(b, blob); + + // Merchant variant: a leading address + extra params, du/dapk still parse. + let merchant = format!( + "dash:Xabc123?amount=0.1&du=bobspizza&dapk={}", + bs58::encode(&blob).into_string() + ); + let (u2, b2) = parse_dashpay_contact_uri(&merchant).expect("parse merchant"); + assert_eq!(u2, "bobspizza"); + assert_eq!(b2, blob); + + assert!(parse_dashpay_contact_uri("http:?du=x&dapk=y").is_err(), "scheme"); + assert!(parse_dashpay_contact_uri("dash:?dapk=abc").is_err(), "no du"); + assert!(parse_dashpay_contact_uri("dash:?du=x").is_err(), "no dapk"); + // '0','O','I','l' are not in the base58 alphabet → decode fails. + assert!(parse_dashpay_contact_uri("dash:?du=x&dapk=0OIl").is_err(), "bad b58"); + } + + #[test] + fn verify_with_pubkey_rejects_truncated_and_no_expiry() { + let key = SecretKey::from_slice(&[0x05u8; 32]).unwrap(); + let pubkey = + dashcore::secp256k1::PublicKey::from_secret_key(&Secp256k1::new(), &key); + let (s, r) = test_ids(); + assert!(!verify_auto_accept_proof_with_pubkey(&pubkey, &[0u8; 3], &s, &r, 0)); + assert_eq!(auto_accept_proof_expiry(&[0u8; 3]), None); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs new file mode 100644 index 00000000000..9273444141f --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs @@ -0,0 +1,464 @@ +//! DashPay `contactInfo` self-encryption (DIP-15). +//! +//! `contactInfo` documents carry the owner's PRIVATE per-contact metadata +//! (alias, note, hidden flag, accepted accounts) — encrypted so only the +//! owner can read them, unlike `contactRequest` payloads which are shared +//! with the counterparty via ECDH. +//! +//! **No reference client has implemented this document type yet** +//! (DashSync-iOS, dashj and dash-shared-core all lack it), so we +//! follow the DIP-15 spec exactly so a future client interops: +//! +//! - Key derivation (DIP-15): two hardened children of the identity's +//! registered ENCRYPTION key in the owner's HD tree: +//! `root / 65536' / index'` for `encToUserId`, +//! `root / 65537' / index'` for `privateData`, where `root` is the +//! identity-auth path of the key referenced by `rootEncryptionKeyIndex` +//! and `index` is `derivationEncryptionKeyIndex`. +//! - `encToUserId`: AES-256-ECB of the 32-byte contact id (two raw blocks, +//! no IV/padding — see `platform_encryption`'s rationale). +//! - `privateData`: `IV(16) ‖ AES-256-CBC(plaintext)`, where the plaintext is +//! the DIP-15 "Dash message data" (Bitcoin P2P) serialization: +//! `version (u32 LE)`, `aliasName (varstr)`, `note (varstr)`, +//! `displayHidden (u8)`, `acceptedAccounts (varInt count + u32 LE[])`. +//! `version = major << 16 | minor`: an unknown MAJOR ⇒ discard the whole +//! document; an unknown MINOR ⇒ parse the known fields and ignore trailing +//! bytes (the forward-compat seam). The contract validates `privateData` by +//! LENGTH only (48–2048 bytes; the schema's "array in cbor" description is +//! advisory, not enforced), so tiny payloads are padded with trailing zero +//! bytes to the 48-byte ciphertext floor — a reader dispatches on `version` +//! and ignores them. + +use key_wallet::bip32::ChildNumber; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use zeroize::Zeroizing; + +use key_wallet::bip32::KeyDerivationType; + +use crate::error::PlatformWalletError; +use crate::wallet::identity::network::identity_auth_derivation_path_for_type; + +/// DIP-15 child index for the `encToUserId` encryption key (2^16 — +/// "to discount other potential derivations of this key in other +/// applications"). +pub const ENC_TO_USER_ID_CHILD: u32 = 1 << 16; + +/// DIP-15 child index for the `privateData` encryption key (2^16 + 1). +pub const PRIVATE_DATA_CHILD: u32 = (1 << 16) + 1; + +/// The deployed schema's `privateData` minimum length (bytes, IV included). +const PRIVATE_DATA_MIN_LEN: usize = 48; + +/// Plaintext floor so `IV(16) ‖ AES-256-CBC/PKCS7(plaintext)` reaches the +/// 48-byte ciphertext floor: a 17-byte plaintext pads to 32 (CBC) + 16 (IV). +const MIN_PLAINTEXT_LEN: usize = PRIVATE_DATA_MIN_LEN - 16 - 15; + +/// DIP-15 `version` for the v0 field set: `major(0) << 16 | minor(0)`. +const PRIVATE_DATA_VERSION_V0: u32 = 0; + +/// The major version this codec understands. A document with a different +/// major version is discarded whole (DIP-15 §"Versioning of Private Data"). +const SUPPORTED_MAJOR: u32 = 0; + +/// The pair of AES-256 keys for one `contactInfo` document. +pub struct ContactInfoKeys { + /// Key for `encToUserId` (AES-256-ECB). + pub enc_to_user_id_key: Zeroizing<[u8; 32]>, + /// Key for `privateData` (AES-256-CBC). + pub private_data_key: Zeroizing<[u8; 32]>, +} + +/// Derive the two `contactInfo` AES keys from the wallet seed. +/// +/// `root_encryption_key_id` is the identity's registered ENCRYPTION +/// key id (the document's `rootEncryptionKeyIndex`); +/// `derivation_index` is the per-document +/// `derivationEncryptionKeyIndex`. Requires a key-resident wallet; +/// external-signable wallets have no in-process HD slot and need a +/// host-side signing hook. +pub fn derive_contact_info_keys( + wallet: &Wallet, + network: Network, + identity_index: u32, + root_encryption_key_id: u32, + derivation_index: u32, +) -> Result { + let root_path = identity_auth_derivation_path_for_type( + network, + KeyDerivationType::ECDSA, + identity_index, + root_encryption_key_id, + )?; + + let derive_child = |feature: u32| -> Result, PlatformWalletError> { + let path = root_path.extend([ + ChildNumber::from_hardened_idx(feature).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid contactInfo feature index: {e}" + )) + })?, + ChildNumber::from_hardened_idx(derivation_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid contactInfo derivation index: {e}" + )) + })?, + ]); + let ext = wallet.derive_extended_private_key(&path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive contactInfo key: {e}" + )) + })?; + Ok(Zeroizing::new(ext.private_key.secret_bytes())) + }; + + Ok(ContactInfoKeys { + enc_to_user_id_key: derive_child(ENC_TO_USER_ID_CHILD)?, + private_data_key: derive_child(PRIVATE_DATA_CHILD)?, + }) +} + +/// Decrypted `contactInfo.privateData` payload (DIP-15 v0 fields). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ContactInfoPrivateData { + /// User-chosen nickname for the contact. + pub alias_name: Option, + /// Free-form note. + pub note: Option, + /// Whether the contact is hidden / ignored (DIP-15 `displayHidden` — the + /// hide flag, also the cross-device ignore signal). + pub display_hidden: bool, + /// Accepted rotated account-references of an established contact (DIP-15 + /// `acceptedAccounts`). Empty until multi-account is populated. + pub accepted_accounts: Vec, +} + +// --- DIP-15 "Dash message data" (Bitcoin P2P) (de)serialization helpers --- + +/// Append a Bitcoin CompactSize var-int. +fn write_varint(out: &mut Vec, n: u64) { + if n < 0xFD { + out.push(n as u8); + } else if n <= 0xFFFF { + out.push(0xFD); + out.extend_from_slice(&(n as u16).to_le_bytes()); + } else if n <= 0xFFFF_FFFF { + out.push(0xFE); + out.extend_from_slice(&(n as u32).to_le_bytes()); + } else { + out.push(0xFF); + out.extend_from_slice(&n.to_le_bytes()); + } +} + +/// Append a Bitcoin variable-length string (var-int length + UTF-8 bytes). +fn write_varstr(out: &mut Vec, s: &str) { + write_varint(out, s.len() as u64); + out.extend_from_slice(s.as_bytes()); +} + +/// Bounds-checked little-endian reader over the decrypted plaintext. +struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], PlatformWalletError> { + let end = self.pos.checked_add(n).filter(|&e| e <= self.buf.len()); + let Some(end) = end else { + return Err(PlatformWalletError::InvalidIdentityData( + "contactInfo privateData is truncated".to_string(), + )); + }; + let slice = &self.buf[self.pos..end]; + self.pos = end; + Ok(slice) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u32_le(&mut self) -> Result { + let b = self.take(4)?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn varint(&mut self) -> Result { + match self.u8()? { + 0xFF => { + let b = self.take(8)?; + Ok(u64::from_le_bytes(b.try_into().expect("8 bytes"))) + } + 0xFE => Ok(self.u32_le()? as u64), + 0xFD => { + let b = self.take(2)?; + Ok(u16::from_le_bytes([b[0], b[1]]) as u64) + } + n => Ok(n as u64), + } + } + + fn varstr(&mut self) -> Result { + let len = self.varint()? as usize; + let bytes = self.take(len)?; + String::from_utf8(bytes.to_vec()).map_err(|_| { + PlatformWalletError::InvalidIdentityData( + "contactInfo privateData string is not valid UTF-8".to_string(), + ) + }) + } +} + +fn empty_to_none(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} + +/// Encode the `privateData` plaintext in the DIP-15 var-int format. +/// +/// Pads with trailing zero bytes up to [`MIN_PLAINTEXT_LEN`] so the +/// AES-256-CBC ciphertext (IV included) reaches the schema's 48-byte floor. +/// A DIP-15 reader dispatches on `version` and ignores bytes past the final +/// v0 field, so the padding round-trips invisibly. +pub fn encode_private_data(data: &ContactInfoPrivateData) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&PRIVATE_DATA_VERSION_V0.to_le_bytes()); + write_varstr(&mut out, data.alias_name.as_deref().unwrap_or("")); + write_varstr(&mut out, data.note.as_deref().unwrap_or("")); + out.push(u8::from(data.display_hidden)); + write_varint(&mut out, data.accepted_accounts.len() as u64); + for account in &data.accepted_accounts { + out.extend_from_slice(&account.to_le_bytes()); + } + if out.len() < MIN_PLAINTEXT_LEN { + out.resize(MIN_PLAINTEXT_LEN, 0); + } + out +} + +/// Decode a `privateData` plaintext (inverse of [`encode_private_data`]). +/// +/// Tolerant per DIP-15 versioning: an unknown **major** version discards the +/// whole document (`Err`); trailing bytes past the known v0 fields (padding, +/// or a higher **minor** version's extra fields) are ignored. +pub fn decode_private_data(bytes: &[u8]) -> Result { + let mut r = Reader::new(bytes); + + let version = r.u32_le()?; + let major = version >> 16; + if major != SUPPORTED_MAJOR { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "contactInfo privateData major version {major} is incompatible — discarding" + ))); + } + + let alias_name = empty_to_none(r.varstr()?); + let note = empty_to_none(r.varstr()?); + let display_hidden = r.u8()? != 0; + + let count = r.varint()?; + // Bounded by the read: a bogus huge count errors out on the first missing + // u32 (the buffer is ≤ 2048 bytes), so no unbounded allocation. + let mut accepted_accounts = Vec::new(); + for _ in 0..count { + accepted_accounts.push(r.u32_le()?); + } + + // Ignore any trailing bytes (padding / higher-minor fields). + Ok(ContactInfoPrivateData { + alias_name, + note, + display_hidden, + accepted_accounts, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + fn test_wallet() -> Wallet { + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::None) + .expect("test wallet") + } + + /// The two feature children and distinct derivation indices must + /// all yield distinct keys, deterministically. + #[test] + fn key_derivation_is_deterministic_and_domain_separated() { + let wallet = test_wallet(); + + let keys_a = derive_contact_info_keys(&wallet, Network::Testnet, 0, 2, 0).expect("derive"); + let keys_a2 = derive_contact_info_keys(&wallet, Network::Testnet, 0, 2, 0).expect("derive"); + assert_eq!( + *keys_a.enc_to_user_id_key, *keys_a2.enc_to_user_id_key, + "deterministic" + ); + assert_ne!( + *keys_a.enc_to_user_id_key, *keys_a.private_data_key, + "65536' and 65537' children must differ" + ); + + let keys_b = derive_contact_info_keys(&wallet, Network::Testnet, 0, 2, 1).expect("derive"); + assert_ne!( + *keys_a.enc_to_user_id_key, *keys_b.enc_to_user_id_key, + "derivation index must be load-bearing" + ); + } + + /// DIP-15 round-trip across present/absent strings and empty/non-empty + /// `acceptedAccounts`. + #[test] + fn private_data_dip15_round_trips() { + for data in [ + ContactInfoPrivateData { + alias_name: Some("Alice".to_string()), + note: Some("met at devnet UAT".to_string()), + display_hidden: true, + accepted_accounts: vec![1, 0xDEAD_BEEF, 42], + }, + ContactInfoPrivateData::default(), + ContactInfoPrivateData { + alias_name: None, + note: Some("note only".to_string()), + display_hidden: false, + accepted_accounts: vec![], + }, + ] { + let decoded = decode_private_data(&encode_private_data(&data)).expect("decode"); + assert_eq!(decoded, data); + } + } + + /// The exact DIP-15 wire bytes for a fixed input — pins the cross-client + /// format so a refactor can't silently change it. + #[test] + fn private_data_wire_format_byte_vector() { + let data = ContactInfoPrivateData { + alias_name: Some("AB".to_string()), + note: None, + display_hidden: true, + accepted_accounts: vec![1], + }; + let encoded = encode_private_data(&data); + assert_eq!( + encoded, + vec![ + 0x00, 0x00, 0x00, 0x00, // version = 0 + 0x02, 0x41, 0x42, // aliasName: len 2, "AB" + 0x00, // note: len 0 + 0x01, // displayHidden = 1 + 0x01, 0x01, 0x00, 0x00, 0x00, // acceptedAccounts: count 1, [1] + 0x00, 0x00, 0x00, // padding to the 17-byte plaintext floor + ], + "DIP-15 privateData wire format changed" + ); + assert_eq!(decode_private_data(&encoded).expect("decode"), data); + } + + /// Tiny payloads pad to the plaintext floor so the ciphertext clears 48 + /// bytes; the padding is ignored on decode. + #[test] + fn private_data_pads_to_plaintext_floor() { + let empty = ContactInfoPrivateData::default(); + let encoded = encode_private_data(&empty); + assert!( + encoded.len() >= MIN_PLAINTEXT_LEN, + "tiny payloads must be padded to ≥{MIN_PLAINTEXT_LEN} plaintext bytes (got {})", + encoded.len() + ); + assert_eq!( + decode_private_data(&encoded).expect("decode padded"), + empty, + "padding must be ignored" + ); + } + + /// Forward-compat: a v0 decoder reading bytes with extra trailing data + /// (a higher minor version's fields) parses the v0 fields and ignores + /// the rest — DIP-15's minor-version rule. + #[test] + fn decode_ignores_trailing_higher_minor_fields() { + let data = ContactInfoPrivateData { + alias_name: Some("X".to_string()), + note: None, + display_hidden: false, + accepted_accounts: vec![7], + }; + let mut wire = encode_private_data(&data); + // Append junk standing in for a future minor field after the v0 fields. + wire.extend_from_slice(&[0xAB, 0xCD, 0xEF, 0x99, 0x01]); + assert_eq!( + decode_private_data(&wire).expect("decode"), + data, + "trailing higher-minor bytes must be ignored" + ); + } + + /// An unknown MAJOR version discards the whole document. + #[test] + fn decode_rejects_incompatible_major() { + let mut wire = encode_private_data(&ContactInfoPrivateData::default()); + // Set major = 1 (version = 1 << 16) — incompatible. + wire[0..4].copy_from_slice(&(1u32 << 16).to_le_bytes()); + assert!( + decode_private_data(&wire).is_err(), + "an unknown major version must be rejected, not partially parsed" + ); + } + + /// A truncated payload errors rather than panicking. + #[test] + fn decode_truncated_errors() { + assert!(decode_private_data(&[0x00, 0x00]).is_err()); + // version ok, but aliasName claims 5 bytes that aren't there. + assert!(decode_private_data(&[0x00, 0x00, 0x00, 0x00, 0x05, 0x41]).is_err()); + } + + /// End-to-end: derive keys, encrypt both fields, decrypt both — and the + /// ciphertext blob respects the schema's 48..=2048 bounds. + #[test] + fn full_contact_info_encryption_round_trip() { + let wallet = test_wallet(); + let keys = derive_contact_info_keys(&wallet, Network::Testnet, 0, 2, 0).expect("derive"); + + let contact_id = [0x5Au8; 32]; + let enc = + platform_encryption::encrypt_enc_to_user_id(&keys.enc_to_user_id_key, &contact_id); + assert_eq!( + platform_encryption::decrypt_enc_to_user_id(&keys.enc_to_user_id_key, &enc), + contact_id + ); + + let data = ContactInfoPrivateData { + alias_name: Some("Bob".to_string()), + note: None, + display_hidden: false, + accepted_accounts: vec![3], + }; + let iv = [0x77u8; 16]; + let blob = platform_encryption::encrypt_private_data( + &keys.private_data_key, + &iv, + &encode_private_data(&data), + ); + assert!( + (PRIVATE_DATA_MIN_LEN..=2048).contains(&blob.len()), + "blob must satisfy the schema's 48..=2048 bounds, got {}", + blob.len() + ); + let plain = + platform_encryption::decrypt_private_data(&keys.private_data_key, &blob).expect("dec"); + assert_eq!(decode_private_data(&plain).expect("decode"), data); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs index b8bb3c0c6f9..799c86917cf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs @@ -26,8 +26,6 @@ //! - [DIP-14](https://github.com/dashpay/dips/blob/master/dip-0014.md) //! - [DIP-15](https://github.com/dashpay/dips/blob/master/dip-0015.md) -use dashcore::hashes::hmac::{Hmac, HmacEngine}; -use dashcore::hashes::{sha256, Hash, HashEngine}; use dashcore::secp256k1::Secp256k1; use dashcore::{Address, Network, PublicKey}; use dpp::prelude::Identifier; @@ -49,12 +47,28 @@ use crate::error::PlatformWalletError; pub struct ContactXpubData { /// The full extended public key for this contact relationship. pub xpub: ExtendedPubKey, - /// Parent key fingerprint (first 4 bytes of HASH160 of parent public key). - pub parent_fingerprint: [u8; 4], - /// Chain code from the derived key (32 bytes). - pub chain_code: [u8; 32], - /// Compressed public key (33 bytes, starts with 0x02 or 0x03). - pub public_key: [u8; 33], + /// The DIP-15 compact components (`parent_fingerprint ‖ chain_code ‖ + /// public_key`) of `xpub` — the wire form fed into `encryptedPublicKey`. + /// Reuses [`platform_encryption::CompactXpub`] rather than duplicating + /// the three byte arrays. + pub compact: platform_encryption::CompactXpub, +} + +impl ContactXpubData { + /// Assemble the DIP-15 compact extended-public-key plaintext (69 bytes). + /// + /// Layout: `parent_fingerprint(4) ‖ chain_code(32) ‖ public_key(33)`. + /// + /// This is the plaintext that must be encrypted into `encryptedPublicKey` + /// per DIP-15 — **not** [`ExtendedPubKey::encode()`], which for the DashPay + /// receiving path emits the 107-byte DIP-14 serialization (extra + /// version/depth/child-number metadata) and encrypts to 128 bytes, failing + /// the contract's `maxItems: 96`. Both reference clients (iOS + /// dash-shared-core, Android dashj `serializeContactPub`) emit exactly this + /// 69-byte form. + pub fn compact_xpub(&self) -> [u8; platform_encryption::COMPACT_XPUB_LEN] { + self.compact.to_bytes() + } } // --------------------------------------------------------------------------- @@ -109,68 +123,73 @@ pub fn derive_contact_xpub( PlatformWalletError::InvalidIdentityData(format!("Failed to derive contact xpub: {}", e)) })?; - let parent_fingerprint = xpub.parent_fingerprint.to_bytes(); - let chain_code = xpub.chain_code.to_bytes(); - let public_key = xpub.public_key.serialize(); + let compact = platform_encryption::CompactXpub { + parent_fingerprint: xpub.parent_fingerprint.to_bytes(), + chain_code: xpub.chain_code.to_bytes(), + public_key: xpub.public_key.serialize(), + }; - Ok(ContactXpubData { - xpub, - parent_fingerprint, - chain_code, - public_key, - }) + Ok(ContactXpubData { xpub, compact }) } // --------------------------------------------------------------------------- -// Account reference (DIP-15) +// Contact xpub reconstruction (DIP-15 receive side) // --------------------------------------------------------------------------- -/// Calculate the account reference per DIP-15. +/// Reconstruct a contact's [`ExtendedPubKey`] from the DIP-15 compact form. /// -/// ```text -/// ASK = HMAC-SHA256(sender_secret_key, encoded_xpub_bytes) -/// ASK28 = first_4_bytes_of(ASK) >> 4 // 28 MSBs -/// shortened = account_index & 0x0FFF_FFFF // 28 low bits -/// version_hi = version << 28 // 4 high bits -/// result = version_hi | (ASK28 ^ shortened) -/// ``` +/// The wire format (`encryptedPublicKey`) carries only +/// `parent_fingerprint ‖ chain_code ‖ public_key` — it deliberately omits the +/// BIP32/DIP-14 version/depth/child-number metadata. To rebuild an +/// `ExtendedPubKey` we synthesize that metadata from the known derivation-path +/// context: both parties know this key sits at the leaf of the friendship path +/// `m/9'/coin'/15'/0'//`, i.e. **depth 6** with a +/// non-hardened 256-bit child. /// -/// The `sender_secret_key` is the raw 32-byte ECDH private key of the sender. -/// The `contact_xpub` is the extended public key for the contact relationship. +/// **Correctness:** only `chain_code` + `public_key` feed non-hardened +/// `ckd_pub` ([`derive_contact_payment_address`]), so the synthesized +/// depth/child-number are pure metadata and do not affect derived payment +/// addresses (pinned by `reconstructed_xpub_derives_identical_addresses`). The +/// `child_number` is set to a `Normal256` zero index purely so the +/// reconstructed key is non-hardened (a hardened child would refuse `ckd_pub`). /// /// # Arguments -/// -/// * `sender_secret_key` - 32-byte ECDH secret key material. -/// * `contact_xpub` - The contact relationship extended public key. -/// * `account_index` - The account index used in the derivation path. -/// * `version` - Protocol version (0..15), placed in top 4 bits. -pub fn calculate_account_reference( - sender_secret_key: &[u8; 32], - contact_xpub: &ExtendedPubKey, - account_index: u32, - version: u32, -) -> u32 { - // Serialize the extended public key (uses DIP-14 256-bit encoding if the - // child number is 256-bit, otherwise standard 78-byte BIP32 encoding). - let xpub_bytes = contact_xpub.encode(); - - // HMAC-SHA256(senderSecretKey, extendedPublicKey) - let mut engine = HmacEngine::::new(sender_secret_key); - engine.input(&xpub_bytes); - let ask = Hmac::::from_engine(engine); - - // Take the 28 most significant bits: read first 4 bytes as big-endian u32, - // then right-shift by 4 to discard the 4 least significant bits. - let ask_bytes = ask.to_byte_array(); - let ask28 = u32::from_be_bytes([ask_bytes[0], ask_bytes[1], ask_bytes[2], ask_bytes[3]]) >> 4; - - // Combine version (4 high bits) with XOR of ASK28 and shortened account bits. - let shortened_account_bits = account_index & 0x0FFF_FFFF; - let version_bits = version << 28; - - version_bits | (ask28 ^ shortened_account_bits) +/// * `compact` - the parsed [`CompactXpub`] components from the wire form. +/// * `network` - Network for address encoding (from path context). +pub fn reconstruct_contact_xpub( + compact: platform_encryption::CompactXpub, + network: Network, +) -> Result { + let public_key = + dashcore::secp256k1::PublicKey::from_slice(&compact.public_key).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Compact contact xpub has an invalid compressed public key: {e}" + )) + })?; + + Ok(ExtendedPubKey { + network, + // Friendship-path leaf depth (m/9'/coin'/15'/0'/sender/recipient). + depth: 6, + parent_fingerprint: key_wallet::bip32::Fingerprint::from_bytes(compact.parent_fingerprint), + // Non-hardened so ckd_pub is permitted; index value is irrelevant to + // non-hardened child derivation, which keys only off chain_code+pubkey. + child_number: ChildNumber::Normal256 { index: [0u8; 32] }, + public_key, + chain_code: key_wallet::bip32::ChainCode::from_bytes(compact.chain_code), + }) } +// --------------------------------------------------------------------------- +// Account reference (DIP-15) +// --------------------------------------------------------------------------- + +// The DIP-15 `accountReference` HMAC + masking moved to `platform-encryption` +// (alongside the other DIP-15 ECDH/AES crypto) so the Keychain signer in +// `rs-sdk-ffi` can reuse the single source. Re-exported so existing callers and +// the crypto-module / lib re-exports are unchanged. +pub use platform_encryption::{calculate_account_reference, unmask_account_reference}; + // --------------------------------------------------------------------------- // Contact payment address derivation // --------------------------------------------------------------------------- @@ -241,7 +260,6 @@ pub const DEFAULT_CONTACT_GAP_LIMIT: u32 = 10; #[cfg(test)] mod tests { use super::*; - use key_wallet::bip32::ExtendedPrivKey; use key_wallet::wallet::initialization::WalletAccountCreationOptions; /// Helper: create a deterministic wallet from a fixed seed. @@ -279,11 +297,11 @@ mod tests { // Path: m/9'/1'/15'/0'/(sender)/(recipient) = depth 6 assert_eq!(data.xpub.depth, 6); assert_eq!(data.xpub.network, Network::Testnet); - assert_eq!(data.parent_fingerprint.len(), 4); - assert_eq!(data.chain_code.len(), 32); - assert_eq!(data.public_key.len(), 33); + assert_eq!(data.compact.parent_fingerprint.len(), 4); + assert_eq!(data.compact.chain_code.len(), 32); + assert_eq!(data.compact.public_key.len(), 33); // Compressed public key prefix - assert!(data.public_key[0] == 0x02 || data.public_key[0] == 0x03); + assert!(data.compact.public_key[0] == 0x02 || data.compact.public_key[0] == 0x03); } #[test] @@ -315,8 +333,8 @@ mod tests { // Both should be valid derivations (may produce same key if index // is not part of derivation path). - assert!(!data0.public_key.is_empty()); - assert!(!data1.public_key.is_empty()); + assert!(!data0.compact.public_key.is_empty()); + assert!(!data1.compact.public_key.is_empty()); } #[test] @@ -330,47 +348,11 @@ mod tests { .expect("recipient->sender"); assert_ne!( - forward.public_key, reverse.public_key, + forward.compact.public_key, reverse.compact.public_key, "Swapping sender/recipient should produce different keys" ); } - #[test] - fn test_account_reference_version_bits() { - let secret_key = [1u8; 32]; - let master_xprv = ExtendedPrivKey::new_master(Network::Testnet, &[2u8; 64]).unwrap(); - let secp = Secp256k1::new(); - let xpub = ExtendedPubKey::from_priv(&secp, &master_xprv); - - // Version 0 - let ref_v0 = calculate_account_reference(&secret_key, &xpub, 0, 0); - assert_eq!(ref_v0 >> 28, 0, "Version 0 → top 4 bits = 0"); - - // Version 1 - let ref_v1 = calculate_account_reference(&secret_key, &xpub, 0, 1); - assert_eq!(ref_v1 >> 28, 1, "Version 1 → top 4 bits = 1"); - - // Version 15 (maximum) - let ref_v15 = calculate_account_reference(&secret_key, &xpub, 0, 15); - assert_eq!(ref_v15 >> 28, 15, "Version 15 → top 4 bits = 15"); - } - - #[test] - fn test_account_reference_deterministic() { - let secret_key = [0xABu8; 32]; - let master_xprv = ExtendedPrivKey::new_master(Network::Testnet, &[0xCDu8; 64]).unwrap(); - let secp = Secp256k1::new(); - let xpub = ExtendedPubKey::from_priv(&secp, &master_xprv); - - let ref1 = calculate_account_reference(&secret_key, &xpub, 0, 0); - let ref2 = calculate_account_reference(&secret_key, &xpub, 0, 0); - - assert_eq!( - ref1, ref2, - "Same inputs should produce same account reference" - ); - } - #[test] fn test_contact_payment_address_derivation() { let wallet = test_wallet(Network::Testnet); @@ -443,4 +425,77 @@ mod tests { fn test_default_gap_limit() { assert_eq!(DEFAULT_CONTACT_GAP_LIMIT, 10); } + + #[test] + fn compact_xpub_is_69_byte_dip15_plaintext_not_107_byte_encode() { + // The send path must encrypt the DIP-15 compact + // plaintext (fingerprint ‖ chaincode ‖ pubkey = 69 bytes), NOT + // `ExtendedPubKey::encode()`, which for the DashPay receiving path is + // the 107-byte DIP-14 serialization (ends in a Normal256 child) and + // encrypts to 128 bytes — failing the contract's maxItems: 96. + // + // The earlier `account_xpub.encode()` producer emitted the 107-byte + // form (107 != 69); this assertion pins the byte-exact compact layout + // so a revert to `encode()` is caught. + let wallet = test_wallet(Network::Testnet); + let (sender, recipient) = test_identifiers(); + + let data = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("derive contact xpub"); + + let compact = data.compact_xpub(); + + // The DashPay receiving path ends in a Normal256 child, so encode() is + // the 107-byte DIP-14 form. Prove the two are genuinely different. + let encoded = data.xpub.encode(); + assert_eq!( + encoded.len(), + 107, + "DashPay account xpub encodes to 107 bytes" + ); + assert_eq!(compact.len(), 69, "compact plaintext must be 69 bytes"); + + // Byte-exact layout: fingerprint ‖ chaincode ‖ compressed pubkey. + assert_eq!(&compact[0..4], &data.compact.parent_fingerprint); + assert_eq!(&compact[4..36], &data.compact.chain_code); + assert_eq!(&compact[36..69], &data.compact.public_key); + + // And it round-trips through the codec. + let parsed = platform_encryption::parse_compact_xpub(&compact).expect("parse compact"); + assert_eq!(parsed.parent_fingerprint, data.compact.parent_fingerprint); + assert_eq!(parsed.chain_code, data.compact.chain_code); + assert_eq!(parsed.public_key, data.compact.public_key); + } + + #[test] + fn reconstructed_xpub_derives_identical_addresses() { + // Receive-side correctness. After compacting a contact xpub to 69 + // bytes and reconstructing an ExtendedPubKey from + // (chain_code, public_key) with synthesized depth/child-number, address + // derivation MUST produce the same addresses as the original xpub — + // because non-hardened ckd_pub uses only chain_code + public_key. + let wallet = test_wallet(Network::Testnet); + let (sender, recipient) = test_identifiers(); + + let data = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("derive contact xpub"); + + let compact = data.compact_xpub(); + let parsed = platform_encryption::parse_compact_xpub(&compact).expect("parse compact"); + let reconstructed = + reconstruct_contact_xpub(parsed, Network::Testnet).expect("reconstruct from compact"); + + for i in 0..6u32 { + let from_original = derive_contact_payment_address(&data.xpub, i, Network::Testnet) + .expect("derive from original"); + let from_reconstructed = + derive_contact_payment_address(&reconstructed, i, Network::Testnet) + .expect("derive from reconstructed"); + assert_eq!( + from_original, from_reconstructed, + "address {} differs between original and reconstructed xpub", + i + ); + } + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index 6be8cf89c8b..d777d0c3f24 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -4,11 +4,16 @@ //! paths, and bytes. pub mod auto_accept; +pub mod contact_info; pub mod dip14; pub mod validation; pub use auto_accept::derive_auto_accept_private_key; +pub use contact_info::{ + decode_private_data, derive_contact_info_keys, encode_private_data, ContactInfoKeys, + ContactInfoPrivateData, +}; pub use dip14::{ calculate_account_reference, derive_contact_payment_address, derive_contact_payment_addresses, - derive_contact_xpub, ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, + derive_contact_xpub, unmask_account_reference, ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs index 9152a5f5d73..d14ca94dd02 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs @@ -16,6 +16,29 @@ pub struct ContactRequestValidation { pub errors: Vec, /// Non-fatal warnings the caller may want to surface. pub warnings: Vec, + /// `true` when a key-PURPOSE mismatch was seen (e.g. a legacy 2024 doc + /// referencing an AUTHENTICATION key). + /// + /// This classification is load-bearing for the sync sweep / accept paths: + /// a purpose mismatch must NOT mark the payment channel + /// **permanently** broken — on-chain history demonstrably contains + /// nonconforming-but-honest documents, and our acceptance policy (not the + /// immutable request) is what might change. A purpose-only failure is a + /// non-permanent skip (log + retry next sweep); a key-TYPE / missing-key / + /// disabled-key failure stays permanent. + /// + /// **Read [`is_purpose_only`](Self::is_purpose_only), not this field, to + /// decide skip-vs-break.** This flag alone is `true` even when a hard + /// (non-purpose) error is *also* present; downgrading to a skip in that + /// case would mask a genuinely permanent failure (a disabled / wrong-type + /// key) into a retry-forever loop. + pub purpose_mismatch: bool, + /// `true` when at least one *non-purpose* hard error was recorded (missing + /// key, wrong key type, disabled key). Distinguishes "purpose mismatch is + /// the sole cause" (downgradable to a skip) from "purpose mismatch plus a + /// genuinely permanent fault" (must stay permanent). See + /// [`is_purpose_only`](Self::is_purpose_only). + pub hard_error: bool, } impl Default for ContactRequestValidation { @@ -24,6 +47,8 @@ impl Default for ContactRequestValidation { is_valid: true, errors: Vec::new(), warnings: Vec::new(), + purpose_mismatch: false, + hard_error: false, } } } @@ -34,10 +59,23 @@ impl ContactRequestValidation { Self::default() } - /// Add a hard error (sets `is_valid = false`). + /// Add a hard (non-purpose) error: sets `is_valid = false` AND flags + /// `hard_error` so a co-occurring purpose mismatch can't downgrade this + /// genuinely-permanent fault to a skip. pub fn add_error(&mut self, error: String) { self.errors.push(error); self.is_valid = false; + self.hard_error = true; + } + + /// Add a key-PURPOSE error: sets `is_valid = false` AND flags + /// `purpose_mismatch` so callers can downgrade a *purpose-only* failure + /// to a non-permanent skip rather than a permanent broken-channel mark. + /// Does NOT set `hard_error`. + pub fn add_purpose_error(&mut self, error: String) { + self.errors.push(error); + self.is_valid = false; + self.purpose_mismatch = true; } /// Add a non-fatal warning. @@ -45,6 +83,14 @@ impl ContactRequestValidation { self.warnings.push(warning); } + /// Whether the *sole* cause of invalidity is a key-purpose mismatch — + /// the only case that may be downgraded to a non-permanent skip. + /// A purpose mismatch that co-occurs with a hard error (disabled / + /// missing / wrong-type key) is NOT purpose-only and must stay permanent. + pub fn is_purpose_only(&self) -> bool { + self.purpose_mismatch && !self.hard_error + } + /// Merge another validation result into this one. pub fn merge(&mut self, other: ContactRequestValidation) { self.errors.extend(other.errors); @@ -52,27 +98,46 @@ impl ContactRequestValidation { if !other.is_valid { self.is_valid = false; } + if other.purpose_mismatch { + self.purpose_mismatch = true; + } + if other.hard_error { + self.hard_error = true; + } } } -/// Validate a contact request before sending. +/// Validate a contact request against the verified on-chain envelope. /// -/// Checks that the sender identity has a suitable ENCRYPTION key at -/// `sender_key_index` and the recipient identity has a suitable DECRYPTION -/// key at `recipient_key_index`. +/// The empirical testnet census (368 docs) shows two live +/// honest cohorts: the dominant mobile population references an **unbound +/// ENCRYPTION key for BOTH indices** (mobile identities carry no DECRYPTION +/// key), and the newest cohort uses bound **ENCRYPTION(sender) / +/// DECRYPTION(recipient)** — our original convention. Consensus enforces +/// neither purpose nor boundedness on these integer fields. This validator is +/// therefore *liberal on receive*: it accepts the purposes mobile actually +/// uses while keeping the ECDSA key-*type* gate (every observed key is +/// ECDSA_SECP256K1) and the disabled-key check. /// /// # Checks performed /// /// **Sender key:** /// - Key at `sender_key_index` exists on the sender identity. /// - Key type is `ECDSA_SECP256K1` (required for ECDH). -/// - Key purpose is `ENCRYPTION`. +/// - Key purpose is `ENCRYPTION` (bound or unbound) — a non-ENCRYPTION +/// purpose is flagged as a `purpose_mismatch` (non-permanent). /// - Key is not disabled. /// -/// **Recipient key:** +/// **Recipient key (our key):** /// - Key at `recipient_key_index` exists on the recipient identity. /// - Key type is compatible (`ECDSA_SECP256K1` or `ECDSA_HASH160`). +/// - Key purpose is `ENCRYPTION` **or** `DECRYPTION` — anything else +/// (AUTHENTICATION/MASTER/TRANSFER) is flagged as a `purpose_mismatch`. /// - Key is not disabled. +/// +/// A failure whose *only* cause is a purpose mismatch sets +/// [`ContactRequestValidation::purpose_mismatch`], signalling callers to skip +/// (and retry) rather than permanently break the channel. pub fn validate_contact_request( sender_identity: &Identity, sender_key_index: u32, @@ -95,9 +160,11 @@ pub fn validate_contact_request( )); } - // Must have ENCRYPTION purpose. + // Must have ENCRYPTION purpose (bound or unbound — both live + // cohorts use ENCRYPTION for the sender). A non-ENCRYPTION + // purpose is a non-permanent purpose mismatch. if key.purpose() != Purpose::ENCRYPTION { - validation.add_error(format!( + validation.add_purpose_error(format!( "Sender key {} has purpose {:?}, but ENCRYPTION is required for contact requests", sender_key_index, key.purpose(), @@ -146,6 +213,23 @@ pub fn validate_contact_request( } } + // Purpose must be ENCRYPTION or DECRYPTION: the mobile + // cohort's recipientKeyIndex points at an ENCRYPTION key, the + // newest cohort's at a DECRYPTION key — both honest. Anything + // else (AUTHENTICATION/MASTER/TRANSFER) is a non-permanent purpose + // mismatch: legacy 2024 docs reference AUTHENTICATION keys, so we + // skip-and-retry rather than permanently break the channel. + match key.purpose() { + Purpose::ENCRYPTION | Purpose::DECRYPTION => {} + other => { + validation.add_purpose_error(format!( + "Recipient key {} has purpose {:?}, but ENCRYPTION or DECRYPTION is \ + required for contact requests", + recipient_key_index, other, + )); + } + } + // Must not be disabled. if let Some(disabled_at) = key.disabled_at() { validation.add_error(format!( @@ -332,4 +416,187 @@ mod tests { assert_eq!(a.errors.len(), 1); assert_eq!(a.warnings.len(), 1); } + + // ----------------------------------------------------------------------- + // Key-purpose alignment. The verified testnet reality + // (368 on-chain docs): the dominant mobile cohort + // references an UNBOUND ENCRYPTION key for BOTH senderKeyIndex and + // recipientKeyIndex (mobile identities carry no DECRYPTION key); the + // newest cohort uses bound ENCRYPTION(sender)/DECRYPTION(recipient). + // Consensus enforces neither purpose nor boundedness. So the validator + // must accept ENCRYPTION for the sender and ENCRYPTION-or-DECRYPTION for + // the recipient, keep the ECDSA type gate, and reject AUTHENTICATION. + // ----------------------------------------------------------------------- + + /// Mobile-cohort shape: sender references an ENCRYPTION key, recipient + /// (our key) is ALSO an ENCRYPTION key (mobile identities have no + /// DECRYPTION key). This must pass. The companion AUTHENTICATION test + /// below pins the recipient-purpose gate: without that gate an + /// AUTHENTICATION recipient key is silently accepted (it "passes" for + /// the wrong reason). + #[test] + fn mobile_cohort_recipient_encryption_key_is_accepted() { + let sender = make_identity(vec![make_key( + 2, + KeyType::ECDSA_SECP256K1, + Purpose::ENCRYPTION, + )]); + let recipient = make_identity(vec![make_key( + 2, + KeyType::ECDSA_SECP256K1, + Purpose::ENCRYPTION, + )]); + + let result = validate_contact_request(&sender, 2, &recipient, 2); + assert!( + result.is_valid, + "mobile-cohort ENC/ENC request must validate, errors: {:?}", + result.errors + ); + assert!(!result.purpose_mismatch); + } + + /// A recipient key of purpose AUTHENTICATION must FAIL validation (legacy + /// 2024 cohort / test-noise shape). Without the recipient-purpose gate an + /// AUTHENTICATION recipient key is silently accepted and a wrong shared + /// secret could be derived. + #[test] + fn recipient_authentication_key_is_rejected_as_purpose_mismatch() { + let sender = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::ENCRYPTION, + )]); + let recipient = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::AUTHENTICATION, + )]); + + let result = validate_contact_request(&sender, 0, &recipient, 0); + assert!( + !result.is_valid, + "an AUTHENTICATION recipient key must be rejected" + ); + assert!( + result.purpose_mismatch, + "an AUTHENTICATION recipient is a PURPOSE mismatch (non-permanent skip), not a hard/permanent failure" + ); + assert!(result.errors.iter().any(|e| e.contains("ENCRYPTION") + || e.contains("DECRYPTION") + || e.contains("purpose"))); + } + + /// Sender ENCRYPTION + recipient DECRYPTION (our existing convention, + /// the newest 2026 cohort) still validates and is not a purpose mismatch. + #[test] + fn bound_convention_enc_dec_still_validates() { + let sender = make_identity(vec![make_key( + 4, + KeyType::ECDSA_SECP256K1, + Purpose::ENCRYPTION, + )]); + let recipient = make_identity(vec![make_key( + 5, + KeyType::ECDSA_SECP256K1, + Purpose::DECRYPTION, + )]); + + let result = validate_contact_request(&sender, 4, &recipient, 5); + assert!(result.is_valid, "errors: {:?}", result.errors); + assert!(!result.purpose_mismatch); + } + + /// A sender key of purpose AUTHENTICATION is a purpose mismatch (the + /// classification flag must be set so the sweep/accept paths skip rather + /// than permanently break the channel). + #[test] + fn sender_authentication_key_is_a_purpose_mismatch() { + let sender = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::AUTHENTICATION, + )]); + let recipient = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::DECRYPTION, + )]); + + let result = validate_contact_request(&sender, 0, &recipient, 0); + assert!(!result.is_valid); + assert!( + result.purpose_mismatch, + "a sender purpose mismatch must be flagged so the channel is not permanently broken" + ); + } + + /// A NON-purpose failure (wrong key type) must NOT set `purpose_mismatch` + /// — it stays a hard/permanent failure that breaks the channel. + #[test] + fn wrong_key_type_is_not_a_purpose_mismatch() { + let sender = make_identity(vec![make_key(0, KeyType::BLS12_381, Purpose::ENCRYPTION)]); + let recipient = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::DECRYPTION, + )]); + + let result = validate_contact_request(&sender, 0, &recipient, 0); + assert!(!result.is_valid); + assert!( + !result.purpose_mismatch, + "a key-TYPE failure is permanent, not a purpose mismatch" + ); + } + + /// **#5 — a purpose mismatch that co-occurs with a hard error must NOT be + /// downgraded to a skip.** `add_purpose_error` flags `purpose_mismatch` + /// even when a genuinely-permanent hard error (disabled / missing / + /// wrong-type key) is also present; reading the bare flag to decide + /// skip-vs-break would mask that permanent fault into a retry-forever + /// loop. `is_purpose_only()` is the correct gate. + #[test] + fn purpose_mismatch_with_hard_error_is_not_purpose_only() { + let mut v = ContactRequestValidation::new(); + v.add_purpose_error("recipient key purpose is AUTHENTICATION".into()); + v.add_error("sender key is disabled".into()); + + assert!(!v.is_valid); + assert!(v.purpose_mismatch, "the purpose flag is still raised"); + assert!( + !v.is_purpose_only(), + "a purpose mismatch alongside a hard error is NOT purpose-only — must stay permanent" + ); + } + + /// A lone purpose mismatch IS purpose-only → skippable. + #[test] + fn lone_purpose_mismatch_is_purpose_only() { + let mut v = ContactRequestValidation::new(); + v.add_purpose_error("recipient key purpose is AUTHENTICATION".into()); + assert!(v.is_purpose_only()); + } + + /// A lone hard error is never purpose-only. + #[test] + fn lone_hard_error_is_not_purpose_only() { + let mut v = ContactRequestValidation::new(); + v.add_error("sender key is disabled".into()); + assert!(!v.is_purpose_only()); + } + + /// `merge` must carry the `hard_error` flag so a hard fault in a merged + /// sub-result can't be lost (which would re-open the masking bug). + #[test] + fn merge_propagates_hard_error() { + let mut a = ContactRequestValidation::new(); + a.add_purpose_error("purpose".into()); + let mut b = ContactRequestValidation::new(); + b.add_error("hard".into()); + a.merge(b); + assert!(a.purpose_mismatch); + assert!(a.hard_error); + assert!(!a.is_purpose_only()); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/mod.rs index 66d1cbdfa90..29ff341b290 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/mod.rs @@ -26,14 +26,14 @@ pub mod types; pub use crypto::{ calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, - derive_contact_payment_addresses, derive_contact_xpub, ContactXpubData, - DEFAULT_CONTACT_GAP_LIMIT, + derive_contact_payment_addresses, derive_contact_xpub, unmask_account_reference, + ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, }; pub use network::IdentityWallet; pub use state::{BlockTime, IdentityLocation, IdentityManager, ManagedIdentity, RegistrationIndex}; pub use types::dashpay::profile::{calculate_avatar_hash, calculate_dhash_fingerprint}; pub use types::{ - ContactRequest, DashPayProfile, DashpayAddressMatch, DpnsNameInfo, EstablishedContact, - IdentityStatus, KeyStorage, PaymentDirection, PaymentEntry, PaymentStatus, PrivateKeyData, - ProfileUpdate, + ContactProfileEntry, ContactRequest, DashPayProfile, DashpayAddressMatch, DpnsNameInfo, + EstablishedContact, IdentityStatus, KeyStorage, PaymentDirection, PaymentEntry, PaymentStatus, + PrivateKeyData, ProfileUpdate, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/account_labels.rs b/packages/rs-platform-wallet/src/wallet/identity/network/account_labels.rs index 768590b65a1..da835f34772 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/account_labels.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/account_labels.rs @@ -6,6 +6,28 @@ use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; +/// kotlin/dashj pad the label to at least 16 characters with trailing spaces +/// before encrypting, and always emit it. This keeps the AES-256-CBC +/// ciphertext (IV + blocks) ≥ 48 bytes — the contract's `encryptedAccountLabel` +/// floor — even for a short or empty label (empty → 16 spaces). Matching it is +/// required for cross-client interop (a label shorter than 16 chars would +/// otherwise produce a 32-byte blob the contract rejects). +const ACCOUNT_LABEL_MIN_CHARS: usize = 16; + +/// Pad `label` to at least [`ACCOUNT_LABEL_MIN_CHARS`] chars with spaces +/// (no-op when it is already ≥ that). Mirrors kotlin's `padEnd(16, ' ')`. +fn pad_account_label(label: &str) -> String { + let chars = label.chars().count(); + if chars >= ACCOUNT_LABEL_MIN_CHARS { + label.to_string() + } else { + let mut s = String::with_capacity(label.len() + (ACCOUNT_LABEL_MIN_CHARS - chars)); + s.push_str(label); + s.extend(std::iter::repeat_n(' ', ACCOUNT_LABEL_MIN_CHARS - chars)); + s + } +} + // --------------------------------------------------------------------------- // Account label encryption / decryption (DIP-15) // --------------------------------------------------------------------------- @@ -33,7 +55,10 @@ impl IdentityWallet { let mut iv = [0u8; 16]; thread_rng().fill_bytes(&mut iv); - let encrypted = platform_encryption::encrypt_account_label(shared_key, &iv, label); + // Pad to ≥16 chars (kotlin/dashj convention) so the ciphertext clears + // the contract's 48-byte `encryptedAccountLabel` floor and interops. + let padded = pad_account_label(label); + let encrypted = platform_encryption::encrypt_account_label(shared_key, &iv, &padded); Ok(encrypted) } @@ -54,16 +79,67 @@ impl IdentityWallet { encrypted: &[u8], shared_key: &[u8; 32], ) -> Result { - platform_encryption::decrypt_account_label(shared_key, encrypted).map_err(|e| match e { - CryptoError::DecryptionFailed => { - PlatformWalletError::InvalidIdentityData("Account label decryption failed".into()) - } - CryptoError::InvalidUtf8 => PlatformWalletError::InvalidIdentityData( - "Decrypted account label is not valid UTF-8".into(), - ), - CryptoError::InvalidCiphertextLength => PlatformWalletError::InvalidIdentityData( - "Invalid encrypted account label length".into(), - ), - }) + let label = + platform_encryption::decrypt_account_label(shared_key, encrypted).map_err(|e| { + match e { + CryptoError::DecryptionFailed => PlatformWalletError::InvalidIdentityData( + "Account label decryption failed".into(), + ), + CryptoError::InvalidUtf8 => PlatformWalletError::InvalidIdentityData( + "Decrypted account label is not valid UTF-8".into(), + ), + CryptoError::InvalidCiphertextLength => { + PlatformWalletError::InvalidIdentityData( + "Invalid encrypted account label length".into(), + ) + } + // Not reachable from account-label decryption (that path never + // parses a compact xpub), but the match must stay exhaustive. + CryptoError::InvalidCompactXpubLength(len) => { + PlatformWalletError::InvalidIdentityData(format!( + "Unexpected compact-xpub length error during label decryption: {len}" + )) + } + } + })?; + // Strip the trailing space padding added on encrypt (kotlin convention). + Ok(label.trim_end_matches(' ').to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pad_account_label_matches_kotlin_pad_end_16() { + assert_eq!(pad_account_label("hi"), "hi "); // 2 + 14 spaces = 16 + assert_eq!(pad_account_label("").len(), 16); // empty → 16 spaces + assert_eq!(pad_account_label("exactly-16-chars"), "exactly-16-chars"); // ≥16: untouched + assert_eq!( + pad_account_label("a longer label than sixteen"), + "a longer label than sixteen" + ); + } + + /// A short label encrypts to ≥48 bytes (clearing the contract floor) and + /// round-trips back to the original (padding stripped) — the bug was that + /// labels < 16 chars produced a 32-byte blob the contract rejects. + #[test] + fn short_and_empty_labels_clear_the_48_byte_floor_and_round_trip() { + use platform_encryption::decrypt_account_label as dec; + let key = [0x42u8; 32]; + let iv = [0x11u8; 16]; + for label in ["", "hi", "lunch fund"] { + let blob = + platform_encryption::encrypt_account_label(&key, &iv, &pad_account_label(label)); + assert!( + (48..=80).contains(&blob.len()), + "label {label:?} blob len {} not in 48..=80", + blob.len() + ); + let decrypted = dec(&key, &blob).expect("decrypt"); + assert_eq!(decrypted.trim_end_matches(' '), label); + } } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs new file mode 100644 index 00000000000..1c1e9dfec89 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs @@ -0,0 +1,710 @@ +//! DashPay `contactInfo` document sync + publish. +//! +//! `contactInfo` carries the owner's PRIVATE per-contact metadata +//! (alias, note, `displayHidden`) self-encrypted per +//! [`crate::wallet::identity::crypto::contact_info`] — publishing it +//! is what makes alias/note/hide survive restore-from-seed and sync +//! across devices (otherwise they live only on the local device). +//! +//! Document identity: the unique index is +//! `($ownerId, rootEncryptionKeyIndex, derivationEncryptionKeyIndex)` +//! — one document per contact, distinguished by the (sequential) +//! derivation index. The contact ↔ document mapping is intentionally +//! **stateless**: resolving which doc belongs to which contact means +//! decrypting each doc's `encToUserId` with the keys its own indices +//! select. No extra local schema, and restore-from-seed recovers +//! everything from chain. + +use dpp::document::{Document, DocumentV0}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::Value; +use dpp::prelude::Identifier; + +use super::*; +use crate::broadcaster::TransactionBroadcaster; +use crate::error::PlatformWalletError; +use crate::wallet::identity::crypto::contact_info::{ + decode_private_data, derive_contact_info_keys, encode_private_data, ContactInfoPrivateData, +}; + +/// One decrypted `contactInfo` document — the fields the sweep applies. (The +/// publish path's update-vs-create needs `doc_id`/`revision`/`derivation_index` +/// too; it reads those from [`RawContactInfoDoc`] before decrypting.) +struct DecryptedContactInfo { + contact_id: Identifier, + data: ContactInfoPrivateData, +} + +/// A `contactInfo` document's public, key-free fields — the result of the +/// paginated owner-scoped scan. Shared by the resident sweep decrypt and the +/// seedless publish/drain (which decrypt via the signer), so the document fetch +/// is single-sourced and no key material is needed to produce it. +pub(super) struct RawContactInfoDoc { + doc_id: Identifier, + revision: u64, + root_index: u32, + derivation_index: u32, + enc_to_user_id: [u8; 32], + private_data: Vec, +} + +/// Outcome of [`IdentityWallet::set_contact_info_with_external_signer`]. +/// +/// The local alias/note/hidden state is ALWAYS updated; this reports +/// whether the self-encrypted `contactInfo` document also reached +/// Platform, so the UI can tell the user the truth ("synced" vs "saved +/// on this device, will sync later") instead of unconditionally claiming +/// a cross-device sync that didn't happen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContactInfoPublishOutcome { + /// The document was created/updated on Platform — synced cross-device. + Published, + /// Local state updated, but the document publish was DEFERRED by the + /// DIP-15 privacy rule (the identity has fewer than two established + /// contacts). A later edit, once a second contact is established, + /// publishes everything. + DeferredUntilTwoContacts, + /// Local state updated, but publish is not possible for a watch-only / + /// seedless identity (no HD slot to derive the self-encryption keys; + /// the host-side signing hook lands this later). + SkippedWatchOnly, +} + +impl IdentityWallet { + /// Paginated owner-scoped scan of `contactInfo` documents — fetch + parse the + /// **public, key-free** fields. Returns the raw docs that carry all public + /// fields PLUS a `rootEncryptionKeyIndex → max(derivationEncryptionKeyIndex)` + /// high-water map over ALL owned docs (so a slot held by a doc we can't + /// parse/decrypt still reserves its index against new writes — the unique + /// index is `($ownerId, rootEncryptionKeyIndex, derivationEncryptionKeyIndex)`). + /// + /// Single-sources the fetch for BOTH the resident sweep decrypt + /// ([`Self::fetch_decrypted_contact_infos`]) and the seedless publish/drain + /// (which decrypt via the signer) — no key material is touched here. + pub(super) async fn fetch_contact_info_docs( + &self, + identity_id: &Identifier, + ) -> Result<(Vec, std::collections::BTreeMap), PlatformWalletError> + { + use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; + use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; + use dash_sdk::platform::FetchMany; + use dpp::document::DocumentV0Getters; + use dpp::platform_value::platform_value; + + let dashpay_contract = super::dashpay_contract()?; + + // Paginated retrieve-all — no truncation. contactInfos are owner-scoped + // (bounded by the contact count), but a wallet with >100 contacts would + // otherwise silently drop the rest, like the old contact-request fetch. + const CONTACT_INFO_PAGE: u32 = 100; + let mut docs: Vec<(Identifier, Option)> = Vec::new(); + let mut start: Option = None; + loop { + let query = dash_sdk::platform::DocumentQuery { + select: dash_sdk::drive::query::SelectProjection::documents(), + data_contract: std::sync::Arc::clone(&dashpay_contract), + document_type_name: "contactInfo".to_string(), + where_clauses: vec![WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: platform_value!(identity_id), + }], + group_by: vec![], + having: vec![], + // Load-bearing, not cosmetic: drive answers a bare + // secondary-index equality with a verified proof of ABSENCE + // (same trap the contact-request queries hit). The order-by + // binds the query to the ownerIdAndUpdatedAt index and gives + // the deterministic order pagination relies on. + order_by_clauses: vec![OrderClause { + field: "$updatedAt".to_string(), + ascending: true, + }], + limit: CONTACT_INFO_PAGE, + start: start.clone(), + }; + + let page = Document::fetch_many(&self.sdk, query) + .await + .map_err(PlatformWalletError::Sdk)?; + let page_len = page.len(); + let last_id = page.keys().last().copied(); + docs.extend(page); + + if page_len < CONTACT_INFO_PAGE as usize { + break; + } + match last_id { + Some(id) => start = Some(Start::StartAfter(id.to_buffer().to_vec())), + None => break, + } + } + + let mut out = Vec::new(); + // root_index → max derivation_index seen across ALL owned docs. + let mut high_water: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for (doc_id, maybe_doc) in docs.iter() { + let Some(doc) = maybe_doc else { continue }; + let props = doc.properties(); + let (Some(root_index), Some(derivation_index)) = ( + props + .get("rootEncryptionKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()), + props + .get("derivationEncryptionKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()), + ) else { + tracing::warn!(owner = %identity_id, doc = %doc_id, "contactInfo missing key indices"); + continue; + }; + // Record the slot BEFORE any decrypt attempt, so a doc we can't + // decrypt still reserves its derivation index against new writes. + high_water + .entry(root_index) + .and_modify(|m| *m = (*m).max(derivation_index)) + .or_insert(derivation_index); + let (Some(enc_to_user_id), Some(private_data)) = ( + props + .get("encToUserId") + .and_then(|v: &Value| v.to_binary_bytes().ok()), + props + .get("privateData") + .and_then(|v: &Value| v.to_binary_bytes().ok()), + ) else { + tracing::warn!(owner = %identity_id, doc = %doc_id, "contactInfo missing payload fields"); + continue; + }; + let Ok(enc_to_user_id): Result<[u8; 32], _> = enc_to_user_id.as_slice().try_into() + else { + tracing::warn!(owner = %identity_id, doc = %doc_id, "contactInfo encToUserId is not 32 bytes"); + continue; + }; + out.push(RawContactInfoDoc { + doc_id: *doc_id, + revision: doc.revision().unwrap_or(dpp::document::INITIAL_REVISION), + root_index, + derivation_index, + enc_to_user_id, + private_data, + }); + } + Ok((out, high_water)) + } + + /// Fetch + decrypt every `contactInfo` document owned by `identity_id` using + /// the RESIDENT wallet — the signerless sweep's path. Built on the + /// single-sourced [`Self::fetch_contact_info_docs`]; docs whose keys we can't + /// derive or whose payload doesn't decrypt are skipped with a warning (a + /// malformed doc must not abort the sync). Returns the decrypted docs + the + /// same high-water map. + async fn fetch_decrypted_contact_infos( + &self, + identity_id: &Identifier, + ) -> Result< + ( + Vec, + std::collections::BTreeMap, + ), + PlatformWalletError, + > { + let (raw_docs, high_water) = self.fetch_contact_info_docs(identity_id).await?; + + // Resolve the wallet HD slot once; decryption is per-doc. + let (identity_index, wallet_snapshot) = { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let Some(identity_index) = managed.identity_index else { + // Watch-only / out-of-wallet identity — no resident HD slot; the + // seedless drain handles its contactInfo decrypt via the signer. + return Ok((Vec::new(), high_water)); + }; + let wallet = wm + .get_wallet(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + (identity_index, wallet.clone()) + }; + + let mut out = Vec::new(); + for raw in &raw_docs { + let keys = match derive_contact_info_keys( + &wallet_snapshot, + self.sdk.network, + identity_index, + raw.root_index, + raw.derivation_index, + ) { + Ok(k) => k, + Err(e) => { + tracing::warn!(owner = %identity_id, doc = %raw.doc_id, error = %e, "contactInfo key derivation failed"); + continue; + } + }; + + let contact_id = Identifier::from(platform_encryption::decrypt_enc_to_user_id( + &keys.enc_to_user_id_key, + &raw.enc_to_user_id, + )); + let data = match platform_encryption::decrypt_private_data( + &keys.private_data_key, + &raw.private_data, + ) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("privateData decrypt: {e}")) + }) + .and_then(|plain| decode_private_data(&plain)) + { + Ok(d) => d, + Err(e) => { + tracing::warn!(owner = %identity_id, doc = %raw.doc_id, error = %e, "contactInfo privateData decode failed"); + continue; + } + }; + + out.push(DecryptedContactInfo { contact_id, data }); + } + Ok((out, high_water)) + } + + /// Sync `contactInfo` documents for every wallet-owned identity: + /// fetch, decrypt, and apply alias/note/hidden onto the matching + /// established contacts. Remote state wins — local edits publish + /// immediately (see [`Self::set_contact_info_with_external_signer`]), + /// so convergence is last-writer through Platform. + /// + /// Returns the number of contacts whose metadata was applied. + pub async fn sync_contact_infos(&self) -> Result { + let (identity_ids, has_resident_keys): (Vec, bool) = { + let wm = self.wallet_manager.read().await; + let Some(info) = wm.get_wallet_info(&self.wallet_id) else { + return Ok(0); + }; + let ids = info + .identity_manager + .wallet_identities + .values() + .flat_map(|inner| inner.values().map(|m| m.id())) + .collect(); + // The recurring sweep is signerless. Only a resident-key wallet TYPE + // (Mnemonic/Seed) can decrypt contactInfo inline here; an + // external-signable (seedless) wallet has no resident key material, + // so it DEFERS the decrypt to the signer-backed drain. + let has_resident_keys = wm + .get_wallet(&self.wallet_id) + .map(|w| w.has_seed()) + .unwrap_or(false); + (ids, has_resident_keys) + }; + + let mut applied = 0u32; + for identity_id in identity_ids { + if !has_resident_keys { + // Seedless: enqueue a per-owner `ContactInfoDecrypt` op (idempotent + // per owner); the drain decrypts via the Keychain signer when one + // is available. The sweep itself never derives. + self.enqueue_contact_info_decrypt(&identity_id).await; + continue; + } + // Resident-key wallet type: decrypt in place (the kept resident path). + // Log-and-continue per identity, matching the other sync steps. + // The sync path only consumes the decrypted docs; the high-water + // map is only needed by the publish path. + let infos = match self.fetch_decrypted_contact_infos(&identity_id).await { + Ok((v, _high_water)) => v, + Err(e) => { + tracing::warn!( + identity = %identity_id, + error = %e, + "contactInfo sync failed for identity; continuing" + ); + continue; + } + }; + if infos.is_empty() { + continue; + } + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + continue; + }; + let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) else { + continue; + }; + for decrypted in infos { + // `decrypted` is owned by the loop, so move its already-decoded + // `ContactInfoPrivateData` straight in — no field-by-field clone. + if managed.set_contact_metadata( + &decrypted.contact_id, + decrypted.data, + &self.persister, + ) { + applied += 1; + } + } + } + Ok(applied) + } + + /// Enqueue a per-owner `ContactInfoDecrypt` op for the signerless sweep to + /// defer to the drain. Idempotent per owner — the dedup key uses + /// `(owner, owner, ContactInfoDecrypt)` (contactInfo is owner-scoped, so the + /// `contact_id` slot carries the owner as a sentinel; the op itself carries + /// no payload — the drain re-fetches the latest published docs). Stores only + /// the queue delta (no secret). + async fn enqueue_contact_info_decrypt(&self, owner_id: &Identifier) { + use crate::changeset::{ + upsert_pending_contact_crypto, PendingContactCrypto, PendingContactCryptoOp, + PlatformWalletChangeSet, + }; + let enqueued_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let entry = PendingContactCrypto { + owner_identity_id: *owner_id, + contact_id: *owner_id, + op: PendingContactCryptoOp::ContactInfoDecrypt, + enqueued_at_ms, + }; + { + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + upsert_pending_contact_crypto(&mut info.pending_contact_crypto, entry.clone()); + } + let changeset = PlatformWalletChangeSet { + pending_contact_crypto_added: vec![entry], + ..Default::default() + }; + if let Err(e) = self.persister.store(changeset) { + tracing::warn!( + owner = %owner_id, error = %e, + "failed to persist contactInfo-decrypt enqueue; will re-enqueue next sweep" + ); + } + } + + /// Drain a `ContactInfoDecrypt` queue entry: re-fetch `owner_id`'s contactInfo + /// documents (key-free scan) and decrypt + apply each via the signer-backed + /// `crypto`. Re-fetching means the latest published version always wins. + /// Returns the number of contacts whose metadata was applied. + /// + /// Confused-deputy guard: `owner_id` MUST be a managed identity of this wallet + /// (we resolve its HD index); a queue entry naming a non-owned identity errors + /// out and is left for the caller to handle, never decrypted under the wrong + /// identity. A provider failure (signer unavailable) errors so the caller + /// leaves the entry queued for the next drain. + pub(super) async fn drain_contact_info_decrypt( + &self, + owner_id: &Identifier, + crypto: &C, + ) -> Result { + let identity_index = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(owner_id)) + .and_then(|m| m.identity_index) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "ContactInfoDecrypt: {owner_id} is not a wallet-owned identity with an HD index" + )) + })? + }; + + let (raw_docs, _high_water) = self.fetch_contact_info_docs(owner_id).await?; + + // Decrypt each via the signer (no lock held during the async provider + // calls), collecting the decoded metadata to apply afterwards. + let mut decoded: Vec<(Identifier, ContactInfoPrivateData)> = Vec::new(); + for raw in &raw_docs { + let root_path = super::identity_auth_derivation_path_for_type( + self.sdk.network, + key_wallet::bip32::KeyDerivationType::ECDSA, + identity_index, + raw.root_index, + )?; + let opened = crypto + .contact_info_open( + &root_path, + raw.derivation_index, + &raw.enc_to_user_id, + &raw.private_data, + ) + .await?; + match decode_private_data(&opened.private_data) { + Ok(data) => decoded.push((Identifier::from(opened.contact_id), data)), + Err(e) => { + // A malformed payload is a single bad doc, not a drain failure. + tracing::warn!(owner = %owner_id, error = %e, "drain: contactInfo privateData decode failed; skipping doc"); + } + } + } + + let mut applied = 0usize; + let mut wm = self.wallet_manager.write().await; + if let Some(managed) = wm + .get_wallet_info_mut(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity_mut(owner_id)) + { + for (contact_id, data) in decoded { + if managed.set_contact_metadata(&contact_id, data, &self.persister) { + applied += 1; + } + } + } + Ok(applied) + } + + /// Set alias / note / hidden for an established contact, persist + /// locally, and publish (create or update) the corresponding + /// `contactInfo` document on Platform. + /// + /// DIP-15 privacy rule: with fewer than two established contacts + /// the document write is skipped (a single contactInfo would be + /// trivially linkable to the pair's contactRequest); the local + /// state still updates and the next edit after the second contact + /// is established publishes normally. + pub async fn set_contact_info_with_external_signer( + &self, + identity_id: &Identifier, + contact_id: &Identifier, + alias: Option, + note: Option, + display_hidden: bool, + signer: &S, + crypto: &C, + ) -> Result + where + S: Signer + Send + Sync, + C: super::ContactCryptoProvider + Sync, + { + use dashcore::secp256k1::rand::{thread_rng, RngCore}; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + + // Build the decrypted-payload struct once: it is both the local + // metadata applied to the contact AND (on publish) the plaintext the + // `contactInfo` codec encodes — no threading three loose args, and + // no duplicate struct literal at the encode site below. + let metadata = ContactInfoPrivateData { + alias_name: alias, + note, + display_hidden, + // Multi-account acceptance isn't populated yet; a metadata + // update carries an empty `acceptedAccounts`. + accepted_accounts: Vec::new(), + }; + + // 1. Local state first — works offline and feeds SwiftData. + let (established_count, identity_index, signing_key, root_key_id) = { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity_mut(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + if !managed.set_contact_metadata(contact_id, metadata.clone(), &self.persister) { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Contact {contact_id} is not established for identity {identity_id}" + ))); + } + let established_count = managed.established_contacts.len(); + let identity_index = managed.identity_index; + let signing_key = managed + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .cloned(); + let root_key_id = managed + .identity + .public_keys() + .iter() + .find(|(_, k)| { + k.purpose() == Purpose::ENCRYPTION + && k.key_type() == KeyType::ECDSA_SECP256K1 + && k.disabled_at().is_none() + }) + .map(|(_, k)| k.id()); + (established_count, identity_index, signing_key, root_key_id) + }; + + // 2. DIP-15 privacy gate. + if established_count < 2 { + tracing::info!( + identity = %identity_id, + contact = %contact_id, + established_count, + "contactInfo publish deferred (DIP-15: needs ≥2 established contacts); local state updated" + ); + return Ok(ContactInfoPublishOutcome::DeferredUntilTwoContacts); + } + + let Some(identity_index) = identity_index else { + tracing::info!( + identity = %identity_id, + "contactInfo publish skipped for watch-only/seedless identity (no host-side signing hook); local state updated" + ); + return Ok(ContactInfoPublishOutcome::SkippedWatchOnly); + }; + let signing_key = signing_key.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "No HIGH or CRITICAL authentication key found on identity \ + (required for document state transitions)" + .to_string(), + ) + })?; + let root_key_id = root_key_id.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Identity has no ECDSA_SECP256K1 encryption key (required for contactInfo)" + .to_string(), + ) + })?; + + // 3. Resolve the existing doc for this contact via the signer: the fetch + // is key-free (fetch_contact_info_docs); decrypt each owned doc's + // encToUserId through the provider and match its contact id. No + // existing match → allocate the next sequential derivation index. + // Signer-present (publish), so we decrypt fresh — never guess the index. + let (raw_docs, high_water) = self.fetch_contact_info_docs(identity_id).await?; + let mut existing: Option<(Identifier, u64, u32)> = None; + for raw in &raw_docs { + // Each doc carries its own root index (our encryption key may have + // rotated); build that doc's root path in Rust. + let raw_root_path = super::identity_auth_derivation_path_for_type( + self.sdk.network, + key_wallet::bip32::KeyDerivationType::ECDSA, + identity_index, + raw.root_index, + )?; + match crypto + .contact_info_open( + &raw_root_path, + raw.derivation_index, + &raw.enc_to_user_id, + &raw.private_data, + ) + .await + { + Ok(opened) if opened.contact_id == contact_id.to_buffer() => { + existing = Some((raw.doc_id, raw.revision, raw.derivation_index)); + break; + } + // Different contact, or a payload we can't open — not our target; + // skip (matches the resident path's skip-on-fail). + _ => continue, + } + } + let (doc_id, revision, derivation_index) = match existing { + Some((id, rev, idx)) => (Some(id), rev + 1, idx), + None => { + // Allocate the next index from the high-water mark over ALL owned + // docs at THIS root (including skipped/undecryptable ones), not + // just the decryptable subset — otherwise a skipped doc's slot + // would collide on the unique index. + let next_index = high_water.get(&root_key_id).map(|m| m + 1).unwrap_or(0); + (None, dpp::document::INITIAL_REVISION, next_index) + } + }; + + // 4. Encrypt the payload via the signer — the two hardened-child AES keys + // are derived + wiped in the signer; only ciphertext returns. Root + // path built in Rust at our current encryption key. + let root_path = super::identity_auth_derivation_path_for_type( + self.sdk.network, + key_wallet::bip32::KeyDerivationType::ECDSA, + identity_index, + root_key_id, + )?; + let mut iv = [0u8; 16]; + thread_rng().fill_bytes(&mut iv); + let sealed = crypto + .contact_info_seal( + &root_path, + derivation_index, + &contact_id.to_buffer(), + &encode_private_data(&metadata), + &iv, + ) + .await?; + let enc_to_user_id = sealed.enc_to_user_id; + let private_data = sealed.private_data; + + // 5. Build + put the document through the write seam. + let mut properties = std::collections::BTreeMap::new(); + properties.insert("encToUserId".to_string(), Value::Bytes32(enc_to_user_id)); + properties.insert( + "rootEncryptionKeyIndex".to_string(), + Value::U32(root_key_id), + ); + properties.insert( + "derivationEncryptionKeyIndex".to_string(), + Value::U32(derivation_index), + ); + properties.insert("privateData".to_string(), Value::Bytes(private_data)); + + let document = Document::V0(DocumentV0 { + id: doc_id.unwrap_or_else(|| Identifier::from([0u8; 32])), + owner_id: *identity_id, + properties, + revision: if doc_id.is_some() { + Some(revision) + } else { + None + }, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + let dashpay_contract = super::dashpay_contract()?; + let document_type = dashpay_contract + .document_type_for_name("contactInfo") + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to get contactInfo document type: {e}" + )) + })? + .to_owned_document_type(); + + self.sdk_writer + .put_document(super::sdk_writer::PutDocumentParams { + document, + document_type, + signing_public_key: signing_key, + signer: signer as &(dyn Signer + Send + Sync), + }) + .await?; + + tracing::info!( + identity = %identity_id, + contact = %contact_id, + derivation_index, + updated = doc_id.is_some(), + "Published contactInfo document" + ); + Ok(ContactInfoPublishOutcome::Published) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 3c68fcfdf3c..873e358b754 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -1,7 +1,5 @@ //! DashPay contact request lifecycle: send, sync, accept, reject. -use async_trait::async_trait; -use dpp::address_funds::AddressWitness; use dpp::document::DocumentV0Getters; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -11,50 +9,299 @@ use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; use dpp::identity::KeyType; use dpp::identity::SecurityLevel; -use dpp::platform_value::BinaryData; use dpp::platform_value::Value; use dpp::prelude::Identifier; -use dpp::ProtocolError; -use key_wallet::account::AccountType; - -use dash_sdk::platform::dashpay::EcdhProvider; +use super::contacts::RegisterExternalError; +use super::sdk_writer::SendContactRequestParams; use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; -use dash_sdk::platform::dashpay::SendContactRequestInput; -// Borrowed-signer adapter — see `dpns.rs` for the pattern. -struct SignerRef<'a, S: ?Sized>(&'a S); +// --------------------------------------------------------------------------- +// Deferred-crypto drain provider +// --------------------------------------------------------------------------- + +/// Supplies the wallet-HD key material the seedless contact-crypto paths need +/// (the deferred-crypto drain AND the live send/accept flow), without +/// platform-wallet naming the concrete Keychain signer (`MnemonicResolverCoreSigner` +/// lives in `rs-sdk-ffi`, which platform-wallet does not depend on). The glue +/// crate implements this over the resolver-backed signer; tests implement it +/// with canned values. All methods take a **Rust-built** derivation path — +/// path provenance stays in Rust (the host derives at exactly the path it is +/// handed), and no private scalar ever crosses back into platform-wallet. +#[async_trait::async_trait] +pub trait ContactCryptoProvider { + /// Extended public key at `path` (a generic derive-at-path): the DashPay + /// receiving (friendship) xpub, and also the seed-binding self-check's + /// BIP44 account-0 xpub ([`crate::PlatformWallet::verify_seed_binds`]). + async fn receiving_xpub( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result; + + /// ECDH shared secret between our key at `path` and the contact's `peer` + /// pubkey. + async fn ecdh_shared_secret( + &self, + path: &key_wallet::bip32::DerivationPath, + peer: &dashcore::secp256k1::PublicKey, + ) -> Result<[u8; 32], PlatformWalletError>; + + /// Export the raw **auto-accept private key** at `path` (DIP-15 QR + /// auto-accept) — the **one deliberate exception** to "the signer never + /// returns a raw scalar." The auto-accept key is a shareable, expiry-bounded + /// bearer credential the owner embeds in a QR (`dapk`), so it must leave the + /// signer. `path` MUST be an auto-accept path (`m/9'/coin'/16'/expiry'`); the + /// only caller is [`IdentityWallet::build_auto_accept_qr`], which builds it + /// via `auto_accept_derivation_path`. The key authorizes only contact + /// auto-acceptance — never payments or identity control. + async fn export_auto_accept_private_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result; -impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("SignerRef") + /// DIP-15 `accountReference` for a send: the scalar at `path` (the sender's + /// encryption key) keys the HMAC+mask over `compact_xpub`. Computed in the + /// signer so the raw scalar never returns to platform-wallet. + async fn account_reference( + &self, + path: &key_wallet::bip32::DerivationPath, + compact_xpub: &[u8], + account_index: u32, + version: u32, + ) -> Result; + + /// Inverse of [`Self::account_reference`] — recover `(version, account_index)` + /// from a masked reference using the same in-signer scalar at `path`. Used + /// on re-send to read the previous rotation version. + async fn unmask_account_reference( + &self, + path: &key_wallet::bip32::DerivationPath, + compact_xpub: &[u8], + account_reference: u32, + ) -> Result<(u32, u32), PlatformWalletError>; + + /// DIP-15 contactInfo **seal** — encrypt the contact id (`encToUserId`, + /// AES-256-ECB) and the private-data plaintext (`privateData`, AES-256-CBC + /// with `private_data_iv`) under the two hardened-child keys derived from + /// `root_path` (the identity-auth path; the signer extends it internally with + /// the DIP-15 `65536'`/`65537'` feature children + `derivation_index'`). The + /// AES keys + scalars stay in the signer; only ciphertext returns. + /// + /// `root_path` MUST be built in Rust via `identity_auth_derivation_path_for_type` + /// (never assembled by the host) — a wrong root silently produces contactInfo + /// no client can decrypt. + async fn contact_info_seal( + &self, + root_path: &key_wallet::bip32::DerivationPath, + derivation_index: u32, + contact_id: &[u8; 32], + private_data_plaintext: &[u8], + private_data_iv: &[u8; 16], + ) -> Result; + + /// Inverse of [`Self::contact_info_seal`] — recover the contact id + + /// private-data plaintext from the on-chain ciphertexts at `root_path` / + /// `derivation_index`. + async fn contact_info_open( + &self, + root_path: &key_wallet::bip32::DerivationPath, + derivation_index: u32, + enc_to_user_id: &[u8; 32], + private_data_blob: &[u8], + ) -> Result; +} + +/// Result of [`ContactCryptoProvider::contact_info_seal`] — the two DIP-15 +/// contactInfo ciphertexts to publish on chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContactInfoSealed { + /// `encToUserId` ciphertext (AES-256-ECB of the 32-byte contact id). + pub enc_to_user_id: [u8; 32], + /// `privateData` ciphertext (`iv ‖ AES-256-CBC`). + pub private_data: Vec, +} + +/// Result of [`ContactCryptoProvider::contact_info_open`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContactInfoOpened { + /// The recovered 32-byte contact id (the `toUserId` this doc is about). + pub contact_id: [u8; 32], + /// The recovered `privateData` plaintext (DIP-15 codec applied by the caller). + pub private_data: Vec, +} + +/// Test [`ContactCryptoProvider`] that derives from a resident test seed via +/// `key_wallet` — the seedless-test stand-in for the production Keychain signer. +/// It derives at exactly the Rust-built paths the production glue feeds, so a +/// test wired through this provider exercises the same key material the resident +/// seed would have produced, with no resident seed on the wallet under test — +/// faithful, unlike the canned/stub providers used by the queue-mechanics +/// drain tests. +#[cfg(test)] +pub(crate) struct SeedCryptoProvider { + wallet: key_wallet::wallet::Wallet, +} + +#[cfg(test)] +impl SeedCryptoProvider { + pub(crate) fn from_seed(seed: [u8; 64], network: key_wallet::Network) -> Self { + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + Self { + wallet: Wallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::None) + .expect("test seed wallet"), + } } } -#[async_trait] -impl<'a, K, S> Signer for SignerRef<'a, S> -where - K: Send + Sync, - S: Signer + ?Sized + Send + Sync, -{ - async fn sign(&self, key: &K, data: &[u8]) -> Result { - self.0.sign(key, data).await +#[cfg(test)] +#[async_trait::async_trait] +impl ContactCryptoProvider for SeedCryptoProvider { + async fn receiving_xpub( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + self.wallet.derive_extended_public_key(path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test receiving_xpub: {e}")) + }) } - async fn sign_create_witness( + async fn ecdh_shared_secret( &self, - key: &K, - data: &[u8], - ) -> Result { - self.0.sign_create_witness(key, data).await + path: &key_wallet::bip32::DerivationPath, + peer: &dashcore::secp256k1::PublicKey, + ) -> Result<[u8; 32], PlatformWalletError> { + let xprv = self.wallet.derive_extended_private_key(path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test ecdh derive: {e}")) + })?; + Ok(platform_encryption::derive_shared_key_ecdh( + &xprv.private_key, + peer, + )) } - fn can_sign_with(&self, key: &K) -> bool { - self.0.can_sign_with(key) + async fn export_auto_accept_private_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + let xprv = self.wallet.derive_extended_private_key(path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test export auto-accept: {e}")) + })?; + Ok(xprv.private_key) + } + + async fn account_reference( + &self, + path: &key_wallet::bip32::DerivationPath, + compact_xpub: &[u8], + account_index: u32, + version: u32, + ) -> Result { + let xprv = self.wallet.derive_extended_private_key(path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test accountRef derive: {e}")) + })?; + Ok(platform_encryption::calculate_account_reference( + &xprv.private_key.secret_bytes(), + compact_xpub, + account_index, + version, + )) + } + + async fn unmask_account_reference( + &self, + path: &key_wallet::bip32::DerivationPath, + compact_xpub: &[u8], + account_reference: u32, + ) -> Result<(u32, u32), PlatformWalletError> { + let xprv = self.wallet.derive_extended_private_key(path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test unmask derive: {e}")) + })?; + Ok(platform_encryption::unmask_account_reference( + account_reference, + &xprv.private_key.secret_bytes(), + compact_xpub, + )) + } + + async fn contact_info_seal( + &self, + root_path: &key_wallet::bip32::DerivationPath, + derivation_index: u32, + contact_id: &[u8; 32], + private_data_plaintext: &[u8], + private_data_iv: &[u8; 16], + ) -> Result { + let enc_key = self.contact_info_child(root_path, derivation_index, true)?; + let priv_key = self.contact_info_child(root_path, derivation_index, false)?; + Ok(ContactInfoSealed { + enc_to_user_id: platform_encryption::encrypt_enc_to_user_id(&enc_key, contact_id), + private_data: platform_encryption::encrypt_private_data( + &priv_key, + private_data_iv, + private_data_plaintext, + ), + }) + } + + async fn contact_info_open( + &self, + root_path: &key_wallet::bip32::DerivationPath, + derivation_index: u32, + enc_to_user_id: &[u8; 32], + private_data_blob: &[u8], + ) -> Result { + let enc_key = self.contact_info_child(root_path, derivation_index, true)?; + let priv_key = self.contact_info_child(root_path, derivation_index, false)?; + let private_data = platform_encryption::decrypt_private_data(&priv_key, private_data_blob) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test contactInfo decrypt: {e}")) + })?; + Ok(ContactInfoOpened { + contact_id: platform_encryption::decrypt_enc_to_user_id(&enc_key, enc_to_user_id), + private_data, + }) + } +} + +#[cfg(test)] +impl SeedCryptoProvider { + /// Derive one DIP-15 contactInfo hardened-child AES key (`encToUserId` = + /// `enc=true`, `privateData` = `enc=false`) at + /// `root_path / feature' / derivation_index'` — mirrors the resident + /// `derive_contact_info_keys` so the test provider produces byte-identical + /// keys to the production wallet derivation. + fn contact_info_child( + &self, + root_path: &key_wallet::bip32::DerivationPath, + derivation_index: u32, + enc: bool, + ) -> Result<[u8; 32], PlatformWalletError> { + use crate::wallet::identity::crypto::contact_info::{ + ENC_TO_USER_ID_CHILD, PRIVATE_DATA_CHILD, + }; + use key_wallet::bip32::ChildNumber; + let feature = if enc { + ENC_TO_USER_ID_CHILD + } else { + PRIVATE_DATA_CHILD + }; + let path = root_path.clone().extend([ + ChildNumber::from_hardened_idx(feature).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test contactInfo feature: {e}")) + })?, + ChildNumber::from_hardened_idx(derivation_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test contactInfo index: {e}")) + })?, + ]); + let xprv = self.wallet.derive_extended_private_key(&path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test contactInfo derive: {e}")) + })?; + Ok(xprv.private_key.secret_bytes()) } } @@ -62,6 +309,36 @@ where // Send contact request // --------------------------------------------------------------------------- +/// How the optional DIP-15 `autoAcceptProof` is supplied to a contact-request +/// send. The proof signs over `(sender, recipient, accountReference)`, and +/// `accountReference` is computed **inside** the send — so the QR-scanner +/// variant carries the handed key and is signed there, once the reference is +/// known (binding the proof to the exact reference the document carries). +pub enum AutoAcceptProofSource { + /// No proof (the normal manual flow). + None, + /// A pre-built proof blob. + Provided(Vec), + /// Sign the proof in-send with the auto-accept key decoded from a scanned + /// QR (`dapk`), binding it to the accountReference this send computes. + SignWithKey { + /// The auto-accept private key handed out in the QR. + secret_key: dashcore::secp256k1::SecretKey, + /// The proof's expiry (the key blob's timestamp / derivation index). + expiry: u32, + }, +} + +impl AutoAcceptProofSource { + /// Map a pre-built optional proof (the FFI / legacy shape) into a source. + pub fn from_option(proof: Option>) -> Self { + match proof { + Some(p) => Self::Provided(p), + None => Self::None, + } + } +} + impl IdentityWallet { /// Send a contact request to another identity using an /// externally-supplied signer for the document state-transition. @@ -69,8 +346,12 @@ impl IdentityWallet { /// All parameters that can be resolved internally are resolved /// automatically: /// - **identity_index**: looked up from the local `ManagedIdentity` - /// - **sender_key_index**: first key with `Purpose::ENCRYPTION` on the sender - /// - **recipient_key_index**: first key with `Purpose::DECRYPTION` on the recipient + /// - **sender_key_index**: first `ECDSA_SECP256K1` `Purpose::ENCRYPTION` + /// key on the sender + /// - **recipient_key_index**: first `ECDSA_SECP256K1` `Purpose::DECRYPTION` + /// key on the recipient, falling back to the first ENCRYPTION key when + /// the recipient has no DECRYPTION key (mobile cohort) — see + /// [`select_recipient_key_index`] /// - **account_index**: defaults to `0` /// - **ECDH**: performed SDK-side using the sender's derived /// encryption private key. @@ -78,26 +359,26 @@ impl IdentityWallet { /// Document signing is routed through `signer` — the /// architecturally correct path per `swift-sdk/CLAUDE.md`. /// - /// CAVEAT — ECDH derivation: this method still derives the - /// sender's ECDH private key from the wallet seed via - /// `derive_encryption_private_key`. Watch-only wallets (no seed - /// Rust-side) WILL fail at this step. A follow-up FFI is needed - /// to push ECDH derivation across the FFI (it's a one-shot raw - /// scalar derivation, not a `Signer::sign` call, so it - /// doesn't fit the existing signer trampoline). For wallets - /// where the seed is in-process (the common case during this - /// migration sweep) this variant works end-to-end. + /// All wallet-HD key material — the friendship receiving xpub, the ECDH + /// shared secret, and the DIP-15 `accountReference` — is sourced through + /// `crypto` (a [`ContactCryptoProvider`], the Keychain signer in production, + /// canned values in tests). No resident seed is touched, so this path works + /// for seedless / external-signable wallets: the raw ECDH scalar stays in + /// the signer and only the (public) xpub, the shared secret, and the masked + /// reference cross back. #[allow(clippy::type_complexity)] - pub async fn send_contact_request_with_external_signer( + pub async fn send_contact_request_with_external_signer( &self, sender_identity_id: &Identifier, recipient_identity_id: &Identifier, account_label: Option, - auto_accept_proof: Option>, + auto_accept_proof: AutoAcceptProofSource, signer: &S, + crypto: &C, ) -> Result where S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, { // 1. Retrieve the sender identity and its HD index from the // local manager. @@ -132,69 +413,159 @@ impl IdentityWallet { .ok_or_else(|| PlatformWalletError::IdentityNotFound(*recipient_identity_id))? }; - // 3. Resolve key indices. + // 3. Resolve key indices. The sender selects its own ENCRYPTION key + // (the live convention for both cohorts); ECDSA_SECP256K1 is + // required for ECDH. let sender_encryption_key = sender_identity .public_keys() .iter() - .find(|(_, k)| k.purpose() == Purpose::ENCRYPTION) + .find(|(_, k)| { + k.purpose() == Purpose::ENCRYPTION && k.key_type() == KeyType::ECDSA_SECP256K1 + }) .map(|(_, k)| k.clone()) .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( - "Sender identity has no encryption key".to_string(), + "Sender identity has no ECDSA_SECP256K1 encryption key".to_string(), ) })?; let sender_key_index = sender_encryption_key.id(); - let recipient_key_index = recipient_identity - .public_keys() - .iter() - .find(|(_, k)| k.purpose() == Purpose::DECRYPTION) - .map(|(id, _)| *id) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Recipient identity has no decryption key".to_string(), - ) - })?; + let recipient_key_index = select_recipient_key_index(&recipient_identity)?; + + // 3b. Gate the selected key pair through the same validator + // the receive/accept paths use, BEFORE any ECDH or + // broadcast. The selectors above pick plausible indices; + // the validator pins the full contract (key types, not + // disabled, purpose policy) so a malformed identity can't + // reach the encrypt-and-broadcast stage with a key that + // would poison the channel. + let validation = crate::wallet::identity::crypto::validation::validate_contact_request( + &sender_identity, + sender_key_index, + &recipient_identity, + recipient_key_index, + ); + if !validation.is_valid { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Contact request failed pre-send validation: {}", + validation.errors.join("; ") + ))); + } + for warning in &validation.warnings { + tracing::warn!( + sender = %sender_identity_id, + recipient = %recipient_identity_id, + warning, + "Contact request pre-send validation warning" + ); + } - // 4. Derive the DashPay receiving xpub + ECDH private key from - // the wallet seed. NOTE: this step still requires the seed - // in-process (see CAVEAT in the docstring). + // 4. Derive the DashPay receiving (friendship) xpub via the signer — + // no resident seed. The signer derives at exactly the Rust-built + // path and returns only the (public) xpub. + // + // CONSISTENCY INVARIANT (do not break without re-checking + // `account_reference`): the friendship xpub path + // (`DashpayReceivingFunds`) is pinned to account 0, but the + // accountReference masks THIS `account_index` into its low 28 bits. A + // same-seed cross-wallet recovery un-masks the reference to learn which + // of our accounts the xpub belongs to — so if a future change threads a + // non-zero index here while the path stays at account 0, the recipient + // would look for the wrong account (silent, no oracle). Make the path + // account-aware AND add a round-trip test before relaxing this. let account_index: u32 = 0; - let (xpub_bytes, ecdh_private_key) = { - let wm = self.wallet_manager.read().await; - let wallet = wm - .get_wallet(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - - let account_type = AccountType::DashpayReceivingFunds { - index: account_index, - user_identity_id: sender_identity_id.to_buffer(), - friend_identity_id: recipient_identity_id.to_buffer(), + let contact_xpub_ext = self + .receiving_xpub_for(sender_identity_id, recipient_identity_id, account_index, crypto) + .await?; + // DIP-15 *compact* 69-byte plaintext (parentFingerprint ‖ chainCode ‖ + // pubKey) — NOT `ExtendedPubKey::encode()`. The DashPay receiving path + // ends in a Normal256 child, so `encode()` is the 107-byte DIP-14 + // serialization → 128-byte ciphertext → fails the contract's + // `maxItems: 96` and both reference clients' hard `len == 69` checks. + let xpub_bytes = platform_encryption::CompactXpub { + parent_fingerprint: contact_xpub_ext.parent_fingerprint.to_bytes(), + chain_code: contact_xpub_ext.chain_code.to_bytes(), + public_key: contact_xpub_ext.public_key.serialize(), + } + .to_bytes() + .to_vec(); + + // The sender's encryption-key derivation path. The scalar at this path + // keys BOTH the ECDH shared secret (step 6) and the accountReference + // mask (step 4b); the signer derives at exactly this path and the raw + // scalar never returns here. + let sender_enc_path = Self::identity_auth_derivation_path( + self.sdk.network, + key_wallet::bip32::KeyDerivationType::ECDSA, + identity_index, + sender_encryption_key.id(), + )?; + + // 4b. Mask the accountReference per DIP-15: the low 28 + // bits are the account index XOR'd with a PRF of the + // compact xpub keyed by our ECDH private key; the top 4 + // bits carry the rotation version. The version starts at 0 + // and bumps past the previous sent request's version when + // re-sending to the same recipient — the contract's unique + // index `($ownerId, toUserId, accountReference)` rejects an + // identical resend, so the bump is what makes a superseding + // (rotation) request broadcastable. The HMAC+mask runs in the + // signer (keyed by the raw scalar at `sender_enc_path`). + let account_reference = { + let prior_reference = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(sender_identity_id)) + // Checks both the pending sent map AND the established + // contact's outgoing request — see the method doc for + // why consulting only the pending map breaks rotation + // on established contacts. + .and_then(|managed| managed.prior_sent_account_reference(recipient_identity_id)) }; - let account_path = account_type - .derivation_path(self.sdk.network) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive DashPay receiving account path: {err}" - )) - })?; - let account_xpub = wallet - .derive_extended_public_key(&account_path) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive DashPay receiving account xpub: {err}" - )) - })?; - let xpub = account_xpub.encode(); - - let ecdh_key = Self::derive_encryption_private_key( - wallet, - self.sdk.network, - identity_index, - &sender_encryption_key, - )?; + let previous_version = match prior_reference { + Some(prior) => Some( + crypto + .unmask_account_reference(&sender_enc_path, &xpub_bytes, prior) + .await? + .0, + ), + None => None, + }; + let version = match previous_version { + // 4-bit field; saturate rather than wrap so a 16th + // rotation fails loudly at the unique index instead of + // silently colliding with version 0. + Some(v) if v >= 15 => { + tracing::warn!( + recipient = %recipient_identity_id, + "accountReference rotation version saturated at 15" + ); + 15 + } + Some(v) => v + 1, + None => 0, + }; + crypto + .account_reference(&sender_enc_path, &xpub_bytes, account_index, version) + .await? + }; - (xpub, ecdh_key) + // 4c. Resolve the auto-accept proof now that `account_reference` is + // known. The QR-scanner variant signs `(sender, recipient, + // account_reference)` here with the handed key, binding the proof to + // the exact reference this document carries. + let auto_accept_proof: Option> = match auto_accept_proof { + AutoAcceptProofSource::None => None, + AutoAcceptProofSource::Provided(p) => Some(p), + AutoAcceptProofSource::SignWithKey { secret_key, expiry } => { + Some(crate::wallet::identity::crypto::auto_accept::sign_auto_accept_proof( + &secret_key, + sender_identity_id, + recipient_identity_id, + account_reference, + expiry, + )) + } }; // 5. Build the signing key reference for document signing. @@ -217,70 +588,90 @@ impl IdentityWallet { ) })?; - // 6. Build SDK input. Wrap the borrowed signer in `SignerRef` - // so it satisfies the owned-by-bound `Signer` - // requirement on `SendContactRequestInput`. - let contact_request_input = dash_sdk::platform::dashpay::ContactRequestInput { - sender_identity: sender_identity.clone(), - recipient: dash_sdk::platform::dashpay::RecipientIdentity::Identity(recipient_identity), - sender_key_index, - recipient_key_index, - account_reference: account_index, - account_label, - auto_accept_proof, - }; - - let send_input = SendContactRequestInput { - contact_request: contact_request_input, - identity_public_key, - signer: SignerRef(signer), - }; - - let expected_key_id = sender_key_index; - let ecdh_provider: EcdhProvider< - _, - _, - fn( - &dashcore::secp256k1::PublicKey, - ) -> std::future::Ready>, - _, - > = EcdhProvider::SdkSide { - get_private_key: move |key: &IdentityPublicKey, _index: u32| { - let pk = ecdh_private_key; - let actual_key_id = key.id(); - async move { - if actual_key_id != expected_key_id { - return Err(dash_sdk::Error::Generic(format!( - "ECDH key mismatch: expected key {}, got {}", - expected_key_id, actual_key_id - ))); - } - Ok(pk) - } - }, + // 6. Client-side ECDH via the signer: the shared secret is derived in + // the signer (scalar at `sender_enc_path`) against the recipient's + // encryption key, so the SDK seam receives the finished secret + // (`EcdhProvider::ClientSide`) and no private key is ever materialized + // here. The recipient key is resolved exactly as the SDK would + // (`recipientKeyIndex` on the recipient identity); the seam re-checks + // the SDK asks for this same key before using the secret. + let recipient_enc_pubkey = { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let key = recipient_identity + .public_keys() + .get(&recipient_key_index) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Recipient identity has no key at index {recipient_key_index}" + )) + })?; + dashcore::secp256k1::PublicKey::from_slice(key.data().as_slice()).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Recipient encryption public key is invalid: {e}" + )) + })? }; + let shared_secret = crypto + .ecdh_shared_secret(&sender_enc_path, &recipient_enc_pubkey) + .await?; - let xpub_bytes_clone = xpub_bytes.clone(); + // 7. Broadcast through the write seam. All inputs are resolved + // above; the seam assembles the SDK `EcdhProvider` + xpub + // closure and dispatches `Sdk::send_contact_request`. Routing + // the broadcast through `sdk_writer` (rather than calling the + // seven-generic SDK method inline) is what makes this path + // testable without a live network — see `sdk_writer.rs`. let result = self - .sdk - .send_contact_request(send_input, ecdh_provider, |_account_ref: u32| async move { - Ok::, dash_sdk::Error>(xpub_bytes_clone) + .sdk_writer + .send_contact_request(SendContactRequestParams { + sender_identity: sender_identity.clone(), + recipient_identity, + sender_key_index, + recipient_key_index, + account_reference, + account_label, + auto_accept_proof, + shared_secret, + expected_recipient_pubkey: recipient_enc_pubkey, + xpub_bytes, + signing_public_key: identity_public_key, + signer: signer as &(dyn Signer + Send + Sync), }) - .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to send contact request: {e}" - )) - })?; + .await?; - // 7. Mirror the local-state bookkeeping in `send_contact_request`. + // 8. Mirror the local-state bookkeeping in `send_contact_request`. + // + // Store the REAL 96-byte ciphertext off the broadcast + // document (not a zero placeholder) so the persisted / + // SwiftData row matches what landed on Platform — a restored + // device comparing local rows against chain sees identity, + // and the sent-side re-ingest doesn't "upgrade" the row. + // Hard error rather than a zero-fill fallback: persisting a 96-byte + // all-zero "valid-looking" ciphertext would poison the local row + // (a restored device compares it to chain and mismatches; anything + // treating it as the contact's xpub source decrypts garbage). The + // broadcast already landed on-chain, so the sweep re-ingests + // the real document on the next pass — returning an error here is + // strictly safer than silently storing poison in release builds. + let encrypted_public_key = result + .document + .properties() + .get("encryptedPublicKey") + .and_then(|v: &Value| v.to_binary_bytes().ok()) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "broadcast contactRequest lacks a readable encryptedPublicKey; \ + the on-chain doc will reconcile on the next sync" + .to_string(), + ) + })?; let contact_request = ContactRequest::new( *sender_identity_id, result.recipient_id, sender_key_index, recipient_key_index, result.account_reference, - vec![0u8; 96], + encrypted_public_key, result.document.created_at_core_block_height().unwrap_or(0), result.document.created_at().unwrap_or(0), ); @@ -297,30 +688,260 @@ impl IdentityWallet { managed.add_sent_contact_request(contact_request.clone(), &self.persister); } - self.register_contact_account(sender_identity_id, recipient_identity_id, account_index) - .await?; + // Register our receiving (friendship) account from the xpub the signer + // already derived above — same friendship key, no second derivation and + // no resident seed. + self.register_contact_account( + sender_identity_id, + recipient_identity_id, + account_index, + contact_xpub_ext, + ) + .await?; Ok(contact_request) } } +/// QR-scan send lives in a non-generic `impl` block because it resolves a DPNS +/// name via [`Self::resolve_name`], which is defined on `impl IdentityWallet`. +impl IdentityWallet { + /// Send a contact request from a scanned DIP-15 auto-accept QR + /// (`dash:?du=&dapk=`). + /// + /// Resolves the QR's `du` username to the owner's identity, decodes the + /// handed auto-accept key from `dapk`, and sends a contact request carrying a + /// proof signed (in-send) over this send's `accountReference` — so the + /// owner's client can verify it and auto-accept without a manual tap. + pub async fn send_contact_request_from_qr( + &self, + sender_identity_id: &Identifier, + uri: &str, + signer: &S, + crypto: &C, + ) -> Result + where + S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, + { + use crate::wallet::identity::crypto::auto_accept::{ + decode_auto_accept_key_blob, parse_dashpay_contact_uri, + }; + + let (username, key_blob) = parse_dashpay_contact_uri(uri)?; + let recipient_id = self.resolve_name(&username).await?.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "auto-accept QR username '{username}' did not resolve to an identity" + )) + })?; + let (secret_key, expiry) = decode_auto_accept_key_blob(&key_blob)?; + + self.send_contact_request_with_external_signer( + sender_identity_id, + &recipient_id, + None, + AutoAcceptProofSource::SignWithKey { secret_key, expiry }, + signer, + crypto, + ) + .await + } +} + +/// Collapse a stream of parsed received contact requests to the single +/// newest request per sender, keyed by `sender_id`. +/// +/// "Newest" is the lexicographic max of `(created_at, account_reference)` +/// — created_at is the primary signal (a rotation request is broadcast +/// later), with account_reference as a deterministic tiebreak for the +/// degenerate same-timestamp case. +/// +/// This is the idempotency keystone of the recurring sync: on-chain +/// `contactRequest` docs are immutable and never deleted, so a sender who +/// rotated leaves both their old and bumped-reference docs returning on +/// every sweep. Feeding both into the ingest loop makes the stale one look +/// like a "rotation" away from the tracked state, thrashing it back and +/// forth each pass. Collapsing to the newest first makes the sweep a +/// fixpoint. +/// High-water rewind window applied to the incremental contact-request query. +/// Re-fetching the last 10 minutes each sweep covers clock skew **and** +/// equal-`$createdAt` documents straddling a page boundary, so it is +/// correctness-load-bearing — NOT a tunable; `0` is invalid. +const SYNC_OVERLAP_MS: u64 = 10 * 60_000; + +/// Lower bound for the incremental `$createdAt >` query: the high-water minus +/// the overlap window. `None` (no cursor yet) ⇒ full fetch. +fn query_lower_bound(high_water: Option) -> Option { + high_water.map(|hw| hw.saturating_sub(SYNC_OVERLAP_MS)) +} + +/// Advance a high-water cursor to the max `$createdAt` fetched this sweep, +/// never below its current value. `max_fetched` is the max over docs *seen* +/// (including ones ingest later collapses or skips — the cursor records +/// fetch-completeness, not ingest-success), `None` when nothing was fetched (a +/// zero-doc sweep leaves the cursor unchanged). The caller must only invoke +/// this when the paginate exhausted without error. +fn advance_high_water(current: Option, max_fetched: Option) -> Option { + match (current, max_fetched) { + (Some(c), Some(m)) => Some(c.max(m)), // never move backward + (None, m) => m, // first sweep: adopt what was fetched + (current, None) => current, // zero-doc sweep: leave unchanged + } +} + +/// Advance the cursor only if it still holds `snapshot` — the value read at the +/// start of the sweep. If it changed mid-sweep (an `unignore_sender` resets it +/// to `None` to force a re-fetch of a sender whose docs predate the cursor), +/// this sweep's `max_fetched` is stale — its fetch ran before the reset and +/// excluded that sender — so leave the new value rather than clobber the rewind. +/// Without this, a concurrent un-ignore is lost and the sender stays invisible +/// until a cold restart. +fn advance_if_unchanged( + current: Option, + snapshot: Option, + max_fetched: Option, +) -> Option { + if current == snapshot { + advance_high_water(snapshot, max_fetched) + } else { + current + } +} + +fn newest_received_per_sender( + requests: impl IntoIterator, +) -> std::collections::BTreeMap { + let mut newest: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for req in requests { + let sender = req.sender_id; + let replace = newest + .get(&sender) + .map(|cur| { + (req.created_at, req.account_reference) > (cur.created_at, cur.account_reference) + }) + .unwrap_or(true); + if replace { + newest.insert(sender, req); + } + } + newest +} + +/// Select the recipient identity's key id to reference in +/// `recipientKeyIndex` for an outgoing contact request. +/// +/// Verified testnet reality: the newest cohort uses a +/// recipient **DECRYPTION** key (our original convention), but the dominant +/// 126-owner mobile population has **no DECRYPTION key at all** and references +/// its **ENCRYPTION** key for `recipientKeyIndex`. To send to either cohort: +/// +/// 1. Prefer the recipient's first `ECDSA_SECP256K1` **DECRYPTION** key. +/// 2. Fall back to the recipient's first `ECDSA_SECP256K1` **ENCRYPTION** key. +/// 3. Error only if the recipient has neither. +/// +/// No AUTHENTICATION fallback: no live client population needs it, and reusing +/// signing keys for ECDH is poor key separation. `ECDSA_SECP256K1` is required +/// either way (every observed key is that type, and ECDH needs the full key). +fn select_recipient_key_index(recipient_identity: &Identity) -> Result { + // Skip disabled (revoked) keys: encrypting the DIP-15 compact xpub to a + // key whose private half may be compromised would hand the contact's + // payment xpub to whoever holds the revoked key. `disabled_at().is_none()` + // mirrors the validator's disabled-key gate. + // Prefer a DECRYPTION key. + if let Some((id, _)) = recipient_identity.public_keys().iter().find(|(_, k)| { + k.purpose() == Purpose::DECRYPTION + && k.key_type() == KeyType::ECDSA_SECP256K1 + && k.disabled_at().is_none() + }) { + return Ok(*id); + } + // Fall back to an ENCRYPTION key (mobile cohort). + if let Some((id, _)) = recipient_identity.public_keys().iter().find(|(_, k)| { + k.purpose() == Purpose::ENCRYPTION + && k.key_type() == KeyType::ECDSA_SECP256K1 + && k.disabled_at().is_none() + }) { + return Ok(*id); + } + Err(PlatformWalletError::InvalidIdentityData( + "Recipient identity has no enabled ECDSA_SECP256K1 DECRYPTION or ENCRYPTION key" + .to_string(), + )) +} + // --------------------------------------------------------------------------- // Sync contact requests from platform // --------------------------------------------------------------------------- +/// Max `AutoAccept` ops queued per owner. Bounds the work a flood of +/// junk-`autoAcceptProof` contact requests can create for the owner's next +/// signer-present drain; over the cap, requests stay manually acceptable. +const MAX_AUTO_ACCEPT_QUEUED_PER_OWNER: usize = 64; + +/// Count the ops that represent a contact **waiting for an unlock to finish +/// setup** — the needs-unlock banner's source. +/// +/// `RegisterReceiving` / `RegisterExternal` build a contact's payment account +/// and converge to 0 once drained (candidate selection skips contacts whose +/// external account already exists). `AutoAccept` is an inbound request with a +/// valid-looking proof awaiting auto-acceptance at the next signer-present drain +/// — also "waiting to finish setup," and it clears on accept/permanent-reject, +/// so it converges too. +/// +/// `ContactInfoDecrypt` is intentionally excluded: it is re-enqueued on every +/// signerless sweep (there is no already-decrypted gate), so it is structurally +/// always present and would make the signal a permanent `> 0` — re-tripping the +/// banner shortly after every unlock on a healthy wallet. +fn count_account_build_ops(queue: &[crate::changeset::PendingContactCrypto]) -> usize { + use crate::changeset::PendingContactCryptoOp; + queue + .iter() + .filter(|e| { + matches!( + e.op, + PendingContactCryptoOp::RegisterReceiving + | PendingContactCryptoOp::RegisterExternal { .. } + | PendingContactCryptoOp::AutoAccept + ) + }) + .count() +} + impl IdentityWallet { /// Fetch and process contact requests from the platform for all local identities. /// - /// For every identity in the local manager this method: - /// 1. Fetches received contact-request documents from Platform. - /// 2. Converts them into [`ContactRequest`] structs. - /// 3. Adds each as an incoming request to the corresponding - /// `ManagedIdentity` (which may auto-establish a contact when a - /// matching outgoing request already exists). + /// For every identity in the local manager this method, per sweep: + /// 1. Fetches both **received** and **own sent** contact-request + /// documents from Platform. + /// 2. Ingests received requests via `add_incoming_contact_request` — + /// including reciprocal requests from senders we already sent to (so + /// contacts establish via sync). Dedup is preserved for requests + /// already tracked as incoming or established, and every request from + /// an ignored sender is suppressed (per-sender — all of their requests, + /// rotations included). + /// 3. Ingests own sent requests via `add_sent_contact_request`, which + /// carries its own sent-side guard so a recurring re-ingest + /// creates no phantom pending rows and preserves contact metadata. + /// 4. For **every** established contact missing a sending account + /// (not only newly-established ones — this also repairs + /// restore-from-seed and best-effort-accept gaps), rebuilds both + /// the `DashpayReceivingFunds` and `DashpayExternalAccount` + /// accounts, with the transient/permanent failure policy. + /// + /// **Lock ordering (critical).** The account-building registrations + /// (`register_contact_account`, `register_external_contact_account`) + /// re-acquire the wallet-manager lock, which is a **non-reentrant** + /// tokio `RwLock`. Candidates are therefore collected while the write + /// guard is held, the guard is **dropped**, and only then are the + /// register functions called — mirroring the accept path. Calling + /// them inline under the guard would deadlock on first execution. /// /// Returns all newly discovered incoming contact requests. pub async fn sync_contact_requests(&self) -> Result, PlatformWalletError> { - let identity_ids: Vec = { + // Snapshot each identity's high-water cursors up front so the + // incremental query bound is read before any mutation this sweep. + let identities: Vec<(Identifier, Option, Option)> = { let wm = self.wallet_manager.read().await; let info = wm .get_wallet_info(&self.wallet_id) @@ -328,129 +949,1204 @@ impl IdentityWallet { info.identity_manager .all_identities() .into_iter() - .map(|i| i.id()) + .map(|i| { + let id = i.id(); + let (hwr, hws) = info + .identity_manager + .managed_identity(&id) + .map(|m| (m.high_water_received_ms, m.high_water_sent_ms)) + .unwrap_or((None, None)); + (id, hwr, hws) + }) .collect() }; let mut all_requests = Vec::new(); - for identity_id in identity_ids { - let received_docs = self + for (identity_id, hw_received, hw_sent) in identities { + // --- Fetch (no guard held during the awaits). --- + // + // Log-and-continue per identity: a fetch failure for one + // identity must NOT abort the sweep across the others. This + // is load-bearing for the recurring loop — a single + // identity's transient DAPI error shouldn't stall DashPay + // sync for every other identity on the wallet. + let received_docs = match self .sdk - .fetch_received_contact_requests(identity_id, None) + .fetch_received_contact_requests(identity_id, query_lower_bound(hw_received)) .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to fetch received contact requests: {e}" - )) - })?; - - let mut wm = self.wallet_manager.write().await; - let info = match wm.get_wallet_info_mut(&self.wallet_id) { - Some(i) => i, - None => continue, + { + Ok(docs) => docs, + Err(e) => { + tracing::warn!( + identity = %identity_id, + error = %e, + "Failed to fetch received contact requests; skipping this identity" + ); + continue; + } }; - let managed = match info.identity_manager.managed_identity_mut(&identity_id) { - Some(m) => m, - None => continue, + // Also fetch our own sent requests so a restored / second + // device reconciles established contacts instead of rendering + // them as bare incoming requests. A failure here is logged but + // does not skip the received-side ingest already fetched above — + // and the sent cursor is NOT advanced when this fails. + let mut sent_ok = true; + let sent_docs = match self + .sdk + .fetch_sent_contact_requests(identity_id, query_lower_bound(hw_sent)) + .await + { + Ok(docs) => docs, + Err(e) => { + tracing::warn!( + identity = %identity_id, + error = %e, + "Failed to fetch sent contact requests; reconciling received side only" + ); + sent_ok = false; + Default::default() + } }; - for (_doc_id, maybe_doc) in received_docs.iter() { - let doc = match maybe_doc { - Some(d) => d, + // Max `$createdAt` over docs FETCHED this sweep (not over docs that + // survive ingest's collapse/dedup) — the cursor records + // fetch-completeness. Reaching here means the received fetch + // exhausted without error, so its cursor may advance; the sent + // cursor advances only if `sent_ok`. + let max_received = received_docs + .values() + .filter_map(|d| d.as_ref()) + .filter_map(|d| d.created_at()) + .max(); + let max_sent = sent_docs + .values() + .filter_map(|d| d.as_ref()) + .filter_map(|d| d.created_at()) + .max(); + + // --- Ingest under the write guard; collect account-building + // candidates; then DROP the guard before registering. --- + let candidates = { + let mut wm = self.wallet_manager.write().await; + let Some((wallet, info)) = wm.get_wallet_mut_and_info_mut(&self.wallet_id) else { + continue; + }; + let managed = match info.identity_manager.managed_identity_mut(&identity_id) { + Some(m) => m, None => continue, }; + // Established contacts re-keyed by a rotation request in + // this pass — their stale external accounts are torn down + // below so the build sweep re-registers from the new xpub. + let mut rotated_contacts: Vec = Vec::new(); + + // (1) Ingest received requests. + // + // Immutable contactRequest docs are never deleted on-chain, + // so a sender who rotated leaves MULTIPLE docs — the old + // reference plus the bumped one — that ALL return on every + // sweep. Collapse to the single newest doc per sender BEFORE + // ingest (see `newest_received_per_sender`). Without this, a + // stale older doc is mis-read as a "rotation" away from the + // tracked state on every sweep, flipping the stored reference + // back and forth, tearing down + rebuilding the external + // account, and writing a changeset each pass forever. + let parsed_received = received_docs.iter().filter_map(|(_doc_id, maybe_doc)| { + let doc = maybe_doc.as_ref()?; + Self::parse_contact_request_doc(doc, doc.owner_id(), identity_id) + }); + let newest_by_sender = newest_received_per_sender(parsed_received); + + for (sender_id, contact_request) in newest_by_sender { + // Ignore (per-sender mute, local-only): an ignored + // sender's requests are ALL suppressed from the main + // pending list — including rotated (bumped + // accountReference) ones. Checked FIRST and per-sender, + // unlike the old per-(sender, accountReference) reject: + // if you ignored the person you ignored them. + // `unignore_sender` rewinds the cursor so this skip stops + // firing on the next sweep. + if managed.is_sender_ignored(&sender_id) { + tracing::debug!( + sender = %sender_id, + recipient = %identity_id, + account_reference = contact_request.account_reference, + "Skipping ignored sender's contact request" + ); + continue; + } + // Do NOT skip just because the sender is in + // `sent_contact_requests` — that is the reciprocal we + // need to let through to auto-establish. True dedup is + // (sender, accountReference): the SAME reference as the + // tracked incoming/established state is a re-ingest of a + // known doc; a DIFFERENT reference from a known sender + // is a rotation request (receive side) and must get + // through. + let tracked_reference = managed + .incoming_contact_requests + .get(&sender_id) + .map(|r| r.account_reference) + .or_else(|| { + managed + .established_contacts + .get(&sender_id) + .map(|c| c.incoming_request.account_reference) + }); + if tracked_reference == Some(contact_request.account_reference) { + continue; + } - let sender_id = doc.owner_id(); + if tracked_reference.is_some() { + // Rotation: supersede the tracked request. When an + // established contact was re-keyed, queue the stale + // external account for teardown so the build sweep + // below re-registers it from the new xpub. + if managed.apply_rotated_incoming_request( + contact_request.clone(), + &self.persister, + ) { + rotated_contacts.push(sender_id); + } + all_requests.push(contact_request); + continue; + } - // Skip if already tracked (sent, incoming, or established). - if managed.sent_contact_requests.contains_key(&sender_id) - || managed.incoming_contact_requests.contains_key(&sender_id) - || managed.established_contacts.contains_key(&sender_id) - { - continue; + managed.add_incoming_contact_request(contact_request.clone(), &self.persister); + all_requests.push(contact_request); } - let props = doc.properties(); - - let sender_key_index = match props - .get("senderKeyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - { - Some(v) => v, - None => { - tracing::warn!( - sender = %sender_id, - recipient = %identity_id, - "Skipping contact request document: missing senderKeyIndex" - ); + // (2) Ingest our own sent requests. `add_sent_contact_request` + // guards itself against duplicates / metadata loss. + for (_doc_id, maybe_doc) in sent_docs.iter() { + let doc = match maybe_doc { + Some(d) => d, + None => continue, + }; + // For a sent request the recipient is `toUserId`. + let recipient_id = match doc + .properties() + .get("toUserId") + .and_then(|v: &Value| v.to_identifier().ok()) + { + Some(v) => v, + None => continue, + }; + let Some(contact_request) = + Self::parse_sent_contact_request_doc(doc, identity_id, recipient_id) + else { continue; + }; + managed.add_sent_contact_request(contact_request, &self.persister); + } + + // (2b) Tear down stale external accounts for contacts that + // rotated in this pass: both the immutable Account + // (old xpub — `send_payment`'s derivation source) and + // the managed wrapper (old address pool). The + // candidate collection below then re-queues them and + // the build step re-registers from the NEW encrypted + // xpub. The persisted account row is upserted (same + // unique key) when the re-registration round lands. + for contact_id in &rotated_contacts { + use key_wallet::account::account_collection::DashpayAccountKey; + let key = DashpayAccountKey { + index: 0, + user_identity_id: identity_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }; + wallet.accounts.dashpay_external_accounts.remove(&key); + info.core_wallet + .accounts + .dashpay_external_accounts + .remove(&key); + } + + // Advance the high-water cursors to the max `$createdAt` + // fetched this sweep, never below the current value. The + // received fetch reached here only on success; advance the + // sent cursor only if its fetch also succeeded. A mid-sweep + // fetch error therefore leaves that direction's cursor intact + // so the overlap re-fetches next sweep (no burying). + // + // Compare-and-advance (see `advance_if_unchanged`): a concurrent + // `unignore_sender` may have reset the cursor mid-sweep to force + // a re-fetch; this sweep's stale `max` must not clobber that. + managed.high_water_received_ms = + advance_if_unchanged(managed.high_water_received_ms, hw_received, max_received); + if sent_ok { + managed.high_water_sent_ms = + advance_if_unchanged(managed.high_water_sent_ms, hw_sent, max_sent); + } + + // (3) Collect account-building candidates: every established + // contact missing a sending (external) account, skipping + // contacts whose payment channel is already marked + // permanently broken (no unbounded retry). + Self::collect_account_build_candidates(info, &identity_id) + }; + + // --- Build accounts AFTER dropping the write guard. --- + for candidate in candidates { + self.build_contact_accounts(&identity_id, candidate).await; + } + + // (4) Enqueue DIP-15 auto-accept for inbound requests carrying a + // proof. Signerless: verify + accept happen later in + // `drain_auto_accepts` at a signer-present moment. + self.enqueue_pending_auto_accepts(&identity_id).await; + } + + Ok(all_requests) + } + + /// Parse a received `contactRequest` document into a [`ContactRequest`], + /// logging + returning `None` on any missing required field. + fn parse_contact_request_doc( + doc: &dpp::document::Document, + sender_id: Identifier, + recipient_id: Identifier, + ) -> Option { + let props = doc.properties(); + let sender_key_index = props + .get("senderKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()); + let recipient_key_index = props + .get("recipientKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()); + let account_reference = props + .get("accountReference") + .and_then(|v: &Value| v.to_integer::().ok()); + let encrypted_public_key = props + .get("encryptedPublicKey") + .and_then(|v: &Value| v.as_bytes()) + .cloned(); + // Optional DIP-15 auto-accept proof — read so the sweep can enqueue an + // `AutoAccept` drain. Without this it would be dropped (the proof can't + // be acted on if it never reaches the request), so the contact would only + // ever be addable manually. + let auto_accept_proof = props + .get("autoAcceptProof") + .and_then(|v: &Value| v.as_bytes()) + .cloned(); + + match ( + sender_key_index, + recipient_key_index, + account_reference, + encrypted_public_key, + ) { + (Some(ski), Some(rki), Some(ar), Some(epk)) => { + let mut request = ContactRequest::new( + sender_id, + recipient_id, + ski, + rki, + ar, + epk, + doc.created_at_core_block_height().unwrap_or(0), + doc.created_at().unwrap_or(0), + ); + request.auto_accept_proof = auto_accept_proof; + Some(request) + } + _ => { + tracing::warn!( + sender = %sender_id, + recipient = %recipient_id, + "Skipping contact request document: missing required field" + ); + None + } + } + } + + /// Parse our own sent `contactRequest` document into a [`ContactRequest`] + /// (owner is us, recipient is `toUserId`). + fn parse_sent_contact_request_doc( + doc: &dpp::document::Document, + owner_id: Identifier, + recipient_id: Identifier, + ) -> Option { + // Same field set as the received side; the only difference is which + // identity is owner vs recipient. + Self::parse_contact_request_doc(doc, owner_id, recipient_id) + } + + /// Enqueue a DIP-15 `AutoAccept` op for each inbound contact request to + /// `identity_id` that carries a structurally-valid `autoAcceptProof` and is + /// not yet established — so the next signer-present [`drain_auto_accepts`] + /// verifies + auto-accepts it. Signerless (the sweep has no signer): only a + /// cheap structural pre-check (length + ECDSA key-type byte) runs here; the + /// cryptographic verify happens in the drain. + /// + /// Bounded to [`MAX_AUTO_ACCEPT_QUEUED_PER_OWNER`] entries per owner so a + /// flood of junk-proof requests can't grow the queue without limit — over + /// the cap the request is simply left manually acceptable. Dedup by + /// `(owner, sender, AutoAccept)` means re-runs are idempotent. + async fn enqueue_pending_auto_accepts(&self, identity_id: &Identifier) { + use crate::changeset::{ + upsert_pending_contact_crypto, PendingContactCrypto, PendingContactCryptoOp, + PlatformWalletChangeSet, + }; + + // Collect candidate senders + the current AutoAccept count under a read + // guard (no awaits held). + let to_enqueue: Vec = { + let wm = self.wallet_manager.read().await; + let Some(info) = wm.get_wallet_info(&self.wallet_id) else { + return; + }; + let mut already = info + .pending_contact_crypto + .iter() + .filter(|e| { + e.owner_identity_id == *identity_id + && matches!(e.op, PendingContactCryptoOp::AutoAccept) + }) + .count(); + let Some(managed) = info.identity_manager.managed_identity(identity_id) else { + return; + }; + let mut picked = Vec::new(); + for (sender, request) in &managed.incoming_contact_requests { + if already >= MAX_AUTO_ACCEPT_QUEUED_PER_OWNER { + tracing::warn!( + owner = %identity_id, + "auto-accept enqueue cap reached; leaving further requests manually acceptable" + ); + break; + } + if managed.established_contacts.contains_key(sender) { + continue; // already a contact + } + // Structural pre-check only (no signer here): DIP-15 size + the + // ECDSA key-type byte. The real ECDSA verify is in the drain. + let structurally_ok = request + .auto_accept_proof + .as_deref() + .is_some_and(|p| (38..=102).contains(&p.len()) && p[0] == 0x00); + if structurally_ok { + picked.push(*sender); + already += 1; + } + } + picked + }; + if to_enqueue.is_empty() { + return; + } + + let enqueued_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let entries: Vec = to_enqueue + .into_iter() + .map(|sender| PendingContactCrypto { + owner_identity_id: *identity_id, + contact_id: sender, + op: PendingContactCryptoOp::AutoAccept, + enqueued_at_ms, + }) + .collect(); + + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + for entry in &entries { + upsert_pending_contact_crypto(&mut info.pending_contact_crypto, entry.clone()); + } + } + } + let changeset = PlatformWalletChangeSet { + pending_contact_crypto_added: entries, + ..Default::default() + }; + if let Err(e) = self.persister.store(changeset) { + tracing::warn!( + owner = %identity_id, error = %e, + "failed to persist auto-accept enqueue; will re-enqueue next sweep" + ); + } + } + + /// Collect every established contact (for `identity_id`) that is + /// missing its `DashpayExternalAccount` and is NOT already marked + /// permanently broken — the account-building candidates for this + /// sweep. Runs under the caller's write guard; performs no + /// awaits and no lock re-acquisition. + fn collect_account_build_candidates( + info: &crate::wallet::platform_wallet::PlatformWalletInfo, + identity_id: &Identifier, + ) -> Vec { + use key_wallet::account::account_collection::DashpayAccountKey; + + let Some(managed) = info.identity_manager.managed_identity(identity_id) else { + return Vec::new(); + }; + + let mut out = Vec::new(); + for (contact_id, contact) in &managed.established_contacts { + // Never retry a permanently-broken channel — wait for a + // superseding request (which clears the flag on re-establish). + if contact.payment_channel_broken { + continue; + } + let key = DashpayAccountKey { + index: 0, + user_identity_id: identity_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }; + let has_external = info + .core_wallet + .accounts + .dashpay_external_accounts + .contains_key(&key); + if has_external { + continue; + } + // The incoming request carries the counterparty's encrypted + // xpub + the key indices needed for ECDH. + let incoming = &contact.incoming_request; + out.push(AccountBuildCandidate { + contact_id: *contact_id, + encrypted_public_key: incoming.encrypted_public_key.clone(), + our_decryption_key_index: incoming.recipient_key_index, + contact_encryption_key_index: incoming.sender_key_index, + }); + } + out + } + + /// Build the two DashPay accounts for one established contact, + /// applying the transient/permanent failure policy. + /// + /// Order: + /// 1. Register the `DashpayReceivingFunds` account — derivable from our + /// own seed, no decryption needed. This is what makes *incoming* + /// contact payments visible to SPV; restore-from-seed leaves it + /// unbuilt, so the sweep rebuilds it for every established contact. + /// 2. Fetch the counterparty identity and **validate** the request's + /// key indices via [`validate_contact_request`] BEFORE any ECDH — + /// an attacker-crafted index pointing at an AUTHENTICATION key would + /// otherwise derive a wrong shared secret and poison the account. + /// 3. Register the `DashpayExternalAccount` (decrypt + ECDH). + /// + /// Failure policy: + /// - **Transient** (identity fetch / network): logged, left for the + /// next sweep to retry. The broken flag stays clear. + /// - **Permanent** (validation failure, decrypt/decode failure): the + /// contact is marked `payment_channel_broken` so subsequent sweeps + /// skip it until a superseding request arrives. + /// + /// Watch-only / seedless wallets (no `identity_index`) are skipped and + /// logged — the watch-only ECDH path (host-side signing hook) lands + /// later. + /// + /// Called **after** the sync write guard is dropped: the register + /// functions re-acquire the non-reentrant wallet-manager lock. + async fn build_contact_accounts( + &self, + identity_id: &Identifier, + candidate: AccountBuildCandidate, + ) { + let contact_id = candidate.contact_id; + + // The recurring sweep has NO signer (it runs unattended in the + // background), so it can derive NO private-key material — neither the + // receiving (friendship) xpub nor the ECDH shared secret. Every + // account-build op is therefore DEFERRED: enqueue it for the + // signer-backed drain to complete when a signer becomes available + // (Keychain unlock / signer-present action). The drain fetches the + // contact, validates the key indices, and performs the derivation. + // + // We only SKIP identities that aren't ours to build (unmanaged / + // out-of-wallet — no HD slot); there is nothing to enqueue for those. + let is_ours = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(identity_id)) + .map(|managed| managed.identity_index.is_some()) + .unwrap_or(false) + }; + if !is_ours { + tracing::info!( + identity = %identity_id, + contact = %contact_id, + "Skipping DashPay account build for unmanaged/out-of-wallet identity" + ); + return; + } + + // Enqueue the deferred crypto ops (receiving xpub + external decrypt). + // Idempotent per (owner, contact, kind), so re-enqueuing every sweep is + // a no-op until the drain clears them. + self.enqueue_deferred_contact_crypto(identity_id, &candidate) + .await; + tracing::info!( + identity = %identity_id, + contact = %contact_id, + "Deferred DashPay account build: enqueued for the signer-backed drain" + ); + } + + /// Enqueue the deferred contact-crypto ops for a contact discovered by the + /// signerless sweep. The sweep never derives, so this is its only + /// account-build action; the signer-backed drain completes the ops when a + /// signer is available. Idempotent per `(owner, contact, kind)` — + /// re-enqueuing each sweep updates the entry in place. Stores only the + /// on-chain ciphertext + public key indices, never a secret. + async fn enqueue_deferred_contact_crypto( + &self, + identity_id: &Identifier, + candidate: &AccountBuildCandidate, + ) { + use crate::changeset::{ + upsert_pending_contact_crypto, PendingContactCrypto, PendingContactCryptoOp, + PlatformWalletChangeSet, + }; + let enqueued_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + let entries = vec![ + // (1) Our receiving xpub (no payload — derived from the identity ids). + PendingContactCrypto { + owner_identity_id: *identity_id, + contact_id: candidate.contact_id, + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms, + }, + // (2) The external account — ECDH decrypt of the contact's xpub. + PendingContactCrypto { + owner_identity_id: *identity_id, + contact_id: candidate.contact_id, + op: PendingContactCryptoOp::RegisterExternal { + encrypted_public_key: candidate.encrypted_public_key.clone(), + our_decryption_key_index: candidate.our_decryption_key_index, + contact_encryption_key_index: candidate.contact_encryption_key_index, + }, + enqueued_at_ms, + }, + ]; + + // In-memory upsert under the write lock (released before persisting). + { + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + for entry in &entries { + upsert_pending_contact_crypto(&mut info.pending_contact_crypto, entry.clone()); + } + } + + // Persist the add-delta so the queue survives a restart. Best-effort: + // the recurring sweep re-discovers + re-enqueues if this fails (the + // in-memory queue above already covers the current session). + let changeset = PlatformWalletChangeSet { + pending_contact_crypto_added: entries, + ..Default::default() + }; + if let Err(e) = self.persister.store(changeset) { + tracing::warn!( + identity = %identity_id, contact = %candidate.contact_id, error = %e, + "failed to persist deferred contact-crypto enqueue; will re-enqueue next sweep" + ); + } + } + + /// Number of deferred **account-build** contact-crypto ops queued for this + /// wallet (in-memory): the `RegisterReceiving` / `RegisterExternal` ops that + /// build a contact's payment account and need a signer unlock to complete. + /// + /// A `> 0` count means some contacts are waiting for an unlock to finish + /// setup. It is a wallet-scoped upper bound — it aggregates across the + /// wallet's identities and includes ops that may resolve to channel-broken + /// on the next drain — so a caller should phrase it as "waiting," not + /// "will succeed." `ContactInfoDecrypt` is excluded (see + /// [`count_account_build_ops`]). Signerless / public read; no persistence. + pub async fn pending_contact_crypto_count(&self) -> usize { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .map(|info| count_account_build_ops(&info.pending_contact_crypto)) + .unwrap_or(0) + } + + /// Drain the persisted deferred-crypto queue using `provider` for the + /// Keychain-derived key material. Call when a signer is available (Keychain + /// unlock, or any signer-present DashPay action). Returns the number of + /// entries completed (removed from the queue). + /// + /// Per entry: run the op; on success remove it and persist the removal; on + /// unavailable/transient failure leave it for the next drain. The + /// `RegisterExternal` + `ContactInfoDecrypt` ops (which need a contact + /// fetch + ECDH/contactInfo derivation) drain in a follow-up and are left + /// queued here — so calling this is always safe, it just completes what it + /// can. + pub async fn drain_pending_contact_crypto( + &self, + provider: &P, + ) -> usize { + use crate::changeset::{PendingContactCryptoKey, PendingContactCryptoOp}; + + // Snapshot the queue, then run the async ops without holding the lock. + let entries: Vec = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .map(|info| info.pending_contact_crypto.clone()) + .unwrap_or_default() + }; + if entries.is_empty() { + return 0; + } + + let mut cleared: Vec = Vec::new(); + for entry in &entries { + match &entry.op { + PendingContactCryptoOp::RegisterReceiving => { + // Build the friendship path in Rust; the provider derives + // our receiving xpub at it via the Keychain signer. + let account_type = key_wallet::account::AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: entry.owner_identity_id.to_buffer(), + friend_identity_id: entry.contact_id.to_buffer(), + }; + let path = match account_type.derivation_path(self.sdk.network) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + error = %e, "drain: receiving path build failed; leaving queued" + ); + continue; + } + }; + match provider.receiving_xpub(&path).await { + Ok(xpub) => match self + .register_contact_account( + &entry.owner_identity_id, + &entry.contact_id, + 0, + xpub, + ) + .await + { + Ok(()) => cleared.push(entry.key()), + Err(e) => tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + error = %e, "drain: register receiving account failed; leaving queued" + ), + }, + Err(e) => tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + error = %e, "drain: receiving-xpub provider failed; leaving queued" + ), } - }; - let recipient_key_index = match props - .get("recipientKeyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - { - Some(v) => v, - None => { + } + PendingContactCryptoOp::RegisterExternal { + encrypted_public_key, + our_decryption_key_index, + contact_encryption_key_index, + } => { + // Our HD index, for the ECDH derivation path. If the owner + // isn't wallet-owned, this op can't be ours — leave queued. + let identity_index = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| { + info.identity_manager + .managed_identity(&entry.owner_identity_id) + }) + .and_then(|m| m.identity_index) + }; + let Some(identity_index) = identity_index else { tracing::warn!( - sender = %sender_id, - recipient = %identity_id, - "Skipping contact request document: missing recipientKeyIndex" + owner = %entry.owner_identity_id, contact = %entry.contact_id, + "drain: owner not wallet-owned; leaving queued" ); continue; - } - }; - let account_reference = match props - .get("accountReference") - .and_then(|v: &Value| v.to_integer::().ok()) - { - Some(v) => v, - None => { + }; + + // ECDH path, built in Rust (path provenance stays here). + let path = match Self::identity_auth_derivation_path( + self.sdk.network, + key_wallet::bip32::KeyDerivationType::ECDSA, + identity_index, + *our_decryption_key_index, + ) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + error = %e, "drain: ECDH path build failed; leaving queued" + ); + continue; + } + }; + + // Fetch the contact identity (transient on failure → leave). + let contact_identity = { + use dash_sdk::platform::Fetch; + match Identity::fetch(&self.sdk, entry.contact_id).await { + Ok(Some(id)) => id, + Ok(None) => { + tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + "drain: contact identity not on Platform; leaving queued" + ); + continue; + } + Err(e) => { + tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + error = %e, "drain: contact fetch failed; leaving queued" + ); + continue; + } + } + }; + + // Validate key indices (purpose + type) BEFORE ECDH — the + // same gate the resident sweep path applies, so the deferred + // path enforces the identical contract. A purpose-only + // mismatch (e.g. a legacy doc referencing an AUTH key) is left + // queued for a future acceptance-policy change; a hard failure + // (key type / missing / disabled) marks the channel broken and + // clears the entry. + let our_identity = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| { + info.identity_manager + .managed_identity(&entry.owner_identity_id) + }) + .map(|m| m.identity.clone()) + }; + let Some(our_identity) = our_identity else { tracing::warn!( - sender = %sender_id, - recipient = %identity_id, - "Skipping contact request document: missing accountReference" + owner = %entry.owner_identity_id, contact = %entry.contact_id, + "drain: our identity vanished mid-drain; leaving queued" ); continue; - } - }; - let encrypted_public_key = match props - .get("encryptedPublicKey") - .and_then(|v: &Value| v.as_bytes()) - .cloned() - { - Some(v) => v, - None => { + }; + let validation = + crate::wallet::identity::crypto::validation::validate_contact_request( + &contact_identity, + *contact_encryption_key_index, + &our_identity, + *our_decryption_key_index, + ); + if !validation.is_valid { + if validation.is_purpose_only() { + tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + errors = ?validation.errors, + "drain: contact request key-purpose mismatch; leaving queued (not marking broken)" + ); + continue; + } tracing::warn!( - sender = %sender_id, - recipient = %identity_id, - "Skipping contact request document: missing encryptedPublicKey" + owner = %entry.owner_identity_id, contact = %entry.contact_id, + errors = ?validation.errors, + "drain: contact request failed key-index validation; marking channel broken" ); + self.mark_contact_channel_broken( + &entry.owner_identity_id, + &entry.contact_id, + ) + .await; + cleared.push(entry.key()); continue; } - }; - let contact_request = ContactRequest::new( - sender_id, - identity_id, - sender_key_index, - recipient_key_index, - account_reference, - encrypted_public_key, - doc.created_at_core_block_height().unwrap_or(0), - doc.created_at().unwrap_or(0), + // The contact's encryption pubkey (peer). A malformed/missing + // key is a permanent fault — re-deriving won't help. + let peer = match contact_identity + .public_keys() + .get(contact_encryption_key_index) + { + Some(k) => { + match dashcore::secp256k1::PublicKey::from_slice(k.data().as_slice()) { + Ok(pk) => pk, + Err(e) => { + tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + error = %e, + "drain: contact encryption key invalid; marking channel broken" + ); + self.mark_contact_channel_broken( + &entry.owner_identity_id, + &entry.contact_id, + ) + .await; + cleared.push(entry.key()); + continue; + } + } + } + None => { + tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + "drain: contact encryption key missing; marking channel broken" + ); + self.mark_contact_channel_broken( + &entry.owner_identity_id, + &entry.contact_id, + ) + .await; + cleared.push(entry.key()); + continue; + } + }; + + // ECDH via the Keychain-backed provider (scalar stays in the + // signer; we only get the shared secret). + let shared = match provider.ecdh_shared_secret(&path, &peer).await { + Ok(s) => s, + Err(e) => { + tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + error = %e, "drain: ECDH provider failed; leaving queued" + ); + continue; + } + }; + + match self + .register_external_contact_account( + &entry.owner_identity_id, + &contact_identity, + encrypted_public_key, + shared, + ) + .await + { + Ok(()) => cleared.push(entry.key()), + Err(e) if e.is_permanent() => { + tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + error = %e.into_inner(), + "drain: external register permanent fault; marking channel broken" + ); + self.mark_contact_channel_broken( + &entry.owner_identity_id, + &entry.contact_id, + ) + .await; + cleared.push(entry.key()); + } + Err(e) => tracing::warn!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + error = %e.into_inner(), + "drain: external register transient/unavailable; leaving queued" + ), + } + } + PendingContactCryptoOp::ContactInfoDecrypt => { + // Re-fetch the owner's contactInfo docs + decrypt + apply via + // the signer (the op carries no payload, so the latest + // published version always wins). The owner-ownership / + // confused-deputy guard lives in `drain_contact_info_decrypt`. + match self + .drain_contact_info_decrypt(&entry.owner_identity_id, provider) + .await + { + Ok(applied) => { + tracing::debug!( + owner = %entry.owner_identity_id, applied, + "drain: contactInfo decrypted + applied" + ); + cleared.push(entry.key()); + } + // Provider/fetch failure (signer unavailable, network blip, + // or a non-owned entry) → leave queued for the next drain. + Err(e) => tracing::warn!( + owner = %entry.owner_identity_id, error = %e, + "drain: contactInfo decrypt failed; leaving queued" + ), + } + } + PendingContactCryptoOp::AutoAccept => { + // Verifying the proof and sending the reciprocal both need the + // identity signer, which this provider-only drain doesn't + // carry. Handled by `drain_auto_accepts` at a signer-present + // moment; skip here so the entry stays queued (the inbound + // request remains manually acceptable meanwhile). + } + } + } + + if cleared.is_empty() { + return 0; + } + + // Remove the completed entries from the in-memory queue + persist the + // removal so they don't replay after a restart. + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + info.pending_contact_crypto + .retain(|e| !cleared.iter().any(|k| *k == e.key())); + } + } + let changeset = crate::changeset::PlatformWalletChangeSet { + pending_contact_crypto_cleared: cleared.clone(), + ..Default::default() + }; + if let Err(e) = self.persister.store(changeset) { + tracing::warn!( + error = %e, + "drain: failed to persist cleared queue entries (in-memory already updated)" + ); + } + + cleared.len() + } + + /// Drain queued `AutoAccept` ops (DIP-15 QR auto-accept) — verify each + /// inbound request's `autoAcceptProof` and, if valid + unexpired, auto-accept + /// it (send the reciprocal). Requires the identity `signer` (the reciprocal is + /// a signed state transition) as well as the crypto `provider` (to derive our + /// auto-accept public key); the provider-only [`drain_pending_contact_crypto`] + /// deliberately skips these. Returns the number auto-accepted. + /// + /// Anti-DoS: the cheap local checks (proof present, expiry, ECDSA verify + /// against our own re-derived key) run **before** any network/accept, so a + /// flood of junk proofs is cleared without per-entry round-trips. Verdict + /// mapping: invalid / expired / malformed / bad-index ⇒ permanent (clear); + /// provider-unavailable / accept-send failure ⇒ transient (leave queued). + pub async fn drain_auto_accepts(&self, signer: &S, provider: &P) -> usize + where + S: Signer + Send + Sync, + P: ContactCryptoProvider + Sync, + { + use crate::changeset::{PendingContactCryptoKey, PendingContactCryptoOp}; + use crate::wallet::identity::crypto::auto_accept::{ + auto_accept_derivation_path, auto_accept_proof_expiry, + verify_auto_accept_proof_with_pubkey, + }; + + // Snapshot just the AutoAccept entries. + let entries: Vec = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .map(|info| { + info.pending_contact_crypto + .iter() + .filter(|e| matches!(e.op, PendingContactCryptoOp::AutoAccept)) + .cloned() + .collect() + }) + .unwrap_or_default() + }; + if entries.is_empty() { + return 0; + } + + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let mut cleared: Vec = Vec::new(); + let mut accepted: usize = 0; + + for entry in &entries { + let owner = entry.owner_identity_id; // us (the QR owner / recipient) + let sender = entry.contact_id; // the scanner (request $ownerId) + + // Re-load the inbound request (carrying the proof) from local state. + let request = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(&owner)) + .and_then(|mi| mi.incoming_contact_requests.get(&sender).cloned()) + }; + let Some(request) = request else { + // Gone (already established / removed) — nothing to do. + cleared.push(entry.key()); + continue; + }; + let Some(proof) = request.auto_accept_proof.as_deref() else { + cleared.push(entry.key()); // no proof (shouldn't happen) — permanent + continue; + }; + + // Expiry is the proof's embedded index — the same value that keys + // verification, so it can't be lied about independently of the sig. + let Some(expiry) = auto_accept_proof_expiry(proof) else { + cleared.push(entry.key()); // malformed — permanent + continue; + }; + if now_secs > expiry as u64 { + tracing::info!( + owner = %owner, sender = %sender, expiry, + "auto-accept: proof expired; clearing (request stays manually acceptable)" ); + cleared.push(entry.key()); // expired — permanent + continue; + } + + // Derive OUR auto-accept public key at the proof's expiry, via the + // provider (seedless — no resident wallet). Local ECDSA verify runs + // before any network/accept (anti-DoS). Bind the sender to the + // consensus-authenticated request `$ownerId` (request.sender_id). + let path = match auto_accept_derivation_path(self.sdk.network, expiry) { + Ok(p) => p, + Err(e) => { + tracing::warn!(owner = %owner, sender = %sender, error = %e, + "auto-accept: bad expiry index; clearing"); + cleared.push(entry.key()); // bad index — permanent + continue; + } + }; + let pubkey = match provider.receiving_xpub(&path).await { + Ok(xpub) => xpub.public_key, + Err(e) => { + tracing::warn!(owner = %owner, sender = %sender, error = %e, + "auto-accept: provider unavailable; leaving queued"); + continue; // transient — leave queued + } + }; + let valid = verify_auto_accept_proof_with_pubkey( + &pubkey, + proof, + &request.sender_id, + &owner, + request.account_reference, + ); + if !valid { + tracing::warn!(owner = %owner, sender = %sender, + "auto-accept: proof did not verify; clearing"); + cleared.push(entry.key()); // invalid — permanent + continue; + } - managed.add_incoming_contact_request(contact_request.clone(), &self.persister); - all_requests.push(contact_request); + // Valid + unexpired → accept (send the reciprocal). Idempotent. + match self + .accept_contact_request_with_external_signer(&request, signer, provider) + .await + { + Ok(_) => { + tracing::info!(owner = %owner, sender = %sender, + "auto-accept: proof verified; contact auto-accepted"); + accepted += 1; + cleared.push(entry.key()); + } + Err(e) => tracing::warn!(owner = %owner, sender = %sender, error = %e, + "auto-accept: reciprocal send failed; leaving queued"), } } - Ok(all_requests) + if !cleared.is_empty() { + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + info.pending_contact_crypto + .retain(|e| !cleared.iter().any(|k| *k == e.key())); + } + } + let changeset = crate::changeset::PlatformWalletChangeSet { + pending_contact_crypto_cleared: cleared, + ..Default::default() + }; + if let Err(e) = self.persister.store(changeset) { + tracing::warn!(error = %e, + "auto-accept: failed to persist cleared entries (in-memory already updated)"); + } + } + + accepted + } + + /// Build a DIP-15 auto-accept QR URI (`dash:?du=&dapk=`), + /// valid for [`AUTO_ACCEPT_TTL_SECS`](crate::wallet::identity::crypto::auto_accept::AUTO_ACCEPT_TTL_SECS). + /// + /// Derives the wallet's auto-accept key at `m/9'/coin'/16'/expiry'` via + /// `provider` — the deliberate raw-key export (the key is a bearer credential + /// the QR shares) — encodes the 38-byte `dapk` blob, and assembles the URI. + /// `username` is the owner's DPNS name (required so a scanner can resolve the + /// owner's identity); errors if empty. + pub async fn build_auto_accept_qr( + &self, + username: &str, + provider: &P, + ) -> Result { + use crate::wallet::identity::crypto::auto_accept::{ + auto_accept_derivation_path, encode_auto_accept_key_blob, encode_dashpay_contact_uri, + AUTO_ACCEPT_TTL_SECS, + }; + if username.is_empty() { + return Err(PlatformWalletError::InvalidIdentityData( + "auto-accept QR requires a DPNS username".to_string(), + )); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as u32) + .unwrap_or(0); + let expiry = now.saturating_add(AUTO_ACCEPT_TTL_SECS); + let path = auto_accept_derivation_path(self.sdk.network, expiry)?; + let secret_key = provider.export_auto_accept_private_key(&path).await?; + let blob = encode_auto_accept_key_blob(&secret_key, expiry); + Ok(encode_dashpay_contact_uri(username, &blob)) + } + + /// Mark an established contact's payment channel as permanently broken + /// and persist the transition through the changeset pipeline so + /// it survives restarts and is FFI/UI-visible. Idempotent. + async fn mark_contact_channel_broken(&self, identity_id: &Identifier, contact_id: &Identifier) { + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { + return; + }; + let Some(contact) = managed.established_contacts.get_mut(contact_id) else { + return; + }; + if contact.payment_channel_broken { + return; + } + contact.payment_channel_broken = true; + let snapshot = contact.clone(); + + // Persist the broken flag via an `established` changeset entry + // (the established upsert carries the flag column). + let mut cs = crate::changeset::ContactChangeSet::default(); + cs.established.insert( + crate::changeset::SentContactRequestKey { + owner_id: *identity_id, + recipient_id: *contact_id, + }, + snapshot, + ); + if let Err(e) = self.persister.store(cs.into()) { + tracing::error!("Failed to persist broken-channel changeset: {}", e); + } } } +/// One established contact that needs its DashPay accounts (re)built +/// during a sync sweep. Collected under the write guard, consumed +/// after it is dropped. +struct AccountBuildCandidate { + /// The counterparty identity. + contact_id: Identifier, + /// The counterparty's 96-byte encrypted xpub (from their incoming + /// request to us) to decrypt + register as a `DashpayExternalAccount`. + encrypted_public_key: Vec, + /// Our DECRYPTION key id used for ECDH. + our_decryption_key_index: u32, + /// The counterparty's ENCRYPTION key id used for ECDH. + contact_encryption_key_index: u32, +} + // --------------------------------------------------------------------------- // Accept an incoming contact request // --------------------------------------------------------------------------- @@ -463,19 +2159,22 @@ impl IdentityWallet { /// [`Self::send_contact_request_with_external_signer`] so signing /// crosses the FFI via the supplied `&S: Signer`. /// Same ECDH caveat applies — see that method's docstring. - pub async fn accept_contact_request_with_external_signer( + pub async fn accept_contact_request_with_external_signer( &self, request: &ContactRequest, signer: &S, + crypto: &C, ) -> Result where S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, { let our_identity_id = request.recipient_id; let sender_id = request.sender_id; - // 1. Verify the incoming request is known. - { + // 1. Verify the incoming request is known, and detect whether an + // on-platform reciprocal already exists for this pair. + let already_reciprocated = { let wm = self.wallet_manager.read().await; let info = wm .get_wallet_info(&self.wallet_id) @@ -484,10 +2183,21 @@ impl IdentityWallet { .identity_manager .managed_identity(&our_identity_id) .ok_or(PlatformWalletError::IdentityNotFound(our_identity_id))?; - if !managed.incoming_contact_requests.contains_key(&sender_id) { + // The contact is already established (sync reconciled both + // sides), or our own sent request to this contact already + // exists — in either case the reciprocal is already on + // Platform and re-broadcasting it would be rejected by the + // `(ownerId, toUserId, accountReference)` unique index. + let established = managed.established_contacts.contains_key(&sender_id); + let sent_exists = managed.sent_contact_requests.contains_key(&sender_id); + if !established + && !sent_exists + && !managed.incoming_contact_requests.contains_key(&sender_id) + { return Err(PlatformWalletError::ContactRequestNotFound(sender_id)); } - } + established || sent_exists + }; // 2. Capture the encrypted xpub + key indices BEFORE sending // the reciprocal request (same ordering as the legacy @@ -496,25 +2206,68 @@ impl IdentityWallet { let our_decryption_key_index = request.recipient_key_index; let contact_encryption_key_index = request.sender_key_index; - // 3. Send reciprocal request via the external-signer path. - self.send_contact_request_with_external_signer( - &our_identity_id, - &sender_id, - None, - None, - signer, - ) - .await?; + // 3. Send the reciprocal request — UNLESS one already exists on + // Platform (accept-adopt): re-broadcasting the same + // `(ownerId, toUserId, accountReference)` triple is rejected by + // the unique index forever. When adopting, we still perform the + // fresh-send local registrations below (receiving account + + // validate→decrypt→register external), so the contact becomes + // payable without a duplicate broadcast. + if already_reciprocated { + tracing::info!( + our_identity = %our_identity_id, + contact = %sender_id, + "Accept: reciprocal already on Platform — adopting instead of re-broadcasting" + ); + // Adopt: register the receiving (friendship) account, derived via + // the signer (no resident seed), matching the fresh-send path. + match self + .receiving_xpub_for(&our_identity_id, &sender_id, 0, crypto) + .await + { + Ok(xpub) => { + if let Err(e) = self + .register_contact_account(&our_identity_id, &sender_id, 0, xpub) + .await + { + tracing::warn!( + our_identity = %our_identity_id, + contact = %sender_id, + error = %e, + "Accept-adopt: failed to register receiving account; will retry on next sweep" + ); + } + } + Err(e) => tracing::warn!( + our_identity = %our_identity_id, + contact = %sender_id, + error = %e, + "Accept-adopt: failed to derive receiving xpub via signer; will retry on next sweep" + ), + } + } else { + self.send_contact_request_with_external_signer( + &our_identity_id, + &sender_id, + None, + AutoAcceptProofSource::None, + signer, + crypto, + ) + .await?; + } - // 4. Best-effort external-account registration. Failures are - // logged but do not abort. + // 4. Validate key indices (same gate as the sync sweep and the + // fresh send — applies to ALL three accept paths) BEFORE any + // ECDH, then register the external (sending) account. if let Err(e) = self - .register_external_contact_account( + .accept_register_external_validated( &our_identity_id, &sender_id, &contact_encrypted_xpub, our_decryption_key_index, contact_encryption_key_index, + crypto, ) .await { @@ -523,7 +2276,7 @@ impl IdentityWallet { contact = %sender_id, error = %e, "Failed to register external contact account after accept (external signer) — \ - re-run register_external_contact_account to retry" + re-run sync to retry" ); } @@ -543,6 +2296,128 @@ impl IdentityWallet { .cloned() .ok_or(PlatformWalletError::ContactRequestNotFound(sender_id)) } + + /// Validate the contact request's key indices (purpose + /// ENCRYPTION/DECRYPTION + ECDSA type) BEFORE any ECDH, then register + /// the external sending account. Shared by the accept and accept-adopt + /// paths so the validation gate is applied uniformly (it also runs in + /// the sync sweep). + /// + /// A validation failure is returned as an error so the caller can log + /// it; the channel is not silently registered against an unvalidated + /// index. On the network/decrypt side this simply forwards to + /// [`register_external_contact_account`]. + async fn accept_register_external_validated( + &self, + our_identity_id: &Identifier, + contact_id: &Identifier, + contact_encrypted_xpub: &[u8], + our_decryption_key_index: u32, + contact_encryption_key_index: u32, + crypto: &C, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::Fetch; + + // Fetch counterparty + our identity for validation. + let contact_identity = Identity::fetch(&self.sdk, *contact_id) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch contact identity {contact_id} for validation: {e}" + )) + })? + .ok_or(PlatformWalletError::IdentityNotFound(*contact_id))?; + + let (our_identity, identity_index) = { + let wm = self.wallet_manager.read().await; + let managed = wm + .get_wallet_info(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(our_identity_id)) + .ok_or(PlatformWalletError::IdentityNotFound(*our_identity_id))?; + let index = managed + .identity_index + .ok_or(PlatformWalletError::IdentityIndexNotSet(*our_identity_id))?; + (managed.identity.clone(), index) + }; + + let validation = crate::wallet::identity::crypto::validation::validate_contact_request( + &contact_identity, + contact_encryption_key_index, + &our_identity, + our_decryption_key_index, + ); + if !validation.is_valid { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Contact request failed key-index validation: {:?}", + validation.errors + ))); + } + + // Seedless ECDH: our decryption-key scalar (at the Rust-built path) + // against the contact's encryption pubkey, computed in the signer. The + // resolved shared secret is handed to the register call so its resident + // derivation path is never taken. + let our_dec_path = Self::identity_auth_derivation_path( + self.sdk.network, + key_wallet::bip32::KeyDerivationType::ECDSA, + identity_index, + our_decryption_key_index, + )?; + let contact_pubkey = { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let key = contact_identity + .public_keys() + .get(&contact_encryption_key_index) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Contact identity has no key at index {contact_encryption_key_index}" + )) + })?; + dashcore::secp256k1::PublicKey::from_slice(key.data().as_slice()).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Contact encryption public key is invalid: {e}" + )) + })? + }; + let shared = crypto.ecdh_shared_secret(&our_dec_path, &contact_pubkey).await?; + + // Reuse the identity we just fetched for validation (no second + // network round). The accept path surfaces any failure to the + // caller as a plain error — the transient/permanent split only + // matters to the unattended sync sweep's broken-channel policy. + self.register_external_contact_account( + our_identity_id, + &contact_identity, + contact_encrypted_xpub, + shared, + ) + .await + .map_err(RegisterExternalError::into_inner) + } + + /// Derive our DashPay receiving (friendship) xpub for `(our_identity, + /// contact)` at `account_index` via the signer — the seedless equivalent of + /// deriving it from the wallet. Path is `AccountType::DashpayReceivingFunds` + /// built in Rust; only the public xpub crosses back. + async fn receiving_xpub_for( + &self, + our_identity_id: &Identifier, + contact_id: &Identifier, + account_index: u32, + crypto: &C, + ) -> Result { + let account_type = key_wallet::account::AccountType::DashpayReceivingFunds { + index: account_index, + user_identity_id: our_identity_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }; + let path = account_type.derivation_path(self.sdk.network).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to build DashPay derivation path: {e}" + )) + })?; + crypto.receiving_xpub(&path).await + } } // --------------------------------------------------------------------------- @@ -659,23 +2534,34 @@ impl IdentityWallet { } // --------------------------------------------------------------------------- -// Reject contact request +// Ignore / un-ignore a contact sender (per-sender mute, local-only) // --------------------------------------------------------------------------- impl IdentityWallet { - /// Reject a contact request by hiding the contact. + /// Ignore a contact sender (per-sender mute, = block, reversible). + /// + /// Drops the sender's pending incoming request from local state AND + /// records the sender in `ignored_senders` so the recurring sync ingest + /// path won't resurrect *any* of that sender's still-on-platform + /// immutable `contactRequest` documents — including rotated, bumped- + /// `accountReference` ones. Suppression is per-sender by design: if you + /// ignored the person you ignored them; [`Self::unignore_contact_sender`] + /// is the "changed my mind" affordance. + /// + /// Ignore is **local-only** — there is no on-chain artifact (syncing it + /// would leak who you ignored via the public contact-request indices). + /// The ignore is persisted through the existing + /// changeset → apply → SQLite pipeline so it survives a relaunch. /// - /// This marks the contact as hidden in the local identity manager so that - /// the UI no longer surfaces it. A full DashPay implementation would also - /// create or update a `contactInfo` document on Platform with - /// `display_hidden: true`; that part requires SDK support for document - /// creation on arbitrary contracts which is not yet available here. + /// Unlike the old reject, this does NOT require a pending incoming + /// request to exist: you can ignore a sender whose request the sweep + /// hasn't surfaced yet (the per-sender set still suppresses it). /// /// # Arguments /// /// * `identity_id` - Our identity. - /// * `contact_identity_id` - The identity whose request we reject. - pub async fn reject_contact_request( + /// * `contact_identity_id` - The sender to ignore. + pub async fn ignore_contact_sender( &self, identity_id: &Identifier, contact_identity_id: &Identifier, @@ -689,27 +2575,872 @@ impl IdentityWallet { .managed_identity_mut(identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - // Remove from incoming requests (if present). - if managed - .incoming_contact_requests - .remove(contact_identity_id) - .is_none() - { - return Err(PlatformWalletError::ContactRequestNotFound( - *contact_identity_id, - )); - } + // Record the ignore (drops the pending incoming entry if present, + // adds the sender to `ignored_senders`) and persist it. + // + // PROPAGATE the store error rather than swallow it. Ignore is + // local-only (there's no on-chain artifact), so if it doesn't reach + // disk the still-immutable on-chain requests re-ingest on the next + // launch and the ignored sender RESURFACES — with no signal. + // Returning the error surfaces the failure to the UI so the user + // retries, instead of a silent success that didn't take. + let cs = managed.ignore_sender(contact_identity_id); + self.persister + .store(cs.into()) + .map_err(|e| PlatformWalletError::Persistence(format!("ignore not persisted: {e}")))?; + + tracing::info!( + identity = %identity_id, + ignored_sender = %contact_identity_id, + "Contact sender ignored (local-only; suppressed from the main pending list, won't resurrect on sync)" + ); + + Ok(()) + } + + /// Un-ignore a contact sender (reverse [`Self::ignore_contact_sender`]). + /// + /// Removes the sender from `ignored_senders`, **rewinds the received + /// high-water cursor to `None`** (so the next sweep re-fetches the + /// sender's on-chain requests — otherwise the cursor has already passed + /// them and they'd never reappear), and persists the un-ignore through + /// the changeset pipeline. + /// + /// A no-op (returns `Ok(())`) when the sender wasn't ignored. + /// + /// # Arguments + /// + /// * `identity_id` - Our identity. + /// * `contact_identity_id` - The sender to un-ignore. + pub async fn unignore_contact_sender( + &self, + identity_id: &Identifier, + contact_identity_id: &Identifier, + ) -> Result<(), PlatformWalletError> { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity_mut(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - // TODO: When the SDK supports creating/updating arbitrary DashPay - // documents (contactInfo), submit a `display_hidden: true` document to - // Platform here so the rejection is persisted across devices. + // `unignore_sender` removes the sender + rewinds the cursor and + // returns the removal changeset (empty if the sender wasn't + // ignored). Persist it so the ignored-sender row is deleted. + let cs = managed.unignore_sender(contact_identity_id); + if ::is_empty(&cs) { + // Not ignored — nothing to persist, but not an error. + return Ok(()); + } + self.persister.store(cs.into()).map_err(|e| { + PlatformWalletError::Persistence(format!("un-ignore not persisted: {e}")) + })?; tracing::info!( identity = %identity_id, - rejected_contact = %contact_identity_id, - "Contact request rejected (hidden locally)" + unignored_sender = %contact_identity_id, + "Contact sender un-ignored (cursor rewound; requests will re-fetch on next sweep)" ); Ok(()) } } + +// --------------------------------------------------------------------------- +// Network-layer tests for the sync sweep decision logic. +// +// These exercise the *orchestration* helpers that don't require a live +// network or real ECDH keys: account-build candidate collection +// and the rejected-tombstone / broken-flag persistence round-trip. The +// pure state-machine behaviors (guard relaxation, sent-side dedup, +// metadata-preserving re-establish, tombstone-by-accountReference) are +// pinned in `state/managed_identity/contact_requests.rs`. +// --------------------------------------------------------------------------- +#[cfg(test)] +mod cursor_tests { + use super::{advance_high_water, query_lower_bound, SYNC_OVERLAP_MS}; + + /// No cursor ⇒ full fetch (no lower bound). + #[test] + fn lower_bound_none_is_full_fetch() { + assert_eq!(query_lower_bound(None), None); + } + + /// The query bound is the high-water minus the (mandatory) overlap window, + /// saturating at 0 — the overlap is what re-includes equal-`$createdAt` + /// docs at a page boundary, so it must always be subtracted. + #[test] + fn lower_bound_subtracts_overlap() { + assert_eq!( + query_lower_bound(Some(20 * 60_000)), + Some(20 * 60_000 - SYNC_OVERLAP_MS) + ); + // Saturates rather than underflowing for a high-water below the window. + assert_eq!(query_lower_bound(Some(5 * 60_000)), Some(0)); + const { assert!(SYNC_OVERLAP_MS > 0, "overlap must be > 0 for correctness") }; + } + + /// Advancing never moves the cursor backward (guards out-of-order / + /// stale-max sweeps and restore over-shoot), and a zero-doc sweep leaves + /// it unchanged. + #[test] + fn advance_never_goes_backward_and_zero_doc_is_noop() { + // First sweep from empty: adopt the max fetched. + assert_eq!(advance_high_water(None, Some(100)), Some(100)); + // Forward progress. + assert_eq!(advance_high_water(Some(100), Some(200)), Some(200)); + // A lower max (re-fetch within the overlap, or out-of-order) must NOT + // pull the cursor backward. + assert_eq!(advance_high_water(Some(200), Some(50)), Some(200)); + // A zero-doc sweep leaves the cursor exactly where it was. + assert_eq!(advance_high_water(Some(200), None), Some(200)); + assert_eq!(advance_high_water(None, None), None); + + // `0` is a real cursor value distinct from `None` (a doc at + // `$createdAt == 0`, or a freshly-restored 0 cursor) — pin that a + // future "treat 0 as unset" refactor would regress. + assert_eq!(advance_high_water(None, Some(0)), Some(0)); + assert_eq!(advance_high_water(Some(0), None), Some(0)); + assert_eq!(query_lower_bound(Some(0)), Some(0)); + } + + /// Compare-and-advance: a concurrent `unignore_sender` reset (cursor no + /// longer equals the snapshot) must NOT be clobbered by this sweep's stale + /// max — otherwise the un-ignored sender stays invisible until a restart. + #[test] + fn advance_if_unchanged_respects_a_concurrent_reset() { + use super::advance_if_unchanged; + // Unchanged since snapshot → normal advance. + assert_eq!( + advance_if_unchanged(Some(100), Some(100), Some(200)), + Some(200) + ); + assert_eq!(advance_if_unchanged(Some(100), Some(100), None), Some(100)); + // THE RACE: snapshot was Some(100); un-ignore reset it to None + // mid-sweep; this sweep's max is Some(200) (stale — excluded the sender) + // → keep the None so the next sweep does a full re-fetch. + assert_eq!(advance_if_unchanged(None, Some(100), Some(200)), None); + // Any other concurrent change is likewise respected, not clobbered. + assert_eq!( + advance_if_unchanged(Some(50), Some(100), Some(200)), + Some(50) + ); + } +} + +#[cfg(test)] +mod sweep_tests { + use super::*; + use crate::broadcaster::SpvBroadcaster; + use crate::changeset::{ContactChangeSet, PlatformWalletChangeSet, SentContactRequestKey}; + use crate::wallet::core::WalletBalance; + use crate::wallet::identity::IdentityManager; + use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; + use crate::wallet::platform_wallet::PlatformWalletInfo; + use dpp::identity::v0::IdentityV0; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::wallet::Wallet; + use key_wallet::Network; + use std::collections::BTreeMap; + use std::sync::Arc; + + fn noop_persister() -> WalletPersister { + WalletPersister::new([0u8; 32], Arc::new(NoPlatformPersistence)) + } + + fn build_test_wallet() -> Wallet { + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::None) + .expect("test wallet") + } + + fn empty_info(wallet: &Wallet) -> PlatformWalletInfo { + PlatformWalletInfo { + core_wallet: ManagedWalletInfo::from_wallet(wallet, 0), + balance: Arc::new(WalletBalance::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + pending_contact_crypto: Vec::new(), + } + } + + fn test_identity(id_byte: u8) -> Identity { + Identity::V0(IdentityV0 { + id: Identifier::from([id_byte; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }) + } + + fn test_request(sender: u8, recipient: u8, account_reference: u32) -> ContactRequest { + ContactRequest::new( + Identifier::from([sender; 32]), + Identifier::from([recipient; 32]), + 1, + 2, + account_reference, + vec![7u8; 96], + 100_000, + 0, + ) + } + + /// Seed a wallet-owned identity that has an established contact (no + /// external account yet) into a fresh `PlatformWalletInfo`. + fn info_with_established_contact(our: u8, contact: u8) -> (Wallet, PlatformWalletInfo) { + let wallet = build_test_wallet(); + let mut info = empty_info(&wallet); + let our_id = Identifier::from([our; 32]); + let p = noop_persister(); + info.identity_manager + .add_identity(test_identity(our), 0, [0u8; 32], &p) + .expect("add identity"); + let managed = info + .identity_manager + .managed_identity_mut(&our_id) + .expect("managed identity"); + // Establish a contact via the state machine. + managed.add_incoming_contact_request(test_request(contact, our, 0), &p); + managed.add_sent_contact_request(test_request(our, contact, 0), &p); + assert_eq!(managed.established_contacts.len(), 1); + (wallet, info) + } + + /// **Test 3 (restore-from-seed shape):** an established contact with + /// zero DashPay accounts must surface as an account-build candidate so + /// the sweep rebuilds BOTH the receiving and external accounts. Before + /// the account-building sweep only the fresh-send path created them, so + /// restore-from-seed left the contact unpayable and incoming payments + /// invisible. + #[test] + fn established_contact_missing_external_account_is_a_build_candidate() { + let our = 1u8; + let contact = 2u8; + let our_id = Identifier::from([our; 32]); + let (_wallet, info) = info_with_established_contact(our, contact); + + let candidates = + IdentityWallet::::collect_account_build_candidates(&info, &our_id); + + assert_eq!( + candidates.len(), + 1, + "an established contact with no external account must be a build candidate" + ); + let c = &candidates[0]; + assert_eq!(c.contact_id, Identifier::from([contact; 32])); + // The candidate carries the counterparty's encrypted xpub + the + // ECDH key indices taken from the INCOMING request. + assert_eq!(c.encrypted_public_key, vec![7u8; 96]); + // incoming request: sender=contact key_index 1, recipient(us) key_index 2 + assert_eq!(c.contact_encryption_key_index, 1); + assert_eq!(c.our_decryption_key_index, 2); + } + + /// **Test 4 (permanent failure → no retry):** once a contact's payment + /// channel is marked broken, the sweep must NOT re-list it as a + /// candidate — no unbounded retry until a superseding request clears + /// the flag. + #[test] + fn broken_payment_channel_is_skipped_by_the_sweep() { + let our = 1u8; + let contact = 2u8; + let our_id = Identifier::from([our; 32]); + let contact_id = Identifier::from([contact; 32]); + let (_wallet, mut info) = info_with_established_contact(our, contact); + + // Mark the channel broken (as the permanent-failure path would). + info.identity_manager + .managed_identity_mut(&our_id) + .unwrap() + .established_contacts + .get_mut(&contact_id) + .unwrap() + .payment_channel_broken = true; + + let candidates = + IdentityWallet::::collect_account_build_candidates(&info, &our_id); + assert!( + candidates.is_empty(), + "a permanently-broken contact must not be retried by the sweep" + ); + } + + /// **Test 4 (persistence):** the broken-channel flag round-trips through + /// the changeset → apply pipeline so it survives a restart and is + /// FFI/UI-visible — and a transient (cleared) flag round-trips too. + #[test] + fn broken_channel_flag_round_trips_through_apply() { + let our = 1u8; + let contact = 2u8; + let our_id = Identifier::from([our; 32]); + let contact_id = Identifier::from([contact; 32]); + let (mut wallet, mut info) = info_with_established_contact(our, contact); + + // Build an `established` changeset carrying the broken flag. + let mut contact_obj = info + .identity_manager + .managed_identity(&our_id) + .unwrap() + .established_contacts + .get(&contact_id) + .unwrap() + .clone(); + contact_obj.payment_channel_broken = true; + let mut cs = ContactChangeSet::default(); + cs.established.insert( + SentContactRequestKey { + owner_id: our_id, + recipient_id: contact_id, + }, + contact_obj, + ); + let pcs = PlatformWalletChangeSet { + contacts: Some(cs), + ..Default::default() + }; + + info.apply_changeset(&mut wallet, pcs).expect("apply"); + + assert!( + info.identity_manager + .managed_identity(&our_id) + .unwrap() + .established_contacts + .get(&contact_id) + .unwrap() + .payment_channel_broken, + "broken flag must survive the changeset apply round-trip" + ); + } + + /// **Ignore persistence:** an ignored sender round-trips through the + /// changeset → apply pipeline so a recurring re-sync after a restart + /// still suppresses them — including a rotated (bumped-`accountReference`) + /// request from the same sender (per-sender suppression). + #[test] + fn ignored_sender_round_trips_through_changeset_apply() { + let our = 1u8; + let sender = 9u8; + let our_id = Identifier::from([our; 32]); + let sender_id = Identifier::from([sender; 32]); + let wallet = build_test_wallet(); + let mut info = empty_info(&wallet); + let p = noop_persister(); + info.identity_manager + .add_identity(test_identity(our), 0, [0u8; 32], &p) + .expect("add identity"); + + // Ignore the sender and capture the resulting changeset. + let managed = info.identity_manager.managed_identity_mut(&our_id).unwrap(); + managed.add_incoming_contact_request(test_request(sender, our, 0), &p); + let cs = managed.ignore_sender(&sender_id); + let pcs = PlatformWalletChangeSet { + contacts: Some(cs), + ..Default::default() + }; + + // Wipe the in-memory ignore set, then re-apply the changeset (the + // restore-from-persistence path). + info.identity_manager + .managed_identity_mut(&our_id) + .unwrap() + .ignored_senders + .clear(); + let mut wallet = wallet; + info.apply_changeset(&mut wallet, pcs).expect("apply"); + + let managed = info.identity_manager.managed_identity(&our_id).unwrap(); + assert!( + managed.is_sender_ignored(&sender_id), + "ignored sender must be restored from the changeset" + ); + } + + /// **Ignore suppresses original AND rotated (full sweep):** an ignored + /// sender's ORIGINAL request and a later ROTATED (bumped-`accountReference`) + /// request are BOTH suppressed by `sync_contact_requests`' per-sender + /// ingest guard — neither reaches `incoming_contact_requests`. This is + /// the key per-sender semantic difference from the old per-(sender,ref) + /// reject (which would have let the rotation through). + /// + /// Drives the ingest decision logic directly against the state machine + /// (the full network fetch is exercised by the mock-SDK integration + /// tests): collapse-newest → is_sender_ignored → skip. + #[test] + fn ignored_sender_suppresses_both_original_and_rotated_requests() { + let our = 1u8; + let sender = 9u8; + let our_id = Identifier::from([our; 32]); + let sender_id = Identifier::from([sender; 32]); + let wallet = build_test_wallet(); + let mut info = empty_info(&wallet); + let p = noop_persister(); + info.identity_manager + .add_identity(test_identity(our), 0, [0u8; 32], &p) + .expect("add identity"); + let managed = info.identity_manager.managed_identity_mut(&our_id).unwrap(); + + // Ignore the sender first. + managed.ignore_sender(&sender_id); + assert!(managed.is_sender_ignored(&sender_id)); + + // Simulate the sweep seeing BOTH the original (ref=0) and a rotated + // (ref=7) on-chain doc for this sender. The collapse keeps the + // newest; the ignore check then suppresses it regardless of ref. + let original = test_request_at(sender, our, 0, 100); + let rotated = test_request_at(sender, our, 7, 200); + let collapsed = newest_received_per_sender([original, rotated]); + let newest = collapsed.get(&sender_id).expect("collapsed entry"); + + // The per-sender ignore suppresses the rotated (newest) doc. + assert_eq!( + newest.account_reference, 7, + "collapse keeps the newest (rotated) doc" + ); + assert!( + managed.is_sender_ignored(&sender_id), + "an ignored sender suppresses ALL their requests, including the rotation" + ); + + // And the original ref (0) is suppressed too — per-sender, not + // per-(sender, accountReference). + assert!(managed.is_sender_ignored(&sender_id)); + } + + /// Build a received request with an explicit `created_at` so the + /// dedup tiebreak can be exercised. + fn test_request_at( + sender: u8, + recipient: u8, + account_reference: u32, + created_at: u64, + ) -> ContactRequest { + ContactRequest::new( + Identifier::from([sender; 32]), + Identifier::from([recipient; 32]), + 1, + 2, + account_reference, + vec![7u8; 96], + 100_000, + created_at, + ) + } + + /// **Sweep idempotency (the multi-doc thrash fix).** + /// `contactRequest` docs are immutable and never deleted, so a sender + /// who rotated leaves BOTH their old (ref=0) and bumped (ref=7) docs + /// returning on every sweep. `newest_received_per_sender` must collapse + /// them to the single newest by (created_at, accountReference) so the + /// stale doc can't be re-ingested as a phantom rotation each pass. + /// + /// Without the collapse, the ingest loop processes every doc and compares + /// each against the single tracked reference, so the non-matching doc + /// flips the stored state every sweep; with it, only the newest survives. + #[test] + fn newest_received_per_sender_collapses_rotated_sender_to_latest_doc() { + let sender = 2u8; + let our = 1u8; + // Same sender, two on-chain docs: old ref=0 @t=100, rotated ref=7 @t=200. + let old_doc = test_request_at(sender, our, 0, 100); + let rotated_doc = test_request_at(sender, our, 7, 200); + // A second, unrelated sender to prove per-sender keying. + let other = test_request_at(3, our, 0, 150); + + // Feed in doc-id order (old before new — the order a BTreeMap-keyed + // fetch yields, NOT createdAt order) to prove ordering independence. + let collapsed = + newest_received_per_sender([old_doc.clone(), other.clone(), rotated_doc.clone()]); + + assert_eq!(collapsed.len(), 2, "one entry per distinct sender"); + let sender_id = Identifier::from([sender; 32]); + assert_eq!( + collapsed.get(&sender_id).map(|r| r.account_reference), + Some(7), + "the newest (rotated) doc must win, regardless of input order" + ); + assert_eq!( + collapsed + .get(&Identifier::from([3u8; 32])) + .map(|r| r.account_reference), + Some(0), + "the unrelated sender is unaffected" + ); + + // And the collapse is itself a fixpoint: re-collapsing yields the same. + let again = newest_received_per_sender(collapsed.values().cloned()); + assert_eq!(again.get(&sender_id).map(|r| r.account_reference), Some(7)); + } + + /// **Rotation version bump must read established contacts.** + /// The next request's rotation version is derived by un-masking the + /// PRIOR sent reference. Once a contact establishes, that prior request + /// moves out of `sent_contact_requests` into + /// `established_contacts[..].outgoing_request`, so a lookup that only + /// consults the pending map returns `None` → version resets to 0 → + /// reproduces the original accountReference → unique-index rejection. + /// + /// The hazard: if `prior_sent_account_reference` consulted only + /// `sent_contact_requests` it would return `None` for an established + /// contact; it must fall back to the established outgoing request. + #[test] + fn prior_sent_account_reference_falls_back_to_established_outgoing() { + let our = 1u8; + let contact = 2u8; + let our_id = Identifier::from([our; 32]); + let contact_id = Identifier::from([contact; 32]); + let (_wallet, mut info) = info_with_established_contact(our, contact); + + let managed = info.identity_manager.managed_identity_mut(&our_id).unwrap(); + // Precondition: the outgoing request is NOT in the pending map. + assert!( + !managed.sent_contact_requests.contains_key(&contact_id), + "an established contact's outgoing request lives in established_contacts, not the pending map" + ); + // The fix: the lookup still finds the prior reference via the + // established contact's outgoing_request (reference 0 here). + assert_eq!( + managed.prior_sent_account_reference(&contact_id), + Some(0), + "must read the established contact's outgoing accountReference, not None" + ); + + // And a pending (not-yet-established) recipient still resolves via + // the pending map; an unknown recipient is None. + let pending = Identifier::from([9u8; 32]); + managed.add_sent_contact_request(test_request(our, 9, 4), &noop_persister()); + assert_eq!(managed.prior_sent_account_reference(&pending), Some(4)); + assert_eq!( + managed.prior_sent_account_reference(&Identifier::from([42u8; 32])), + None + ); + } + + /// **Defense-in-depth — `apply_rotated_incoming_request` is + /// idempotent.** Even if the dedup ever let a duplicate through, a + /// re-apply of the byte-identical request must be a no-op: no second + /// changeset, no re-reported re-key (which would re-tear-down the + /// external account). + #[test] + fn apply_rotated_incoming_request_is_idempotent() { + let our = 1u8; + let contact = 2u8; + let our_id = Identifier::from([our; 32]); + let (_wallet, mut info) = info_with_established_contact(our, contact); + let p = noop_persister(); + + let managed = info.identity_manager.managed_identity_mut(&our_id).unwrap(); + let rotated = test_request(contact, our, 7); + + // First apply: real re-key (returns true — caller tears down the account). + assert!( + managed.apply_rotated_incoming_request(rotated.clone(), &p), + "first rotation must re-key the established contact" + ); + // Second apply of the SAME request: no-op (returns false). + assert!( + !managed.apply_rotated_incoming_request(rotated.clone(), &p), + "re-applying an identical request must be a no-op (no re-key, no churn)" + ); + let stored = info + .identity_manager + .managed_identity(&our_id) + .unwrap() + .established_contacts + .get(&Identifier::from([contact; 32])) + .unwrap(); + assert_eq!(stored.incoming_request.account_reference, 7); + } +} + +// --------------------------------------------------------------------------- +// Send-side recipient key selection. +// +// Verified testnet reality: the dominant mobile cohort has +// an ENCRYPTION key but NO DECRYPTION key, and references its ENCRYPTION key +// for recipientKeyIndex. Sending to such a recipient must succeed by falling +// back to the ENCRYPTION key — without that fallback the send errors with +// "no decryption key" for the dominant mobile cohort. +// --------------------------------------------------------------------------- +#[cfg(test)] +mod recipient_key_selection_tests { + use super::*; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::SecurityLevel; + use std::collections::BTreeMap; + + fn key(id: u32, key_type: KeyType, purpose: Purpose) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + key_type, + purpose, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![0x02; 33]), + disabled_at: None, + }) + } + + fn identity_with_keys(keys: Vec) -> Identity { + let mut map = BTreeMap::new(); + for k in keys { + map.insert(k.id(), k); + } + Identity::V0(IdentityV0 { + id: Identifier::from([0xBB; 32]), + public_keys: map, + balance: 0, + revision: 0, + }) + } + + /// Mobile-shaped recipient: AUTHENTICATION + ENCRYPTION keys, NO + /// DECRYPTION key. Selection must fall back to the ENCRYPTION key (id 2) + /// rather than erroring "no decryption key". + #[test] + fn falls_back_to_encryption_key_when_recipient_has_no_decryption_key() { + let recipient = identity_with_keys(vec![ + key(0, KeyType::ECDSA_SECP256K1, Purpose::AUTHENTICATION), + key(1, KeyType::ECDSA_SECP256K1, Purpose::AUTHENTICATION), + key(2, KeyType::ECDSA_SECP256K1, Purpose::ENCRYPTION), + ]); + + let idx = select_recipient_key_index(&recipient) + .expect("must select the ENCRYPTION key for a mobile-shaped recipient"); + assert_eq!(idx, 2, "should reference the recipient's ENCRYPTION key"); + } + + /// Newest cohort / our convention: a DECRYPTION key is present and + /// preferred over any ENCRYPTION key. + #[test] + fn prefers_decryption_key_when_present() { + let recipient = identity_with_keys(vec![ + key(4, KeyType::ECDSA_SECP256K1, Purpose::ENCRYPTION), + key(5, KeyType::ECDSA_SECP256K1, Purpose::DECRYPTION), + ]); + + let idx = select_recipient_key_index(&recipient).expect("decryption key present"); + assert_eq!(idx, 5, "must prefer DECRYPTION over ENCRYPTION"); + } + + /// Neither DECRYPTION nor ENCRYPTION (only AUTHENTICATION): error. No + /// AUTHENTICATION fallback — reusing signing keys for ECDH is poor key + /// separation and no live population needs it. + #[test] + fn errors_when_recipient_has_neither_encryption_nor_decryption() { + let recipient = identity_with_keys(vec![key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::AUTHENTICATION, + )]); + + let err = select_recipient_key_index(&recipient).unwrap_err(); + assert!( + matches!(err, PlatformWalletError::InvalidIdentityData(_)), + "expected InvalidIdentityData, got {err:?}" + ); + } + + /// A DECRYPTION key of the wrong key TYPE is not selectable; selection + /// falls through to a valid ECDSA ENCRYPTION key. + #[test] + fn skips_non_ecdsa_decryption_key_and_uses_ecdsa_encryption() { + let recipient = identity_with_keys(vec![ + key(0, KeyType::BLS12_381, Purpose::DECRYPTION), + key(1, KeyType::ECDSA_SECP256K1, Purpose::ENCRYPTION), + ]); + + let idx = select_recipient_key_index(&recipient) + .expect("ECDSA encryption key must be selectable"); + assert_eq!(idx, 1); + } + + fn disabled_key(id: u32, key_type: KeyType, purpose: Purpose) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + key_type, + purpose, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![0x02; 33]), + disabled_at: Some(1_700_000_000_000), + }) + } + + /// **#6 — a disabled (revoked) recipient key must not be selected.** The + /// chosen key receives the contact's DIP-15 compact xpub encrypted via + /// ECDH; picking a revoked key would hand that payment xpub to whoever + /// holds the compromised private half. A disabled DECRYPTION key must be + /// skipped in favour of an enabled ENCRYPTION key. + #[test] + fn skips_disabled_decryption_key_and_falls_back_to_enabled_encryption() { + let recipient = identity_with_keys(vec![ + disabled_key(0, KeyType::ECDSA_SECP256K1, Purpose::DECRYPTION), + key(1, KeyType::ECDSA_SECP256K1, Purpose::ENCRYPTION), + ]); + + let idx = select_recipient_key_index(&recipient) + .expect("must skip the disabled DECRYPTION key and use the enabled ENCRYPTION key"); + assert_eq!(idx, 1, "the disabled key (id 0) must not be selected"); + } + + /// When the ONLY candidate is disabled, selection errors rather than + /// silently encrypting to a revoked key. + #[test] + fn errors_when_only_candidate_key_is_disabled() { + let recipient = identity_with_keys(vec![disabled_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::ENCRYPTION, + )]); + + let err = select_recipient_key_index(&recipient).unwrap_err(); + assert!( + matches!(err, PlatformWalletError::InvalidIdentityData(_)), + "a sole disabled key must error, got {err:?}" + ); + } +} + +#[cfg(test)] +mod contact_info_provider_tests { + use super::*; + use crate::wallet::identity::crypto::contact_info::derive_contact_info_keys; + use crate::wallet::identity::network::identity_auth_derivation_path_for_type; + use key_wallet::bip32::KeyDerivationType; + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::Network; + + // Canonical BIP-39 test mnemonic. + const PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + /// MUST-FIX (security review): the contactInfo seal/open the signer produces + /// must be byte-identical to the resident `derive_contact_info_keys` AT THE + /// REAL identity-auth root path — not an arbitrary path. contactInfo is + /// self-encrypted (no counterparty round-trip), so a wrong root silently + /// writes data no client can decrypt, with no on-chain oracle. This pins the + /// provider's seal to the production derivation at the real path and confirms + /// open round-trips. + #[tokio::test] + async fn contact_info_seal_open_matches_resident_derivation_at_real_auth_path() { + let seed = Mnemonic::from_phrase(PHRASE, Language::English) + .expect("valid mnemonic") + .to_seed(""); + let network = Network::Testnet; + let identity_index = 0u32; + let root_key_id = 2u32; + let derivation_index = 0u32; + let contact_id = [0x33u8; 32]; + let plaintext = b"hello private data".to_vec(); + let iv = [0x11u8; 16]; + + // The REAL identity-auth root the production publish path builds. + let root_path = identity_auth_derivation_path_for_type( + network, + KeyDerivationType::ECDSA, + identity_index, + root_key_id, + ) + .expect("auth path"); + + let provider = SeedCryptoProvider::from_seed(seed, network); + let sealed = provider + .contact_info_seal(&root_path, derivation_index, &contact_id, &plaintext, &iv) + .await + .expect("seal"); + + // Resident twin at the same real path → byte-identical ciphertext. + let wallet = key_wallet::wallet::Wallet::from_seed_bytes( + seed, + network, + key_wallet::wallet::initialization::WalletAccountCreationOptions::None, + ) + .expect("wallet"); + let keys = + derive_contact_info_keys(&wallet, network, identity_index, root_key_id, derivation_index) + .expect("resident keys"); + let enc_k: [u8; 32] = *keys.enc_to_user_id_key; + let priv_k: [u8; 32] = *keys.private_data_key; + let expected_enc = platform_encryption::encrypt_enc_to_user_id(&enc_k, &contact_id); + let expected_priv = platform_encryption::encrypt_private_data(&priv_k, &iv, &plaintext); + assert_eq!( + sealed.enc_to_user_id, expected_enc, + "encToUserId must equal the resident derivation at the REAL auth path" + ); + assert_eq!( + sealed.private_data, expected_priv, + "privateData must equal the resident derivation at the REAL auth path" + ); + + // open round-trips the inputs. + let opened = provider + .contact_info_open( + &root_path, + derivation_index, + &sealed.enc_to_user_id, + &sealed.private_data, + ) + .await + .expect("open"); + assert_eq!(opened.contact_id, contact_id, "open recovers the contact id"); + assert_eq!(opened.private_data, plaintext, "open recovers the private data"); + } + + /// The "needs unlock" count must track only the account-build ops + /// (`RegisterReceiving` / `RegisterExternal`) and exclude + /// `ContactInfoDecrypt`. `ContactInfoDecrypt` is re-enqueued on every + /// signerless sweep, so counting it would make the count a permanent + /// `> 0` and re-trip the UI banner ~15s after every unlock on a healthy + /// wallet. This fails against a naive `pending_contact_crypto.len()`. + #[test] + fn account_build_count_excludes_contact_info_decrypt() { + use crate::changeset::{PendingContactCrypto, PendingContactCryptoOp}; + + let owner = Identifier::from([1u8; 32]); + let mk = |contact: u8, op| PendingContactCrypto { + owner_identity_id: owner, + contact_id: Identifier::from([contact; 32]), + op, + enqueued_at_ms: 0, + }; + + let queue = vec![ + mk(2, PendingContactCryptoOp::RegisterReceiving), + mk( + 2, + PendingContactCryptoOp::RegisterExternal { + encrypted_public_key: vec![0u8; 96], + our_decryption_key_index: 0, + contact_encryption_key_index: 0, + }, + ), + mk(3, PendingContactCryptoOp::ContactInfoDecrypt), + mk(5, PendingContactCryptoOp::AutoAccept), + ]; + // Two account-build ops + one auto-accept = 3 "waiting to finish setup"; + // the ContactInfoDecrypt is not counted. + assert_eq!(count_account_build_ops(&queue), 3); + + // A queue of only ContactInfoDecrypt is zero actionable backlog. + assert_eq!( + count_account_build_ops(&[mk(4, PendingContactCryptoOp::ContactInfoDecrypt)]), + 0 + ); + // AutoAccept alone counts (a contact waiting to be auto-accepted). + assert_eq!( + count_account_build_ops(&[mk(6, PendingContactCryptoOp::AutoAccept)]), + 1 + ); + + // Empty queue is zero. + assert_eq!(count_account_build_ops(&[]), 0); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs index 8a3479043bf..57cfa971882 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -1,7 +1,6 @@ //! Established contacts + DIP-14/15 contact key derivation + external account registration. use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::Identity; use dpp::prelude::Identifier; use key_wallet::account::AccountType; @@ -9,11 +8,89 @@ use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use super::*; use crate::broadcaster::TransactionBroadcaster; +use crate::changeset::{ + AccountAddressPoolEntry, AccountRegistrationEntry, PlatformWalletChangeSet, +}; use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; use crate::wallet::identity::types::dashpay::payment::DashpayAddressMatch; use crate::wallet::platform_wallet::PlatformWalletInfo; +/// Build the persistence round for a newly registered DashPay account +/// (`DashpayReceivingFunds` / `DashpayExternalAccount`): the +/// [`AccountRegistrationEntry`] plus the account's initial address-pool +/// snapshot — the same shape `wallet_lifecycle` emits at wallet +/// creation. Without this round the account exists only in memory and +/// vanishes on relaunch, so its persisted UTXOs are dropped at the next +/// load (`load: ... dropped_no_account`) and received-payment history +/// can't be rebuilt. +fn dashpay_account_registration_changeset( + account_type: AccountType, + account_xpub: key_wallet::bip32::ExtendedPubKey, + managed: &key_wallet::managed_account::ManagedCoreFundsAccount, +) -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet { + account_registrations: vec![AccountRegistrationEntry { + account_type, + account_xpub, + }], + ..Default::default() + }; + for pool in managed.managed_account_type().address_pools() { + let addresses: Vec = pool.addresses.values().cloned().collect(); + if addresses.is_empty() { + continue; + } + cs.account_address_pools.push(AccountAddressPoolEntry { + account_type, + pool_type: pool.pool_type, + addresses, + }); + } + cs +} + +/// Why a [`register_external_contact_account`] attempt failed, classified +/// for the payment-channel policy. +/// +/// The distinction is load-bearing: +/// - **Permanent** marks the contact's payment channel broken (no unbounded +/// retry on a poisoned channel). +/// - **Transient** leaves the channel intact so the next sync sweep retries. +/// +/// (In the seedless model this method no longer derives the ECDH scalar — the +/// caller passes a signer-derived `shared_key` — so a "key material +/// unavailable" classification no longer arises here; that DEFER decision now +/// lives at the drain's provider call.) +/// +/// [`register_external_contact_account`]: IdentityWallet::register_external_contact_account +#[derive(Debug)] +pub enum RegisterExternalError { + /// The request itself is unusable and re-deriving won't help — a + /// malformed encrypted xpub, a missing/non-secp recipient key. Mark the + /// channel broken. + Permanent(PlatformWalletError), + /// A local persistence / in-memory-insert hiccup — the account simply + /// wasn't built this pass. Leave the channel intact; the next sweep + /// retries. + Transient(PlatformWalletError), +} + +impl RegisterExternalError { + /// Whether this failure should permanently break the payment channel. + pub fn is_permanent(&self) -> bool { + matches!(self, RegisterExternalError::Permanent(_)) + } + + /// Unwrap to the underlying error (all arms carry one) for callers + /// that don't act on the classification. + pub fn into_inner(self) -> PlatformWalletError { + match self { + RegisterExternalError::Permanent(e) | RegisterExternalError::Transient(e) => e, + } + } +} + // --------------------------------------------------------------------------- // Established contacts accessor // --------------------------------------------------------------------------- @@ -53,43 +130,6 @@ impl IdentityWallet { // --------------------------------------------------------------------------- impl IdentityWallet { - /// Get the contact xpub data for a specific contact relationship. - /// - /// Derives the extended public key along path: - /// `m/9'/coin'/15'/account'/(sender_id)/(recipient_id)` - /// - /// The last two segments use DIP-14 256-bit non-hardened derivation. - /// - /// # Arguments - /// - /// * `account_index` - Account index (hardened) in the derivation path. - /// * `sender_id` - Our identity identifier. - /// * `recipient_id` - The contact's identity identifier. - pub async fn contact_xpub( - &self, - account_index: u32, - sender_id: &Identifier, - recipient_id: &Identifier, - ) -> Result { - let wm = self.wallet_manager.read().await; - let wallet = wm - .get_wallet(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - crate::wallet::identity::crypto::dip14::derive_contact_xpub( - wallet, - self.sdk.network, - account_index, - sender_id, - recipient_id, - ) - } - - // TODO: Isn't this something what should be done internally? - /// Derive payment addresses for a contact (for receiving payments from them). - /// - /// Returns `count` addresses starting from `start_index`, derived via - /// standard BIP32 from the contact xpub. - /// /// Register a DashPay contact account in the wallet's `ManagedWalletInfo`. /// /// Creates a `DashpayReceivingFunds` managed account with address pools @@ -102,6 +142,9 @@ impl IdentityWallet { our_identity_id: &Identifier, contact_identity_id: &Identifier, account_index: u32, + // Our receiving (friendship) xpub, derived by the Keychain signer. There + // is no resident-seed path — every caller supplies it. + account_xpub: key_wallet::bip32::ExtendedPubKey, ) -> Result<(), PlatformWalletError> { let account_type = AccountType::DashpayReceivingFunds { index: account_index, @@ -111,21 +154,33 @@ impl IdentityWallet { // Derive the account xpub and add to both Wallet and ManagedWalletInfo let mut wm = self.wallet_manager.write().await; + + // Early-exit if the account already exists — keeps the recurring + // sweep's re-registration a true no-op (no duplicate persistence + // round, no managed-state churn). + { + use key_wallet::account::account_collection::DashpayAccountKey; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let key = DashpayAccountKey { + index: account_index, + user_identity_id: our_identity_id.to_buffer(), + friend_identity_id: contact_identity_id.to_buffer(), + }; + if info + .core_wallet + .accounts + .dashpay_receival_accounts + .contains_key(&key) + { + return Ok(()); + } + } + let wallet = wm .get_wallet(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let path = account_type - .derivation_path(self.sdk.network) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive DashPay contact account path: {err}" - )) - })?; - let account_xpub = wallet.derive_extended_public_key(&path).map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive DashPay contact xpub: {err}" - )) - })?; let account = key_wallet::Account { parent_wallet_id: Some(wallet.wallet_id), @@ -135,14 +190,42 @@ impl IdentityWallet { is_watch_only: false, }; - // Add managed wrapper to ManagedWalletInfo (address pools, state tracking) - let info = wm - .get_wallet_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; // DashPay accounts are funds-bearing; use the typed // `insert_funds_bearing_account` API exposed by the post-split // collection rather than wrapping in `OwnedManagedCoreAccount`. let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); + + // Persist the registration BEFORE the in-memory inserts: a store + // failure aborts with nothing mutated, while an insert failure + // after a successful store leaves only a benign extra row that + // the next load restores into a valid (empty) account. + self.persister + .store(dashpay_account_registration_changeset( + account_type, + account_xpub, + &managed, + )) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to persist contact account registration: {e}" + )) + })?; + + let (wallet, info) = wm + .get_wallet_mut_and_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + + // Mirror the restored shape: the immutable `wallet.accounts` + // collection holds the Account (like `build_wallet_start_state` + // recreates it at load), the managed collection holds pools + + // UTXO state. + wallet + .add_account(account_type, Some(account_xpub)) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to add contact account to wallet: {e}" + )) + })?; info.core_wallet .accounts .insert_funds_bearing_account(managed) @@ -152,6 +235,12 @@ impl IdentityWallet { )) })?; + tracing::info!( + our_identity = %our_identity_id, + contact = %contact_identity_id, + "Registered DashpayReceivingFunds account for receiving payments from contact" + ); + Ok(()) } @@ -258,40 +347,6 @@ impl IdentityWallet { } None } - - /// # Arguments - /// - /// * `account_index` - Account index (hardened) in the derivation path. - /// * `sender_id` - Our identity identifier. - /// * `recipient_id` - The contact's identity identifier. - /// * `start_index` - First payment address index. - /// * `count` - Number of addresses to derive. - pub async fn contact_payment_addresses( - &self, - account_index: u32, - sender_id: &Identifier, - recipient_id: &Identifier, - start_index: u32, - count: u32, - ) -> Result, PlatformWalletError> { - let wm = self.wallet_manager.read().await; - let wallet = wm - .get_wallet(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let data = crate::wallet::identity::crypto::dip14::derive_contact_xpub( - wallet, - self.sdk.network, - account_index, - sender_id, - recipient_id, - )?; - crate::wallet::identity::crypto::dip14::derive_contact_payment_addresses( - &data.xpub, - start_index, - count, - self.sdk.network, - ) - } } // --------------------------------------------------------------------------- @@ -313,28 +368,43 @@ impl IdentityWallet { /// # Arguments /// /// * `our_identity_id` - Our identity that shares the contact relationship. - /// * `contact_identity_id` - The contact's identity. + /// * `contact_identity` - The contact's **already-fetched** identity. The + /// caller fetches it once (for the key-index validation + /// that must precede ECDH) and passes it in, so this + /// method performs **no network I/O** — every failure it + /// returns is therefore a permanent crypto/data fault, + /// not a transient DAPI blip. /// * `contact_encrypted_xpub` - 96-byte encrypted xpub from the contact's /// `contactRequest` document (16-byte IV + 80-byte /// AES-256-CBC ciphertext). - /// * `our_decryption_key_index` - Key ID of our ENCRYPTION key used for ECDH. - /// * `contact_encryption_key_index` - Key ID of the contact's ENCRYPTION key used for ECDH. + /// * `shared_key` - The ECDH shared secret, computed by the Keychain + /// signer (the raw scalar never enters this crate). The + /// caller derives it through the `ContactCryptoProvider`; + /// the key indices it used live with the caller. + /// + /// Returns [`RegisterExternalError`] so the caller can apply the + /// transient/permanent payment-channel policy: a `Permanent` failure + /// (malformed encrypted xpub, missing/non-secp key) breaks the channel; + /// a `Transient` one (persistence/insert hiccup) leaves it for retry. pub async fn register_external_contact_account( &self, our_identity_id: &Identifier, - contact_identity_id: &Identifier, + contact_identity: &Identity, contact_encrypted_xpub: &[u8], - our_decryption_key_index: u32, - contact_encryption_key_index: u32, - ) -> Result<(), PlatformWalletError> { + shared_key: [u8; 32], + ) -> Result<(), RegisterExternalError> { + use RegisterExternalError::{Permanent, Transient}; let account_index: u32 = 0; + let contact_identity_id = contact_identity.id(); // --- 1. Early-exit if the external account already exists. --- { let wm = self.wallet_manager.read().await; - let info = wm - .get_wallet_info(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + Transient(PlatformWalletError::WalletNotFound(hex::encode( + self.wallet_id, + ))) + })?; use key_wallet::account::account_collection::DashpayAccountKey; let key = DashpayAccountKey { index: account_index, @@ -351,108 +421,46 @@ impl IdentityWallet { } } - // --- 2. Derive our ECDH private key under a read lock. --- - let our_private_key = { - let wm = self.wallet_manager.read().await; - let info = wm - .get_wallet_info(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let managed = info - .identity_manager - .managed_identity(our_identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*our_identity_id))?; - // ECDH key derivation needs the wallet HD slot — only valid - // for wallet-owned identities. Reject the out-of-wallet case - // explicitly rather than letting derivation produce a - // misleading error downstream. - let identity_index = managed - .identity_index - .ok_or(PlatformWalletError::IdentityIndexNotSet(*our_identity_id))?; - - // Find our decryption key by its key ID. - let our_encryption_key = managed - .identity - .public_keys() - .get(&our_decryption_key_index) - .cloned() - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "Our encryption key {} not found on identity {}", - our_decryption_key_index, our_identity_id - )) - })?; - - let wallet = wm - .get_wallet(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - - Self::derive_encryption_private_key( - wallet, - self.sdk.network, - identity_index, - &our_encryption_key, - )? - }; - - // --- 3. Fetch the contact's identity from Platform and extract their encryption pubkey. --- - let contact_public_key: dashcore::secp256k1::PublicKey = { - use dash_sdk::platform::Fetch; - let contact_identity = Identity::fetch(&self.sdk, *contact_identity_id) - .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to fetch contact identity {}: {}", - contact_identity_id, e - )) - })? - .ok_or_else(|| PlatformWalletError::IdentityNotFound(*contact_identity_id))?; - - let contact_key = contact_identity - .public_keys() - .get(&contact_encryption_key_index) - .cloned() - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "Contact encryption key {} not found on identity {}", - contact_encryption_key_index, contact_identity_id - )) - })?; - - // Deserialize the compressed public key bytes from the identity key data. - dashcore::secp256k1::PublicKey::from_slice(contact_key.data().as_slice()).map_err( - |e| { - PlatformWalletError::InvalidIdentityData(format!( - "Contact encryption key is not a valid secp256k1 public key: {}", - e - )) - }, - )? - }; - - // --- 4. Derive the ECDH shared key. --- - let shared_key: [u8; 32] = - platform_encryption::derive_shared_key_ecdh(&our_private_key, &contact_public_key); - - // --- 5. Decrypt the contact's xpub. --- + // --- 2. Decrypt the contact's xpub with the signer-derived secret. --- let decrypted_xpub_bytes = platform_encryption::decrypt_extended_public_key(&shared_key, contact_encrypted_xpub) .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( + Permanent(PlatformWalletError::InvalidIdentityData(format!( "Failed to decrypt contact xpub: {}", e - )) + ))) })?; - // --- 6. Reconstruct the ExtendedPubKey from the raw encoded bytes. --- - let contact_xpub = key_wallet::bip32::ExtendedPubKey::decode(&decrypted_xpub_bytes) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to decode contact xpub: {}", - e - )) - })?; + // --- 3. Reconstruct the ExtendedPubKey from the decrypted plaintext. --- + // + // DIP-15 + both reference clients (iOS dash-shared-core, Android dashj) + // use the 69-byte COMPACT form (fingerprint ‖ chaincode ‖ pubkey) — + // the version/depth/child-number metadata is omitted on the wire and + // reconstructed here from the known friendship-path context. Only + // chain_code + public_key feed non-hardened ckd_pub, so reconstruction + // yields identical payment addresses (pinned by + // `reconstructed_xpub_derives_identical_addresses` in crypto::dip14). + // + // Backward-compat: a locally-stored legacy plaintext could be the old + // 78/107-byte BIP32/DIP-14 serialization. Nothing nonconforming has + // reached chain, but we keep one cheap fallback branch as insurance. + let contact_xpub = match platform_encryption::parse_compact_xpub(&decrypted_xpub_bytes) { + Ok(compact) => crate::wallet::identity::crypto::dip14::reconstruct_contact_xpub( + compact, + self.sdk.network, + ) + .map_err(Permanent)?, + Err(_) => { + key_wallet::bip32::ExtendedPubKey::decode(&decrypted_xpub_bytes).map_err(|e| { + Permanent(PlatformWalletError::InvalidIdentityData(format!( + "Decrypted contact xpub is neither a 69-byte DIP-15 compact form \ + nor a 78/107-byte BIP32/DIP-14 serialization: {e}" + ))) + })? + } + }; - // --- 7. Build the watch-only Account and register it. --- + // --- 4. Build the watch-only Account and register it. --- // // Two insertions are needed: // a) `wallet.accounts` (immutable AccountCollection) — stores the Account with @@ -479,20 +487,40 @@ impl IdentityWallet { // typed `insert_funds` API after the upstream split. let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); + // Persist the registration BEFORE the in-memory inserts (same + // rationale as `register_contact_account`): without this round + // the account vanishes on relaunch and `send_payment` loses its + // xpub + derived-address state until the next sweep rebuilds it. + self.persister + .store(dashpay_account_registration_changeset( + account_type, + contact_xpub, + &managed, + )) + .map_err(|e| { + Transient(PlatformWalletError::InvalidIdentityData(format!( + "Failed to persist external contact account registration: {e}" + ))) + })?; + let mut wm = self.wallet_manager.write().await; let (wallet, info) = wm .get_wallet_mut_and_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + .ok_or_else(|| { + Transient(PlatformWalletError::WalletNotFound(hex::encode( + self.wallet_id, + ))) + })?; // (a) Insert Account into the immutable wallet account collection so the // xpub is accessible by `send_payment`. wallet .add_account(account_type, Some(contact_xpub)) .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( + Transient(PlatformWalletError::InvalidIdentityData(format!( "Failed to add external contact account to wallet: {}", e - )) + ))) })?; // (b) Insert ManagedCoreFundsAccount for address-pool tracking. @@ -500,10 +528,10 @@ impl IdentityWallet { .accounts .insert_funds_bearing_account(managed) .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( + Transient(PlatformWalletError::InvalidIdentityData(format!( "Failed to register external contact account: {}", e - )) + ))) })?; tracing::info!( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rs deleted file mode 100644 index 0c07acb2375..00000000000 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Comprehensive DashPay-sync aggregator. -//! -//! Glue method that drives the two step DashPay refresh (contact -//! requests then profiles). Lives in its own file so the -//! `IdentityWallet` handle's operation surface stays one-method-per-file. - -use super::*; -use crate::broadcaster::TransactionBroadcaster; -use crate::error::PlatformWalletError; - -impl IdentityWallet { - /// Comprehensive DashPay sync: contact requests followed by profiles. - /// - /// Call this on wallet open and on periodic refresh. Failures in either - /// step propagate immediately; partial progress is not rolled back. - pub async fn dashpay_sync(&self) -> Result<(), PlatformWalletError> { - // Contact requests first — may establish new contacts. - self.sync_contact_requests().await?; - // Then profiles for all managed identities. - self.sync_profiles().await?; - Ok(()) - } -} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 41369a3f72b..c8416c2997c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -31,6 +31,88 @@ enum KeyHashSource<'a> { Master(&'a ExtendedPrivKey), } +/// For each on-chain key of `identity`, decide its derivation breadcrumb: +/// `Some((wallet_id, identity_index, key_id))` when `candidate_scalars` +/// holds a scalar that reproduces the on-chain key — so the client can +/// re-derive that key's private material from the wallet seed — else +/// `None`, a watch-only key the wallet cannot sign with. +/// +/// Verification uses the canonical +/// [`IdentityPublicKey::validate_private_key_bytes`] — the same primitive +/// the protocol uses to validate key ownership — so the wallet's match is +/// identical to consensus and cannot drift. For ECDSA it recomputes the +/// compressed public key from the candidate scalar and compares; every key +/// the wallet could own is 33-byte compressed, so this matches all of them. +/// A key that is NOT reproducible from this wallet's seed stays watch-only: +/// a foreign key, a BLS/EdDSA key (an ECDSA-derived candidate never +/// reproduces a different-curve key), or an uncompressed externally- +/// registered ECDSA key (Platform's signature checks accept uncompressed +/// keys, but the wallet only ever derives the compressed form, so such a key +/// simply isn't wallet-derivable). So a non-reproducible key is never handed +/// a (wrong) breadcrumb — the load-bearing guard that stops the client from +/// materializing and signing with a key the identity does not authorize +/// on-chain. An ECDSA *authentication* key that fails to verify at its +/// `key_id` candidate is logged at `warn` so a still-unsignable import is +/// diagnosable in the field (no key material is logged). +fn breadcrumb_decisions( + identity: &Identity, + identity_index: u32, + wallet_id: [u8; 32], + network: key_wallet::Network, + candidate_scalars: &std::collections::BTreeMap< + dpp::identity::KeyID, + zeroize::Zeroizing<[u8; 32]>, + >, +) -> Vec { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; + use dpp::identity::KeyType; + + identity + .public_keys() + .iter() + .map(|(key_id, on_chain_key)| { + let reproduces = candidate_scalars + .get(key_id) + .map(|scalar| { + on_chain_key + .validate_private_key_bytes(scalar, network) + .unwrap_or(false) + }) + .unwrap_or(false); + let (breadcrumb, verified_scalar) = if reproduces { + // Carry the already-verified scalar to the client so it + // stores the bytes directly instead of re-deriving from a + // mnemonic. Only a scalar that reproduced the on-chain key + // reaches this branch, so the carried secret is always the + // authorized one. + ( + Some((wallet_id, identity_index, *key_id)), + candidate_scalars.get(key_id).cloned(), + ) + } else { + if matches!( + on_chain_key.key_type(), + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 + ) { + tracing::warn!( + identity = %identity.id(), + key_id = *key_id, + "discovered identity ECDSA key did not verify at its key_id \ + derivation candidate; left watch-only (cannot sign with this key)" + ); + } + (None, None) + }; + crate::changeset::KeyWithBreadcrumb { + key: on_chain_key.clone(), + breadcrumb, + verified_scalar, + } + }) + .collect() +} + // --------------------------------------------------------------------------- // Identity discovery (gap-limit scan) // --------------------------------------------------------------------------- @@ -135,6 +217,81 @@ impl IdentityWallet { .await } + /// Derive a candidate ECDSA auth scalar for every on-chain key of a + /// just-found `identity`, verify each reproduces the published key, and + /// return the per-key derivation-breadcrumb decisions (see + /// [`breadcrumb_decisions`]). Shared by the discovery scan and the + /// index-load path so both materialize the identity's *full* signable + /// key set — not just the MASTER key — letting an imported identity + /// sign with its HIGH / CRITICAL keys. + /// + /// `master == Some(..)` derives lock-free from the resolved master + /// xpriv (external-signable wallets); `None` derives from the resident + /// in-memory wallet under a brief read lock that is dropped before the + /// caller takes the write lock to emit. `key_index == key_id` mirrors + /// the registration path; the per-key verify makes a wrong assumption + /// fail safe (watch-only). + pub(crate) async fn derive_key_breadcrumbs( + &self, + identity: &Identity, + identity_index: u32, + network: key_wallet::Network, + master: Option<&ExtendedPrivKey>, + ) -> Result, PlatformWalletError> { + use super::identity_handle::{ + derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_keypair, + }; + + let mut candidate_scalars: std::collections::BTreeMap< + dpp::identity::KeyID, + zeroize::Zeroizing<[u8; 32]>, + > = std::collections::BTreeMap::new(); + match master { + Some(master) => { + for key_id in identity.public_keys().keys() { + if let Ok(kp) = derive_ecdsa_identity_auth_keypair_from_master( + master, + network, + identity_index, + *key_id, + ) { + candidate_scalars.insert(*key_id, kp.private_key); + } + } + } + None => { + let wm_read = self.wallet_manager.read().await; + // The wallet was present for the master probe one step + // earlier; its absence here is a genuine error, so fail loud + // (consistent with every other manager lookup in this file) + // rather than silently leaving every key watch-only. + let wallet = wm_read.get_wallet(&self.wallet_id).ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound( + "Wallet not found in wallet manager".to_string(), + ) + })?; + for key_id in identity.public_keys().keys() { + if let Ok((_, xpriv, _)) = + derive_identity_auth_keypair(wallet, network, identity_index, *key_id) + { + candidate_scalars.insert( + *key_id, + zeroize::Zeroizing::new(xpriv.private_key.secret_bytes()), + ); + } + } + } + } + + Ok(breadcrumb_decisions( + identity, + identity_index, + self.wallet_id, + network, + &candidate_scalars, + )) + } + /// Shared gap-limit scan body for [`Self::discover`] and /// [`Self::discover_from_master`]. The only thing the two callers /// vary is `source`, which decides how each probe's MASTER auth @@ -148,16 +305,11 @@ impl IdentityWallet { opts: IdentityDiscoveryOptions, source: KeyHashSource<'_>, ) -> Result, PlatformWalletError> { - use super::identity_handle::{ - derive_identity_auth_key_hash_from_master, identity_auth_derivation_path, - MASTER_KEY_INDEX, - }; + use super::identity_handle::{derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX}; use crate::wallet::identity::state::managed_identity::key_storage::DpnsNameInfo; use crate::wallet::identity::state::managed_identity::key_storage::IdentityStatus; use dash_sdk::platform::types::identity::PublicKeyHash; use dash_sdk::platform::Fetch; - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - use dpp::util::hash::ripemd160_sha256; // `MASTER_KEY_INDEX = 0` pulled in from `identity_handle` — // only key_index 0 is ever registered as the MASTER auth @@ -229,25 +381,24 @@ impl IdentityWallet { Ok(Some(identity)) => { let identity_id = identity.id(); - // Same helper the FFI-side preview uses — - // keeps the scan and the preview on a single - // path-building code path. - let full_path = - identity_auth_derivation_path(network, identity_index, MASTER_KEY_INDEX)?; - - // Find the KeyID in the on-chain identity whose - // hash matches our derived key so the derivation - // path gets stored against the right KeyID. - let matched_key_id_and_pub = identity - .public_keys() - .iter() - .find(|(_, pk)| { - let pk_hash = ripemd160_sha256(pk.data().as_slice()); - pk_hash.as_slice() == key_hash_array - }) - .map(|(kid, pk)| (*kid, pk.clone())); - - // Acquire write lock to add/enrich the identity. + // Derive + verify a candidate for every on-chain key + // (shared with the index-load path) BEFORE taking the write + // lock — candidate derivation borrows the resident wallet / + // master xpriv, while breadcrumb emission needs `&mut info`. + let key_decisions = self + .derive_key_breadcrumbs( + &identity, + identity_index, + network, + match source { + KeyHashSource::Master(master) => Some(master), + KeyHashSource::ResidentWallet => None, + }, + ) + .await?; + + // Acquire write lock to add/enrich the identity, then emit + // every per-key breadcrumb in one batched changeset. let mut wm_guard = self.wallet_manager.write().await; let info_guard = wm_guard @@ -273,22 +424,14 @@ impl IdentityWallet { { managed.set_status(IdentityStatus::Active, &self.persister); managed.wallet_id = Some(wallet_id); - - if let Some((_kid, pub_key)) = matched_key_id_and_pub { - // Pass the DIP-9 breadcrumb so the client can - // re-derive the private key from its wallet - // mnemonic via the iOS Keychain path. - managed.add_key( - pub_key, - Some((wallet_id, identity_index, MASTER_KEY_INDEX)), - &self.persister, - ); - } + // Breadcrumbs for every re-derivable key (not just the + // MASTER key) so the client (iOS Keychain) can + // re-derive each signing key's private key — without + // this only the master key is materialized and the + // imported identity cannot sign with its HIGH / + // CRITICAL authentication keys. + managed.add_keys(key_decisions, &self.persister); } - // `full_path` is no longer needed once `add_key` - // takes the breadcrumb instead of the materialized - // DerivationPath. - let _ = full_path; drop(wm_guard); if is_new { @@ -367,3 +510,194 @@ impl IdentityWallet { Ok(discovered) } } + +#[cfg(test)] +mod tests { + use super::super::identity_handle::derive_ecdsa_identity_auth_keypair_from_master; + use super::breadcrumb_decisions; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; + use dpp::prelude::Identifier; + use key_wallet::bip32::ExtendedPrivKey; + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::Network; + use std::collections::BTreeMap; + + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + fn test_master() -> ExtendedPrivKey { + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("mnemonic"); + let seed = mnemonic.to_seed(""); + ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master xpriv") + } + + fn ecdsa_auth_key(id: KeyID, data: Vec) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(data), + disabled_at: None, + }) + } + + fn identity_with_keys(keys: Vec) -> Identity { + let mut map = BTreeMap::new(); + for k in keys { + map.insert(k.id(), k); + } + Identity::V0(IdentityV0 { + id: Identifier::from([0x42; 32]), + public_keys: map, + balance: 0, + revision: 0, + }) + } + + /// Every on-chain key re-derivable at `(identity_index, key_id)` earns a + /// breadcrumb. This is the fix for "imported identity cannot sign": + /// before, only the MASTER key was breadcrumbed, so the non-master + /// signing keys had no private material and signing failed. + #[test] + fn breadcrumb_decisions_emits_for_every_reproducible_key() { + let master = test_master(); + let wallet_id = [0xAB; 32]; + let identity_index = 0u32; + + let mut scalars = BTreeMap::new(); + let mut keys = Vec::new(); + for key_id in 0u32..5 { + let kp = derive_ecdsa_identity_auth_keypair_from_master( + &master, + Network::Testnet, + identity_index, + key_id, + ) + .expect("derive candidate"); + keys.push(ecdsa_auth_key(key_id, kp.public_key.to_vec())); + scalars.insert(key_id, kp.private_key); + } + let identity = identity_with_keys(keys); + + let decisions = breadcrumb_decisions( + &identity, + identity_index, + wallet_id, + Network::Testnet, + &scalars, + ); + assert_eq!(decisions.len(), 5); + for decision in &decisions { + assert_eq!( + decision.breadcrumb, + Some((wallet_id, identity_index, decision.key.id())), + "key {} must be breadcrumbed", + decision.key.id() + ); + // A reproducible key carries its already-verified scalar so the + // client stores it without re-deriving from the mnemonic. + assert_eq!( + decision.verified_scalar.as_deref(), + scalars.get(&decision.key.id()).map(|s| &**s), + "key {} must carry its verified scalar", + decision.key.id() + ); + } + } + + /// A published key whose bytes do NOT match the candidate at its `key_id` + /// (a foreign / non-wallet key) stays watch-only — verify-before-emit + /// never hands a wrong breadcrumb that would store an unauthorized key. + #[test] + fn breadcrumb_decisions_leaves_non_reproducible_key_watch_only() { + let master = test_master(); + let wallet_id = [0xAB; 32]; + + let kp0 = derive_ecdsa_identity_auth_keypair_from_master(&master, Network::Testnet, 0, 0) + .expect("derive"); + // A foreign pubkey for key_id 1 (derived at an unrelated slot) — the + // seed candidate at (0, 1) won't reproduce it. + let kp_foreign = + derive_ecdsa_identity_auth_keypair_from_master(&master, Network::Testnet, 9, 9) + .expect("derive"); + let kp1_candidate = + derive_ecdsa_identity_auth_keypair_from_master(&master, Network::Testnet, 0, 1) + .expect("derive"); + + let mut scalars = BTreeMap::new(); + scalars.insert(0u32, kp0.private_key); + scalars.insert(1u32, kp1_candidate.private_key); + + let identity = identity_with_keys(vec![ + ecdsa_auth_key(0, kp0.public_key.to_vec()), + ecdsa_auth_key(1, kp_foreign.public_key.to_vec()), + ]); + let decisions = breadcrumb_decisions(&identity, 0, wallet_id, Network::Testnet, &scalars); + let by_id: BTreeMap = + decisions.iter().map(|d| (d.key.id(), d)).collect(); + assert_eq!( + by_id[&0].breadcrumb, + Some((wallet_id, 0, 0)), + "reproducible key breadcrumbed" + ); + assert!( + by_id[&0].verified_scalar.is_some(), + "reproducible key carries its verified scalar" + ); + assert_eq!(by_id[&1].breadcrumb, None, "foreign key left watch-only"); + assert!( + by_id[&1].verified_scalar.is_none(), + "foreign key carries no scalar" + ); + } + + /// An on-chain key typed `ECDSA_HASH160` (data = the 20-byte hash of + /// the pubkey) is matched by its hash — `validate_private_key_bytes` + /// covers both ECDSA representations. (Uncompressed 65-byte ECDSA keys + /// are deliberately NOT tested: Platform rejects them at registration + /// with `UncompressedPublicKeyNotAllowedError`, so they can't be a valid + /// on-chain key; compressed-only matching is correct and consensus- + /// consistent.) + #[test] + fn breadcrumb_decisions_matches_hash160_key() { + use dpp::util::hash::ripemd160_sha256; + + let master = test_master(); + let wallet_id = [0xAB; 32]; + let kp = derive_ecdsa_identity_auth_keypair_from_master(&master, Network::Testnet, 0, 0) + .expect("derive"); + let hash = ripemd160_sha256(&kp.public_key).to_vec(); + + let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: dpp::platform_value::BinaryData::new(hash), + disabled_at: None, + }); + + let mut scalars = BTreeMap::new(); + scalars.insert(0u32, kp.private_key); + let identity = identity_with_keys(vec![key]); + + let decisions = breadcrumb_decisions(&identity, 0, wallet_id, Network::Testnet, &scalars); + assert_eq!( + decisions[0].breadcrumb, + Some((wallet_id, 0, 0)), + "HASH160 key must verify by hash" + ); + assert!( + decisions[0].verified_scalar.is_some(), + "HASH160 key carries its verified scalar" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 881a9a73890..e2ace6e9d34 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -315,6 +315,15 @@ pub struct IdentityWallet { /// `SpvBroadcaster`-pinned, while this one picks the broadcaster /// used by `send_payment` (static dispatch per call). pub(crate) broadcaster: Arc, + /// Object-safe seam over the SDK's DashPay write operations + /// (contact-request broadcast, document put). Defaults to an + /// [`SdkWriter`](super::sdk_writer::SdkWriter) wrapping `sdk` so + /// public construction and the FFI are untouched; the network-layer + /// tests inject a recording/stub writer here. The write half of the + /// DashPay network layer can't ride the dash-sdk mock the way the + /// fetch half does (`Sdk::send_contact_request` is generic over + /// seven type params), so this trait object is the test seam for it. + pub(crate) sdk_writer: Arc, } // Manual `Debug`: the derive would require `B: Debug`, which is not part @@ -337,6 +346,7 @@ impl Clone for IdentityWallet { asset_locks: Arc::clone(&self.asset_locks), persister: self.persister.clone(), broadcaster: Arc::clone(&self.broadcaster), + sdk_writer: Arc::clone(&self.sdk_writer), } } } @@ -454,59 +464,6 @@ impl IdentityWallet { &self.wallet_id } - /// Derive the ECDH private key for the given identity's encryption - /// key (DashPay ECDH). - /// - /// Uses the DIP-9 identity-authentication derivation path and - /// returns the raw `secp256k1::SecretKey` needed for ECDH with a - /// contact. - /// - /// The encryption key must be `ECDSA_SECP256K1` or `ECDSA_HASH160`; - /// other key types are not supported for ECDH derivation. - pub(super) fn derive_encryption_private_key( - wallet: &Wallet, - network: key_wallet::Network, - identity_index: u32, - encryption_key: &IdentityPublicKey, - ) -> Result { - // Validate that the encryption key type is compatible with ECDH - // derivation. - match encryption_key.key_type() { - KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => {} - other => { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "Unsupported key type {:?} for ECDH derivation; \ - expected ECDSA_SECP256K1 or ECDSA_HASH160", - other - ))); - } - } - - let path = Self::identity_auth_derivation_path( - network, - KeyDerivationType::ECDSA, - identity_index, - encryption_key.id(), - )?; - - let ext_priv = wallet.derive_extended_private_key(&path).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive encryption private key: {}", - e - )) - })?; - - // Wrap intermediate private key bytes in `Zeroizing` so they - // are wiped on drop. - let secret_bytes = Zeroizing::new(ext_priv.private_key.secret_bytes()); - - dashcore::secp256k1::SecretKey::from_slice(&*secret_bytes).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Invalid derived encryption private key: {}", - e - )) - }) - } } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 562a7950d06..3e3cced070b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -2,7 +2,6 @@ use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::Identity; use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; @@ -171,7 +170,6 @@ impl IdentityWallet { use crate::wallet::identity::state::managed_identity::key_storage::IdentityStatus; use dash_sdk::platform::types::identity::PublicKeyHash; use dash_sdk::platform::Fetch; - use dpp::util::hash::ripemd160_sha256; let wallet_id = self.wallet_id; @@ -183,7 +181,7 @@ impl IdentityWallet { // unit-testable `derive_load_probe_hash` helper, which probes // `MASTER_KEY_INDEX` (not a hardcoded `0`) so loading and the // discovery scan visibly target the same slot. - let key_hash_array = { + let (key_hash_array, network) = { let wm = self.wallet_manager.read().await; let wallet = wm.get_wallet(&self.wallet_id).ok_or_else(|| { crate::error::PlatformWalletError::WalletNotFound( @@ -202,7 +200,10 @@ impl IdentityWallet { } LoadKeyHashSource::Master(master) => ResolvedLoadKeyHashSource::Master(master), }; - derive_load_probe_hash(resolved, network, identity_index)? + ( + derive_load_probe_hash(resolved, network, identity_index)?, + network, + ) }; // Query Platform for an identity registered with this key hash. @@ -219,15 +220,20 @@ impl IdentityWallet { let identity_id = identity.id(); - // Find which KeyID in the on-chain identity matches this key hash. - let matched_key_id_and_pub = identity - .public_keys() - .iter() - .find(|(_, pk)| { - let pk_hash = ripemd160_sha256(pk.data().as_slice()); - pk_hash.as_slice() == key_hash_array - }) - .map(|(kid, pk)| (*kid, pk.clone())); + // Derive + verify a candidate for EVERY on-chain key (shared with + // the discovery scan) so the imported identity can sign with all its + // re-derivable keys, not just MASTER. Runs before the write lock. + let key_decisions = self + .derive_key_breadcrumbs( + &identity, + identity_index, + network, + match source { + LoadKeyHashSource::Master(master) => Some(master), + LoadKeyHashSource::ResidentWallet => None, + }, + ) + .await?; // Add the identity to the manager and enrich it. { @@ -249,19 +255,8 @@ impl IdentityWallet { if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { managed.set_status(IdentityStatus::Active, &self.persister); managed.wallet_id = Some(wallet_id); - - if let Some((_kid, pub_key)) = matched_key_id_and_pub { - // Emit the DIP-9 derivation breadcrumb on the - // keys-changeset upsert so the client can re-derive - // the private key from its wallet mnemonic. Use - // `MASTER_KEY_INDEX` (not a bare `0`) so the stored - // breadcrumb matches the slot we probed above. - managed.add_key( - pub_key, - Some((wallet_id, identity_index, MASTER_KEY_INDEX)), - &self.persister, - ); - } + // Breadcrumbs for every re-derivable key (was MASTER-only). + managed.add_keys(key_decisions, &self.persister); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 533973014e1..7d3a54e8ef8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -33,17 +33,33 @@ mod withdrawal; // DashPay-contract operations (same `IdentityWallet` impl blocks). mod account_labels; +mod contact_info; mod contact_requests; mod contacts; -mod dashpay_sync; +mod payment_handler; +pub(crate) use payment_handler::DashPayPaymentHandler; +// Re-exported for the payments unit tests, which drive the hooks +// directly; the handler itself calls it module-locally. +#[cfg(test)] +pub(crate) use payment_handler::run_dashpay_payment_hooks; mod payments; +pub(crate) use payments::{ + confirm_sent_dashpay_payment, confirm_sent_dashpay_payment_by_txid, + record_incoming_dashpay_payments, +}; mod profile; +pub(crate) mod sdk_writer; +mod seed_binding; // Token state-transition operations (same `IdentityWallet` impl blocks). // Bookkeeping (watch / sync / balance) lives on // `crate::manager::identity_sync::IdentitySyncManager`. mod tokens; +pub use contact_info::ContactInfoPublishOutcome; +pub use contact_requests::{ + AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoSealed, +}; pub use discovery::IdentityDiscoveryOptions; pub use dpns::{ContestContender, ContestVoteState, ContestWinner}; pub use identity_handle::{ @@ -57,3 +73,32 @@ pub use identity_handle::{ // avoids each sibling having to spell out `identity_handle::` on // every call site. pub(super) use identity_handle::derive_identity_auth_key_hash; + +/// Process-wide cached DashPay data contract. +/// +/// The bundled system contract is immutable for a given platform +/// version, so one parse serves every operation — the previous +/// per-call `load_system_data_contract` re-deserialized the contract +/// on every profile / contactInfo op. +pub(crate) fn dashpay_contract( +) -> Result, crate::error::PlatformWalletError> { + static CONTRACT: std::sync::OnceLock> = + std::sync::OnceLock::new(); + if let Some(contract) = CONTRACT.get() { + return Ok(std::sync::Arc::clone(contract)); + } + let contract = dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::Dashpay, + dpp::version::PlatformVersion::latest(), + ) + .map_err(|e| { + crate::error::PlatformWalletError::InvalidIdentityData(format!( + "Failed to load DashPay contract: {e}" + )) + })?; + let arc = std::sync::Arc::new(contract); + // A concurrent first call may have won the race — return whichever + // Arc actually landed in the cell. + let _ = CONTRACT.set(std::sync::Arc::clone(&arc)); + Ok(CONTRACT.get().map(std::sync::Arc::clone).unwrap_or(arc)) +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs new file mode 100644 index 00000000000..0808e8c0116 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs @@ -0,0 +1,326 @@ +//! Event handler that drives the DashPay payment hooks off upstream +//! `WalletEvent`s. +//! +//! Registered as one of the [`PlatformEventHandler`]s in +//! [`PlatformEventManager`](crate::events::PlatformEventManager), this +//! keeps the DashPay-payment domain logic out of the generic +//! core-changeset bridge ([`spawn_wallet_event_adapter`]): the bridge +//! projects every event into a `CoreChangeSet` and persists it, while +//! this handler independently records incoming payments and confirms +//! sent ones. +//! +//! # Why it spawns +//! +//! [`PlatformEventHandler::on_wallet_event`] is synchronous and is +//! dispatched from dash-spv's wallet-event broadcast monitor, which can +//! fire while SPV holds the wallet-manager write lock. The payment hooks +//! are async and take that same write lock, so they cannot run inline. +//! The handler therefore captures an owned copy of the event and spawns +//! a task that queues on the write lock and runs once SPV releases it. +//! Every hook path is idempotent per txid (re-detections converge and +//! the recurring reconcile sweep backfills anything a lagged broadcast +//! dropped), so running off the core-store bridge's ordering is safe — +//! a payment row's only foreign key is to its `identities` parent, never +//! to a core transaction row. + +use std::sync::Arc; + +use dash_spv::EventHandler; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet_manager::{WalletEvent, WalletId, WalletManager}; +use tokio::sync::RwLock; + +use crate::changeset::traits::PlatformWalletPersistence; +use crate::events::PlatformEventHandler; +use crate::wallet::platform_wallet::PlatformWalletInfo; + +/// Records incoming DashPay payments and confirms sent ones in response +/// to upstream `WalletEvent`s. +/// +/// Holds the manager's `wallet_manager` (for the in-memory identity / +/// payment state the hooks mutate) and an `Arc` +/// (to persist the resulting payment entries). Both are cheap `Arc` +/// clones taken at manager construction. +pub(crate) struct DashPayPaymentHandler { + wallet_manager: Arc>>, + persister: Arc, +} + +impl DashPayPaymentHandler { + pub(crate) fn new( + wallet_manager: Arc>>, + persister: Arc, + ) -> Self { + Self { + wallet_manager, + persister, + } + } +} + +impl EventHandler for DashPayPaymentHandler { + fn on_wallet_event(&self, event: &WalletEvent) { + if !drives_payment_hooks(event) { + return; + } + // Capture owned clones so the async hooks can outlive this + // synchronous dispatch. See the module docs for why this runs on + // its own task rather than inline. + let wallet_manager = Arc::clone(&self.wallet_manager); + let persister = Arc::clone(&self.persister); + let event = event.clone(); + tokio::spawn(async move { + let wallet_id = event.wallet_id(); + let wallet_persister = + crate::wallet::persister::WalletPersister::new(wallet_id, persister); + run_dashpay_payment_hooks(&wallet_manager, &wallet_id, &wallet_persister, &event).await; + }); + } +} + +impl PlatformEventHandler for DashPayPaymentHandler {} + +/// Transaction records carried by `event` that should drive the DashPay +/// payment hooks (live incoming-record recording + sent-payment confirm). +/// +/// [`WalletEvent::TransactionDetected`] is the first off-chain sighting of +/// a transaction — mempool, or a direct InstantSend lock — so its +/// `record.context` is not yet block-confirmed. +/// [`WalletEvent::BlockProcessed`] carries the records a block changed: +/// `inserted` (first stored in this block) and `updated` +/// (previously-known records that this block confirmed). A wallet sees its +/// *own* broadcast in the mempool first, so that transaction reaches a +/// confirmed context only via `BlockProcessed.updated` — routing solely +/// `TransactionDetected` is the gap that left sent payments stuck +/// `Pending`: the confirm hook early-returns on the unconfirmed mempool +/// sighting and never sees the confirming block. `matured` is +/// coinbase-maturity only — never a DashPay payment — so it is excluded. +fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { + // Exhaustive on purpose (no `_` arm): a new upstream `WalletEvent` + // variant that carries transaction records must fail to compile here + // rather than be silently dropped — routing only `TransactionDetected` + // is exactly the gap that left sent payments stuck `Pending`. + match event { + WalletEvent::TransactionDetected { record, .. } => vec![record.as_ref()], + WalletEvent::BlockProcessed { + inserted, updated, .. + } => inserted.iter().chain(updated.iter()).collect(), + WalletEvent::TransactionInstantLocked { .. } + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::ChainLockProcessed { .. } => Vec::new(), + } +} + +/// Whether `event` is worth spawning a payment-hook task for. +/// +/// Covers the record-bearing events ([`dashpay_payment_records`]) plus +/// [`WalletEvent::TransactionInstantLocked`], which drives the sent-payment +/// confirm by txid alone (no record). A `BlockProcessed` that changed no +/// records — the common case while syncing past empty blocks — has no +/// payment work, so it is skipped rather than spawning a task that would +/// only take and release the wallet-manager write lock for nothing. +/// Allocation-free. +fn drives_payment_hooks(event: &WalletEvent) -> bool { + match event { + WalletEvent::TransactionDetected { .. } | WalletEvent::TransactionInstantLocked { .. } => { + true + } + WalletEvent::BlockProcessed { + inserted, updated, .. + } => !inserted.is_empty() || !updated.is_empty(), + WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => false, + } +} + +/// Run the DashPay payment hooks for `event`: record any incoming DashPay +/// payment, then advance a matching sent payment from `Pending` to +/// `Confirmed` once its transaction reaches finality (mined or +/// InstantSend-locked). All paths are idempotent per txid, so re-detections +/// and repeated block-processing rounds converge without duplicating +/// entries. +pub(crate) async fn run_dashpay_payment_hooks( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + persister: &crate::wallet::persister::WalletPersister, + event: &WalletEvent, +) { + // An InstantSend lock applied to a previously-seen transaction carries + // no record — only a txid — and is final for DashPay display, so + // confirm the matching sent payment directly. + if let WalletEvent::TransactionInstantLocked { txid, .. } = event { + crate::wallet::identity::network::confirm_sent_dashpay_payment_by_txid( + wallet_manager, + wallet_id, + persister, + txid, + ) + .await; + return; + } + for record in dashpay_payment_records(event) { + crate::wallet::identity::network::record_incoming_dashpay_payments( + wallet_manager, + wallet_id, + persister, + record, + ) + .await; + crate::wallet::identity::network::confirm_sent_dashpay_payment( + wallet_manager, + wallet_id, + persister, + record, + ) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::blockdata::transaction::Transaction; + use dashcore::TxIn; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::{TransactionContext, TransactionType}; + use key_wallet::WalletCoreBalance; + + /// A `TransactionRecord` whose txid is uniquely seeded by `seed` (via a + /// distinct input outpoint). Context is irrelevant to the routing under + /// test, so it stays `Mempool`. + fn record(seed: u8) -> TransactionRecord { + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: dashcore::OutPoint::new(dashcore::Txid::from([seed; 32]), 0), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) + } + + fn block_processed( + inserted: Vec, + updated: Vec, + matured: Vec, + ) -> WalletEvent { + WalletEvent::BlockProcessed { + wallet_id: [0u8; 32], + height: 1, + chain_lock: None, + inserted, + updated, + matured, + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + addresses_derived: Vec::new(), + } + } + + /// `BlockProcessed` is the path by which a wallet's own broadcast + /// confirms (`updated`), and the path by which a payment first seen in a + /// block lands (`inserted`); both must drive the DashPay payment hooks. + /// `matured` is coinbase-maturity only and carries no DashPay payment, so + /// it is excluded. A regression that re-narrows routing to + /// `TransactionDetected` — the original sent-payment-stuck-`Pending` bug — + /// drops the `updated` record and fails this test. + #[test] + fn dashpay_payment_records_covers_block_processed_inserted_and_updated() { + let event = block_processed(vec![record(0x01)], vec![record(0x02)], vec![record(0x03)]); + let txids: Vec<_> = dashpay_payment_records(&event) + .iter() + .map(|r| r.txid) + .collect(); + assert!( + txids.contains(&record(0x01).txid), + "inserted record must drive the payment hooks" + ); + assert!( + txids.contains(&record(0x02).txid), + "updated (just-confirmed) record must drive the payment hooks — \ + this is how a sent payment flips Pending → Confirmed" + ); + assert!( + !txids.contains(&record(0x03).txid), + "matured coinbase is not a DashPay payment and must be excluded" + ); + assert_eq!(txids.len(), 2, "exactly inserted ∪ updated"); + } + + /// The first mempool sighting still routes its single record (incoming + /// recording + the early-returning confirm probe). + #[test] + fn dashpay_payment_records_covers_transaction_detected() { + let event = WalletEvent::TransactionDetected { + wallet_id: [0u8; 32], + record: Box::new(record(0x07)), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + addresses_derived: Vec::new(), + }; + let txids: Vec<_> = dashpay_payment_records(&event) + .iter() + .map(|r| r.txid) + .collect(); + assert_eq!(txids, vec![record(0x07).txid]); + } + + /// Events with no transaction records contribute nothing, and a + /// record-less, non-IS event does not drive the payment hooks. + #[test] + fn dashpay_payment_records_empty_for_non_record_events() { + let event = WalletEvent::SyncHeightAdvanced { + wallet_id: [0u8; 32], + height: 42, + }; + assert!(dashpay_payment_records(&event).is_empty()); + assert!(!drives_payment_hooks(&event)); + } + + /// `TransactionInstantLocked` carries no record but DOES drive the + /// payment hooks — it confirms a sent payment by txid alone (an + /// InstantSend lock is final for DashPay display). + #[test] + fn instant_locked_drives_payment_hooks_without_a_record() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + let event = WalletEvent::TransactionInstantLocked { + wallet_id: [0u8; 32], + txid: dashcore::Txid::from([0x11; 32]), + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + // No record to route, but the event must still drive the hooks. + assert!(dashpay_payment_records(&event).is_empty()); + assert!(drives_payment_hooks(&event)); + } + + /// A `BlockProcessed` that changed no records (syncing past an empty + /// block) has no payment work, so it must not spawn a hook task. Pins + /// the spawn-skip that keeps initial sync from taking the wallet-manager + /// write lock once per empty block. + #[test] + fn empty_block_processed_does_not_drive_payment_hooks() { + let event = block_processed(Vec::new(), Vec::new(), vec![record(0x05)]); + // `matured`-only blocks carry no DashPay payment and no inserted/ + // updated records, so there is nothing to route and nothing to spawn. + assert!(dashpay_payment_records(&event).is_empty()); + assert!(!drives_payment_hooks(&event)); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index c135e04e6fc..f06d5bea1ad 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -3,60 +3,365 @@ use dpp::prelude::Identifier; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use std::sync::Arc; + +use tokio::sync::RwLock; + +use key_wallet_manager::WalletManager; + use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; -use crate::wallet::identity::types::dashpay::payment::DashpayAddressMatch; +use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; // --------------------------------------------------------------------------- -// Incoming payment recording +// Incoming payment recording + reconcile // --------------------------------------------------------------------------- impl IdentityWallet { - /// Match a Core transaction output address against DashPay contact - /// receiving accounts AND record the payment if matched. + /// Derive missing `Received` [`PaymentEntry`]s from the wallet's + /// `DashpayReceivingFunds` accounts' UTXO sets. /// - /// Combines address matching with payment recording in one call so - /// callers don't need to manually construct `PaymentEntry` or access - /// `ManagedIdentity`. Returns the match info for logging. + /// Recovery path for incoming-payment history: live detection + /// ([`record_incoming_dashpay_payments`]) only fires while the app + /// is running, so payments received before a relaunch (whose UTXOs + /// are restored from persistence) or during a missed event window + /// would otherwise never appear in the payment history. Runs as a + /// local-only step of `dashpay_sync()` — no network round-trips. /// - /// Non-blocking: returns `Err(())` if the wallet-manager lock is - /// contended. Safe to call from any thread. - #[allow(clippy::result_unit_err)] - pub fn try_record_incoming_payment( - &self, - address: &dashcore::Address, - txid: String, - value: u64, - ) -> Result, ()> { - let wm = self.wallet_manager.try_write().map_err(|_| ())?; - let Some(info) = wm.get_wallet_info(&self.wallet_id) else { - return Ok(None); - }; - let m = Self::match_in_collection(info, address); - drop(wm); - - if let Some(ref m) = m { - let this = self.clone(); - let owner_id = m.user_identity_id; - let contact_id = m.friend_identity_id; - tokio::spawn(async move { - let mut wm = this.wallet_manager.write().await; - if let Some(info) = wm.get_wallet_info_mut(&this.wallet_id) { - if let Some(managed) = info.identity_manager.managed_identity_mut(&owner_id) { - managed.record_dashpay_payment( - txid, - crate::wallet::identity::types::dashpay::payment::PaymentEntry::new_received( - contact_id, value, None, - ), - &this.persister, - ); + /// Idempotent: entries are keyed by txid and an existing entry for + /// a txid (including the owner's own `Sent` record when both + /// identities live in one wallet) is never overwritten. + /// + /// Returns the number of newly recorded entries. + pub async fn reconcile_incoming_payments(&self) -> Result { + use std::collections::BTreeMap; + + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return Ok(0); + }; + + // Sum per (owner, contact, txid) first so the immutable borrow + // of the account collection ends before the identity-manager + // mutations below. Multiple outputs of one tx to the same + // receival account collapse into a single entry. + let mut totals: BTreeMap<(Identifier, Identifier, String), u64> = BTreeMap::new(); + for (key, account) in &info.core_wallet.accounts.dashpay_receival_accounts { + for utxo in account.utxos.values() { + let txid = utxo.outpoint.txid.to_string(); + *totals + .entry(( + Identifier::from(key.user_identity_id), + Identifier::from(key.friend_identity_id), + txid, + )) + .or_default() += utxo.txout.value; + } + } + + let mut recorded = 0usize; + for ((owner, contact, txid), amount_duffs) in totals { + let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { + continue; + }; + if managed.dashpay_payments.contains_key(&txid) { + continue; + } + tracing::info!( + owner = %owner, + contact = %contact, + %txid, + amount_duffs, + "Recording reconciled incoming DashPay payment" + ); + // Self-healing path: a failed persist is re-derived from UTXOs + // on the next reconcile sweep, so log and continue. + if let Err(e) = managed.record_dashpay_payment( + txid, + crate::wallet::identity::types::dashpay::payment::PaymentEntry::new_received( + contact, + amount_duffs, + None, + ), + &self.persister, + ) { + tracing::warn!(error = %e, "Failed to persist reconciled payment; will retry next sweep"); + } + recorded += 1; + } + Ok(recorded) + } + + /// Flip `Pending` `Sent` [`PaymentEntry`]s to `Confirmed` when the + /// persisted core transaction record reports the transaction final. + /// + /// Recovery path for sent-payment confirmation. The live confirm path + /// ([`confirm_sent_dashpay_payment`](super::confirm_sent_dashpay_payment)) + /// flips a sent payment the moment its block / InstantSend-lock event + /// arrives, but that is a single live event: if it is missed — a lagged + /// wallet-event broadcast, or a relaunch after the transaction confirmed + /// but before the flip was captured — the entry would otherwise stay + /// `Pending` forever (received payments self-heal from receival-account + /// UTXOs; sent payments have no such ground truth). This sweep consults + /// the persisted core tx record (txid + context) and flips any `Pending` + /// `Sent` entry whose transaction is mined or InstantSend-locked. + /// + /// Runs as a local-only step of `dashpay_sync()` — one persister read + /// per pending sent payment, no network round-trips. Idempotent: a + /// `Confirmed` entry is left alone, and a transaction not yet final is + /// retried on the next sweep. + /// + /// Returns the number of entries confirmed this pass. + pub async fn reconcile_sent_payments(&self) -> Result { + use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; + use key_wallet::transaction_checking::TransactionContext; + + // Snapshot the pending sent (owner, txid) pairs under a read lock so + // the persister reads below don't hold the wallet lock across I/O. + let pending: Vec<(Identifier, String)> = { + let wm = self.wallet_manager.read().await; + let Some(info) = wm.get_wallet_info(&self.wallet_id) else { + return Ok(0); + }; + let mut out = Vec::new(); + for owner in info.identity_manager.identity_ids() { + let Some(managed) = info.identity_manager.managed_identity(&owner) else { + continue; + }; + for (txid, entry) in &managed.dashpay_payments { + if entry.direction == PaymentDirection::Sent + && entry.status == PaymentStatus::Pending + { + out.push((owner, txid.clone())); } } - }); + } + out + }; + + let mut confirmed = 0usize; + for (_owner, txid_str) in pending { + let Ok(txid) = txid_str.parse::() else { + continue; + }; + let record = match self.persister.get_core_tx_record(&txid) { + Ok(Some(record)) => record, + Ok(None) => continue, + Err(e) => { + tracing::warn!( + error = %e, + txid = %txid_str, + "reconcile_sent_payments: tx-record read failed; will retry next sweep" + ); + continue; + } + }; + // An InstantSend lock is final for DashPay display, same as a + // mined block. + let is_final = record.is_confirmed() + || matches!(record.context, TransactionContext::InstantSend(_)); + if !is_final { + continue; + } + // Flip in place via the shared confirm path (re-checks the + // entry is still a `Pending` `Sent` under its own write lock, + // so it stays correct if a live event raced this sweep). + confirm_sent_payment_by_txid( + &self.wallet_manager, + &self.wallet_id, + &self.persister, + &txid_str, + ) + .await; + confirmed += 1; } + Ok(confirmed) + } +} + +/// Record `Received` [`PaymentEntry`]s for a freshly detected Core +/// transaction whose outputs pay DashPay receival-account addresses. +/// +/// Live-detection half of incoming-payment recording: called by the +/// wallet-event adapter +/// ([`spawn_wallet_event_adapter`](crate::changeset::core_bridge::spawn_wallet_event_adapter)) +/// on every `TransactionDetected` event, so a payment from a contact +/// lands in the receiver's payment history the moment SPV sees the +/// transaction. The recurring [`IdentityWallet::reconcile_incoming_payments`] +/// sweep covers anything this misses (relaunch restore, dropped events). +/// +/// Idempotent per txid — re-detections of the same transaction +/// (mempool → in-block → chain-locked) hit the existing-entry guard. +pub(crate) async fn record_incoming_dashpay_payments( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + persister: &crate::wallet::persister::WalletPersister, + record: &key_wallet::managed_account::transaction_record::TransactionRecord, +) { + use key_wallet::managed_account::transaction_record::OutputRole; + use std::collections::BTreeMap; + + // Candidate outputs: received by us, with a decodable address. + // Change outputs can't be DashPay-incoming (they pay back to our + // standard accounts), so only `Received` is considered. + let candidates: Vec<(dashcore::Address, u64)> = record + .output_details + .iter() + .filter(|d| matches!(d.role, OutputRole::Received)) + .filter_map(|d| Some((d.address.clone()?, d.value))) + .collect(); + if candidates.is_empty() { + return; + } + let txid = record.txid.to_string(); + + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return; + }; - Ok(m) + let mut totals: BTreeMap<(Identifier, Identifier), u64> = BTreeMap::new(); + for (address, value) in candidates { + if let Some(m) = IdentityWallet::::match_in_collection( + info, &address, + ) { + *totals + .entry((m.user_identity_id, m.friend_identity_id)) + .or_default() += value; + } + } + + for ((owner, contact), amount_duffs) in totals { + let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { + continue; + }; + if managed.dashpay_payments.contains_key(&txid) { + continue; + } + tracing::info!( + owner = %owner, + contact = %contact, + %txid, + amount_duffs, + "Recording incoming DashPay payment" + ); + // Self-healing: a failed persist of a live-detected Received entry + // is re-derived from UTXOs by the next reconcile sweep. + if let Err(e) = managed.record_dashpay_payment( + txid.clone(), + crate::wallet::identity::types::dashpay::payment::PaymentEntry::new_received( + contact, + amount_duffs, + None, + ), + persister, + ) { + tracing::warn!(error = %e, "Failed to persist live incoming payment; will retry next sweep"); + } + } +} + +/// Advance a sender's `Sent` [`PaymentEntry`] from `Pending` to +/// `Confirmed` once its broadcast transaction reaches finality. +/// +/// [`IdentityWallet::send_payment`] records the outgoing entry as +/// `Pending` at broadcast time and nothing else advances it. The wallet +/// re-emits the sender's own transaction as it moves through mempool → +/// InstantSend → in-block → chain-locked, so when a re-detection reports +/// the transaction final the matching entry is flipped in place. +/// +/// An **InstantSend lock counts as final** for DashPay display: it is +/// effectively irreversible, so the user sees `Confirmed` without waiting +/// for the surrounding block. A bare mempool re-detection (no IS lock, not +/// yet mined) leaves the entry `Pending` — which it genuinely still is. +/// Idempotent: once `Confirmed`, later re-detections find nothing to +/// change and skip the persistence round. +pub(crate) async fn confirm_sent_dashpay_payment( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + persister: &crate::wallet::persister::WalletPersister, + record: &key_wallet::managed_account::transaction_record::TransactionRecord, +) { + use key_wallet::transaction_checking::TransactionContext; + // Mined (InBlock / InChainLockedBlock) OR InstantSend-locked advances + // the entry. A plain mempool sighting does not. + let is_instant_send = matches!(record.context, TransactionContext::InstantSend(_)); + if !record.is_confirmed() && !is_instant_send { + return; + } + confirm_sent_payment_by_txid( + wallet_manager, + wallet_id, + persister, + &record.txid.to_string(), + ) + .await; +} + +/// Confirm a sender's `Sent` [`PaymentEntry`] by txid alone, for a +/// [`WalletEvent::TransactionInstantLocked`](key_wallet_manager::WalletEvent::TransactionInstantLocked) +/// that applies an InstantSend lock to a previously-seen transaction. +/// That event carries no [`TransactionRecord`](key_wallet::managed_account::transaction_record::TransactionRecord), +/// only the txid; an IS lock is treated as final for DashPay display, so +/// this flips a matching `Pending` `Sent` entry to `Confirmed`. Idempotent +/// (the underlying flip skips entries already past `Pending`). +pub(crate) async fn confirm_sent_dashpay_payment_by_txid( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + persister: &crate::wallet::persister::WalletPersister, + txid: &dashcore::Txid, +) { + confirm_sent_payment_by_txid(wallet_manager, wallet_id, persister, &txid.to_string()).await; +} + +/// Flip the `Pending` `Sent` [`PaymentEntry`] under `txid` (if any) to +/// `Confirmed`, in place, preserving amount/memo/counterparty. +/// +/// No-op when no entry exists for `txid`, it is not a `Sent` entry, or it +/// is already past `Pending` (so repeated confirmed re-detections are +/// idempotent and skip the persistence round). Separated from the event +/// glue above so the state transition is unit-testable without +/// constructing a full `TransactionRecord`. +async fn confirm_sent_payment_by_txid( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + persister: &crate::wallet::persister::WalletPersister, + txid: &str, +) { + use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; + + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return; + }; + + // The sent transaction belongs to one managed identity; find the + // `Pending` `Sent` entry under this txid and confirm it in place. + for owner in info.identity_manager.identity_ids() { + let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { + continue; + }; + let confirmed = match managed.dashpay_payments.get(txid) { + Some(entry) + if entry.direction == PaymentDirection::Sent + && entry.status == PaymentStatus::Pending => + { + let mut updated = entry.clone(); + updated.status = PaymentStatus::Confirmed; + updated + } + _ => continue, + }; + tracing::info!(owner = %owner, %txid, "Confirming sent DashPay payment"); + if let Err(e) = managed.record_dashpay_payment(txid.to_string(), confirmed, persister) { + tracing::warn!( + error = %e, + "Failed to persist sent-payment confirmation; will retry on next detection" + ); + } + // txid is unique — only one identity can hold this entry. + break; } } @@ -85,17 +390,22 @@ impl IdentityWallet { /// * `to_contact_id` - The contact's identity. /// * `amount_duffs` - Amount to send in duffs (1 DASH = 1e8 duffs). /// * `memo` - Optional free-text memo to attach to the entry. + /// * `signer` - Keychain-backed [`key_wallet::signer::Signer`] + /// that produces each funding input's ECDSA signature on demand. The + /// wallet seed is never made resident — every signature is derived and + /// wiped inside the signer (mirrors `core_wallet::send_to_addresses`). /// /// # Returns /// /// The `Txid` of the broadcast transaction and the newly created /// [`PaymentEntry`] recording the outgoing payment. - pub async fn send_payment( + pub async fn send_payment( &self, from_identity_id: &Identifier, to_contact_id: &Identifier, amount_duffs: u64, memo: Option, + signer: &S, ) -> Result< ( dashcore::Txid, @@ -195,8 +505,12 @@ impl IdentityWallet { .set_funding(managed_account, account) .add_output(&payment_address, amount_duffs); + // Sign through the injected signer (blanket + // `impl TransactionSigner for S`) rather than the + // resident `wallet`, so funding-input signatures are produced + // from Keychain-derived keys without a resident seed. let (tx, _fee) = builder - .build_signed(wallet, |addr| { + .build_signed(signer, |addr| { managed_account.address_derivation_path(&addr) }) .await @@ -232,11 +546,18 @@ impl IdentityWallet { if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { if let Some(managed) = info.identity_manager.managed_identity_mut(from_identity_id) { - managed.record_dashpay_payment( - txid.to_string(), - entry.clone(), - &self.persister, - ); + // Propagate a persist failure: the tx is already + // broadcast on-chain, but the local Sent entry + memo + // has no on-chain recovery, so a silent drop would lose + // the user's payment record. Surfacing it lets the UI + // report the partial outcome (sent, but not recorded). + managed + .record_dashpay_payment(txid.to_string(), entry.clone(), &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "payment broadcast but not recorded locally: {e}" + )) + })?; } } } @@ -244,3 +565,1606 @@ impl IdentityWallet { Ok((txid, entry)) } } + +#[cfg(test)] +mod tests { + //! Receiver-side payment persistence tests. + //! + //! These pin the three pieces that make incoming DashPay payments + //! survive across app relaunches (without them, a recipient's received + //! payments show "Payments (0)"): + //! + //! 1. `register_contact_account` must PERSIST the account + //! registration, so the `DashpayReceivingFunds` account is + //! rebuilt at next load and its persisted UTXOs route instead + //! of being dropped (`dropped_no_account`). + //! 2. `reconcile_incoming_payments` must derive missing + //! `Received` PaymentEntries from the receival accounts' UTXO + //! sets (recovers history after restore and any missed live + //! events). + //! 3. Reconcile must be idempotent and never clobber an existing + //! entry for the same txid. + + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex}; + + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use dpp::prelude::Identifier; + use key_wallet::account::account_collection::DashpayAccountKey; + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::error::PlatformWalletError; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::persister::WalletPersister; + use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWalletManager; + + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + /// Persister that records every store round so tests can assert on + /// exactly what would reach the host (SwiftData) for a given flow. + #[derive(Default)] + struct RecordingPersister { + stores: Mutex>, + } + + impl PlatformWalletPersistence for RecordingPersister { + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.stores.lock().unwrap().push((wallet_id, changeset)); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + /// Persister that answers `get_core_tx_record` from a configurable + /// in-memory map, so a test can stage the persisted core transaction + /// state the sent-payment reconcile reads. `store`/`flush` are no-ops; + /// `load` returns the default state. + #[derive(Default)] + struct RecordStorePersister { + records: Mutex< + std::collections::HashMap< + dashcore::Txid, + key_wallet::managed_account::transaction_record::TransactionRecord, + >, + >, + } + + impl PlatformWalletPersistence for RecordStorePersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + fn get_core_tx_record( + &self, + _wallet_id: WalletId, + txid: &dashcore::Txid, + ) -> Result< + Option, + PersistenceError, + > { + Ok(self.records.lock().unwrap().get(txid).cloned()) + } + } + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + /// Build a testnet wallet backed by an arbitrary persister `P`, for + /// flows that need a persister beyond [`RecordingPersister`] (e.g. the + /// sent-payment reconcile, which reads `get_core_tx_record`). + async fn make_wallet_with( + persister: Arc

, + ) -> (Arc>, WalletId) { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + handler, + )); + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet creation"); + let wallet_id = wallet.wallet_id(); + // Wallet stays external-signable (no resident seed) — the production + // posture: signing runs through the host signer, never a grafted seed. + // Tests that need private-key ops derive via a Wallet-from-seed helper + // (test_receiving_xpub) or a SeedCryptoProvider from the same mnemonic. + (manager, wallet_id) + } + + async fn make_wallet() -> ( + Arc>, + Arc, + WalletId, + ) { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(RecordingPersister::default()); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + handler, + )); + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet creation"); + let wallet_id = wallet.wallet_id(); + // Wallet stays external-signable (no resident seed) — the production + // posture: signing runs through the host signer, never a grafted seed. + // Tests that need private-key ops derive via a Wallet-from-seed helper + // (test_receiving_xpub) or a SeedCryptoProvider from the same mnemonic. + (manager, persister, wallet_id) + } + + /// Like [`make_wallet`], leaving the wallet external-signable + /// (`has_seed() == false`) — the watch-only / seedless state the + /// unattended sync sweep can hit before a Keychain unlock. + async fn make_watch_only_wallet() -> ( + Arc>, + Arc, + WalletId, + ) { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(RecordingPersister::default()); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + handler, + )); + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet creation"); + let wallet_id = wallet.wallet_id(); + // Intentionally seedless: creation downgrades to external-signable, so + // the wallet has no resident key material. + (manager, persister, wallet_id) + } + + /// The DashPay receiving (friendship) xpub for `(owner, contact)` at account + /// 0, derived from `TEST_MNEMONIC` via a standalone `Wallet` — the same xpub + /// the Keychain signer produces in production. Lets seedless-wallet tests + /// supply `register_contact_account`'s now-mandatory xpub without a resident + /// wallet. + fn test_receiving_xpub( + owner: &Identifier, + contact: &Identifier, + ) -> key_wallet::bip32::ExtendedPubKey { + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid mnemonic") + .to_seed(""); + let wallet = key_wallet::wallet::Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("seed wallet"); + crate::wallet::identity::crypto::dip14::derive_contact_xpub( + &wallet, + Network::Testnet, + 0, + owner, + contact, + ) + .expect("derive receiving xpub") + .xpub + } + + fn bare_identity(id_bytes: [u8; 32]) -> Identity { + Identity::V0(IdentityV0 { + id: Identifier::from(id_bytes), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }) + } + + /// Insert a fake UTXO into the (owner, contact) receival account, + /// paying `value_duffs` to the account's first pool address, and + /// return the txid hex string used. + async fn plant_receival_utxo( + manager: &Arc>, + wallet_id: WalletId, + owner: Identifier, + contact: Identifier, + txid_byte: u8, + value_duffs: u64, + ) -> String { + use dashcore::hashes::Hash; + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet registered"); + let iw = wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + let account = info + .core_wallet + .accounts + .dashpay_receival_accounts + .get_mut(&key) + .expect("receival account registered"); + let address_info = { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + account + .managed_account_type() + .address_pools() + .first() + .expect("receival account has a pool") + .addresses + .values() + .next() + .expect("pool has at least one derived address") + .clone() + }; + let txid = dashcore::Txid::from_slice(&[txid_byte; 32]).expect("txid"); + let outpoint = dashcore::OutPoint { txid, vout: 0 }; + account.utxos.insert( + outpoint, + key_wallet::Utxo { + outpoint, + txout: dashcore::TxOut { + value: value_duffs, + script_pubkey: address_info.script_pubkey.clone(), + }, + address: address_info.address.clone(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + txid.to_string() + } + + /// 1. Registering a contact receival account must persist an + /// `AccountRegistrationEntry` — otherwise the account (and every + /// UTXO routed to it) silently vanishes on the next app launch + /// (`load: ... dropped_no_account`). + #[tokio::test] + async fn register_contact_account_persists_account_registration() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + persister.stores.lock().unwrap().clear(); + + { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register_contact_account"); + } + + { + let stores = persister.stores.lock().unwrap(); + let registered = stores.iter().any(|(_, cs)| { + cs.account_registrations.iter().any(|entry| { + matches!( + entry.account_type, + key_wallet::account::AccountType::DashpayReceivingFunds { + user_identity_id, + friend_identity_id, + .. + } if user_identity_id == owner.to_buffer() + && friend_identity_id == contact.to_buffer() + ) + }) + }); + assert!( + registered, + "register_contact_account must emit an AccountRegistrationEntry \ + so the DashpayReceivingFunds account survives relaunch" + ); + } + + // Re-registering must be a no-op (no duplicate persistence round). + persister.stores.lock().unwrap().clear(); + { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("re-register is a no-op"); + } + let stores = persister.stores.lock().unwrap(); + assert!( + stores + .iter() + .all(|(_, cs)| cs.account_registrations.is_empty()), + "re-registering an existing contact account must not re-persist" + ); + } + + /// 2. Reconcile derives `Received` entries from receival-account + /// UTXOs (restores payment history after relaunch / missed events), + /// and 3. is idempotent across passes. + #[tokio::test] + async fn reconcile_records_received_payments_from_receival_utxos() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + iw.register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register_contact_account"); + // The owner identity must be managed for the entry to land. + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity( + bare_identity([0xAA; 32]), + 0, + wallet_id, + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add managed identity"); + } + + let txid = plant_receival_utxo(&manager, wallet_id, owner, contact, 0x07, 1_000_000).await; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + + let recorded = iw + .reconcile_incoming_payments() + .await + .expect("reconcile pass"); + assert_eq!(recorded, 1, "one missing Received entry must be recorded"); + + { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let managed = info + .identity_manager + .managed_identity(&owner) + .expect("managed identity"); + let entry = managed + .dashpay_payments + .get(&txid) + .expect("Received entry recorded under the UTXO's txid"); + assert_eq!(entry.counterparty_id, contact); + assert_eq!(entry.amount_duffs, 1_000_000); + assert_eq!( + entry.direction, + super::super::super::types::dashpay::payment::PaymentDirection::Received + ); + } + + // Idempotency: a second pass records nothing new. + let recorded_again = iw + .reconcile_incoming_payments() + .await + .expect("second reconcile pass"); + assert_eq!(recorded_again, 0, "reconcile must be idempotent"); + } + + /// 3b. An existing entry under the same txid (e.g. the sender's + /// own `Sent` record when both identities share one wallet) must + /// not be clobbered by reconcile. + #[tokio::test] + async fn reconcile_does_not_clobber_existing_entry_for_same_txid() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + iw.register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register_contact_account"); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity( + bare_identity([0xAA; 32]), + 0, + wallet_id, + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add managed identity"); + } + + let txid = plant_receival_utxo(&manager, wallet_id, owner, contact, 0x09, 500_000).await; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + + // Pre-record an entry under the same txid. + let preexisting = crate::wallet::identity::types::dashpay::payment::PaymentEntry::new_sent( + contact, 123, None, + ); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed identity"); + managed + .record_dashpay_payment( + txid.clone(), + preexisting.clone(), + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("record"); + } + + let recorded = iw + .reconcile_incoming_payments() + .await + .expect("reconcile pass"); + assert_eq!(recorded, 0, "existing txid entry must be left alone"); + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let managed = info + .identity_manager + .managed_identity(&owner) + .expect("managed identity"); + assert_eq!( + managed.dashpay_payments.get(&txid), + Some(&preexisting), + "reconcile must not overwrite the pre-existing entry" + ); + } + + /// Persister that succeeds until `armed`, then fails every store — + /// lets a test build state normally, then prove a later user-initiated + /// write propagates a persist failure instead of swallowing it. + #[derive(Default)] + struct ToggleFailPersister { + armed: std::sync::atomic::AtomicBool, + } + + impl PlatformWalletPersistence for ToggleFailPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if self.armed.load(std::sync::atomic::Ordering::SeqCst) { + Err(PersistenceError::backend("store armed to fail")) + } else { + Ok(()) + } + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + /// **C1 (Critical) — ignore must PROPAGATE a persist failure.** + /// Ignore is local-only (no on-chain artifact), so a swallowed store + /// error would resurface the ignored sender on the next launch with no + /// signal. The user-initiated `ignore` path must return the error + /// instead. + /// + /// The hazard: if `ignore_contact_sender` merely logged the store error + /// and returned `Ok(())`, the ignore would be lost; it must return + /// `Err(Persistence)`. + #[tokio::test] + async fn ignore_propagates_persist_failure() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(ToggleFailPersister::default()); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + handler, + )); + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet creation"); + let wallet_id = wallet.wallet_id(); + + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + // Setup (persister still succeeding): managed owner + an incoming + // request to ignore. + { + let iw = wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + let incoming = + crate::wallet::identity::types::dashpay::contact_request::ContactRequest::new( + contact, + owner, + 1, + 2, + 0, + vec![7u8; 96], + 100, + 0, + ); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .add_incoming_contact_request(incoming, &p); + } + + // Arm the persister to fail, then ignore: must return Err, NOT Ok. + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + let iw = wallet.identity(); + let result = iw.ignore_contact_sender(&owner, &contact).await; + assert!( + matches!(result, Err(PlatformWalletError::Persistence(_))), + "ignore must propagate a persist failure (got {result:?}), \ + else the ignore is lost and the sender resurfaces" + ); + } + + /// A `Sent` payment must advance `Pending → Confirmed` once its + /// transaction confirms on-chain. `send_payment` records it `Pending` + /// and nothing else moved it, so before the confirm path was wired the + /// entry was stuck `Pending` forever (sent payments never showed + /// confirmed). Pins the flip, idempotency on re-detection, and that + /// amount/memo are preserved. + #[tokio::test] + async fn confirm_flips_sent_payment_pending_to_confirmed() { + use crate::wallet::identity::types::dashpay::payment::{ + PaymentDirection, PaymentEntry, PaymentStatus, + }; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let txid = "a".repeat(64); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid.clone(), + PaymentEntry::new_sent(contact, 50_000, Some("dinner".into())), + &p, + ) + .expect("record pending sent"); + } + + // Read the current entry under a short-lived read lock. + async fn read_entry( + iw: &crate::wallet::identity::IdentityWallet, + wallet_id: &WalletId, + owner: &Identifier, + txid: &str, + ) -> PaymentEntry { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(wallet_id).expect("info"); + info.identity_manager + .managed_identity(owner) + .unwrap() + .dashpay_payments + .get(txid) + .cloned() + .expect("entry") + } + + assert_eq!( + read_entry(iw, &wallet_id, &owner, &txid).await.status, + PaymentStatus::Pending, + "precondition: entry starts Pending" + ); + + // A confirmed detection flips it to Confirmed, preserving fields. + super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid).await; + let entry = read_entry(iw, &wallet_id, &owner, &txid).await; + assert_eq!( + entry.status, + PaymentStatus::Confirmed, + "a confirmed tx must flip the Sent entry to Confirmed" + ); + assert_eq!(entry.direction, PaymentDirection::Sent); + assert_eq!(entry.amount_duffs, 50_000); + assert_eq!(entry.memo.as_deref(), Some("dinner"), "memo preserved"); + + // Idempotent: a second confirmed re-detection changes nothing. + super::confirm_sent_payment_by_txid(&iw.wallet_manager, &wallet_id, &p, &txid).await; + assert_eq!( + read_entry(iw, &wallet_id, &owner, &txid).await.status, + PaymentStatus::Confirmed + ); + } + + /// A sent payment confirmed by a block must flip `Pending → Confirmed`. + /// + /// The wallet sees its *own* broadcast in the mempool first + /// (`TransactionDetected`, context `Mempool`), where the confirm hook + /// early-returns because the transaction is not yet confirmed. The + /// transaction reaches a confirmed context only when a block mines it — + /// delivered as [`key_wallet_manager::WalletEvent::BlockProcessed`] with + /// the record in `updated` (a previously-known record that just + /// confirmed). Routing the payment hooks only for `TransactionDetected` + /// would leave the entry `Pending` forever. This drives the real adapter + /// dispatch + /// ([`run_dashpay_payment_hooks`](crate::wallet::identity::network::run_dashpay_payment_hooks)) + /// with a `BlockProcessed` event and pins the flip end-to-end, so a + /// regression that re-narrows the routing to `TransactionDetected` is + /// caught here. Also pins idempotency across a repeated block-processing + /// round and that the `matured` bucket (coinbase maturity) never + /// confirms a payment. + #[tokio::test] + async fn block_processed_confirms_sent_payment() { + use dashcore::blockdata::transaction::Transaction; + use dashcore::hashes::Hash; + use dashcore::{BlockHash, TxIn}; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletEvent; + + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + + // The sent transaction; `tx.txid()` is the payment-entry key, so the + // entry and the confirming record agree on the same display-order + // txid string the confirm path looks up. + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: dashcore::OutPoint::new( + dashcore::Txid::from_byte_array([0x5f; 32]), + 0, + ), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + let txid = tx.txid(); + + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid.to_string(), + PaymentEntry::new_sent(contact, 100_000, Some("lunch".into())), + &p, + ) + .expect("record pending sent"); + } + + // A block confirms the transaction; the wallet already knew it from + // the mempool, so it rides `BlockProcessed.updated`. + let confirmed = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InBlock(BlockInfo::new(1_499_050, BlockHash::all_zeros(), 0)), + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + -100_000, + ); + assert!( + confirmed.is_confirmed(), + "precondition: an InBlock record reports confirmed" + ); + + let event = WalletEvent::BlockProcessed { + wallet_id, + height: 1_499_050, + chain_lock: None, + inserted: Vec::new(), + updated: vec![confirmed], + matured: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + addresses_derived: Vec::new(), + }; + + crate::wallet::identity::network::run_dashpay_payment_hooks( + &iw.wallet_manager, + &wallet_id, + &p, + &event, + ) + .await; + + // Read the entry under a short-lived read lock so the re-fire below + // can take the write lock. + async fn read_status( + iw: &crate::wallet::identity::IdentityWallet, + wallet_id: &WalletId, + owner: &Identifier, + txid: &str, + ) -> PaymentEntry { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(wallet_id).expect("info"); + info.identity_manager + .managed_identity(owner) + .expect("managed") + .dashpay_payments + .get(txid) + .cloned() + .expect("entry present under the sent txid") + } + + let entry = read_status(iw, &wallet_id, &owner, &txid.to_string()).await; + assert_eq!( + entry.status, + PaymentStatus::Confirmed, + "a sent payment confirmed via BlockProcessed must flip Pending → Confirmed" + ); + assert_eq!(entry.memo.as_deref(), Some("lunch"), "memo preserved"); + + // Idempotent: a repeated block-processing round for the same txid + // changes nothing (the confirm path skips entries past `Pending`). + crate::wallet::identity::network::run_dashpay_payment_hooks( + &iw.wallet_manager, + &wallet_id, + &p, + &event, + ) + .await; + assert_eq!( + read_status(iw, &wallet_id, &owner, &txid.to_string()) + .await + .status, + PaymentStatus::Confirmed, + "re-processing the same block must not change a Confirmed entry" + ); + + // A confirmed record arriving only in the `matured` bucket (coinbase + // maturity) must NOT confirm a payment — `matured` is never a DashPay + // payment, so it is excluded from the payment hooks. + let matured_tx = Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: dashcore::OutPoint::new( + dashcore::Txid::from_byte_array([0xC0; 32]), + 0, + ), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + let matured_txid = matured_tx.txid(); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + matured_txid.to_string(), + PaymentEntry::new_sent(contact, 7_000, None), + &p, + ) + .expect("record pending sent"); + } + let matured_record = TransactionRecord::new( + matured_tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 1_499_060, + BlockHash::all_zeros(), + 0, + )), + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + -7_000, + ); + let matured_event = WalletEvent::BlockProcessed { + wallet_id, + height: 1_499_060, + chain_lock: None, + inserted: Vec::new(), + updated: Vec::new(), + matured: vec![matured_record], + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + addresses_derived: Vec::new(), + }; + crate::wallet::identity::network::run_dashpay_payment_hooks( + &iw.wallet_manager, + &wallet_id, + &p, + &matured_event, + ) + .await; + assert_eq!( + read_status(iw, &wallet_id, &owner, &matured_txid.to_string()) + .await + .status, + PaymentStatus::Pending, + "a confirmed record in the `matured` bucket must not confirm a payment" + ); + } + + /// An InstantSend lock applied to a previously-seen sent payment + /// confirms it without waiting for a block. The lock arrives as + /// `WalletEvent::TransactionInstantLocked` (no record, just a txid); an + /// IS lock is final for DashPay display, so the entry flips + /// `Pending → Confirmed`. Drives the real adapter dispatch. + #[tokio::test] + async fn instant_send_lock_confirms_sent_payment() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletEvent; + + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let txid = dashcore::Txid::from([0x5f; 32]); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid.to_string(), + PaymentEntry::new_sent(contact, 50_000, None), + &p, + ) + .expect("record pending sent"); + } + + let event = WalletEvent::TransactionInstantLocked { + wallet_id, + txid, + instant_lock: InstantLock::default(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + crate::wallet::identity::network::run_dashpay_payment_hooks( + &iw.wallet_manager, + &wallet_id, + &p, + &event, + ) + .await; + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let entry = info + .identity_manager + .managed_identity(&owner) + .expect("managed") + .dashpay_payments + .get(&txid.to_string()) + .cloned() + .expect("entry present under the sent txid"); + assert_eq!( + entry.status, + PaymentStatus::Confirmed, + "an InstantSend lock must confirm a sent payment" + ); + } + + /// A transaction first seen *with* an InstantSend lock arrives as a + /// `TransactionDetected` whose record context is `InstantSend`. The + /// confirm gate accepts IS context (not just mined), so it flips the + /// entry `Pending → Confirmed` — a plain mempool sighting would not. + #[tokio::test] + async fn instant_send_context_record_confirms_sent_payment() { + use dashcore::blockdata::transaction::Transaction; + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dashcore::TxIn; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{TransactionContext, TransactionType}; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletEvent; + + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: dashcore::OutPoint::new(dashcore::Txid::from([0x5e; 32]), 0), + ..Default::default() + }], + output: Vec::new(), + special_transaction_payload: None, + }; + let txid = tx.txid(); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .record_dashpay_payment( + txid.to_string(), + PaymentEntry::new_sent(contact, 50_000, None), + &p, + ) + .expect("record pending sent"); + } + + let record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InstantSend(InstantLock::default()), + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + -50_000, + ); + assert!( + !record.is_confirmed(), + "precondition: an InstantSend record is not block-confirmed" + ); + let event = WalletEvent::TransactionDetected { + wallet_id, + record: Box::new(record), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + addresses_derived: Vec::new(), + }; + crate::wallet::identity::network::run_dashpay_payment_hooks( + &iw.wallet_manager, + &wallet_id, + &p, + &event, + ) + .await; + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!( + info.identity_manager + .managed_identity(&owner) + .expect("managed") + .dashpay_payments + .get(&txid.to_string()) + .expect("entry") + .status, + PaymentStatus::Confirmed, + "an InstantSend-context record must confirm a sent payment" + ); + } + + /// `reconcile_sent_payments` recovers a `Pending` `Sent` payment whose + /// live confirm event was missed: it flips the entry to `Confirmed` when + /// the persisted core tx record reports the transaction final (mined or + /// IS-locked), leaves a not-yet-final entry `Pending`, and is idempotent. + #[tokio::test] + async fn reconcile_sent_payments_confirms_from_persisted_record() { + use dashcore::blockdata::transaction::Transaction; + use dashcore::hashes::Hash; + use dashcore::BlockHash; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + + use crate::wallet::identity::types::dashpay::payment::{PaymentEntry, PaymentStatus}; + + // A persisted core tx record carrying only `txid` + `context` (the + // contract `get_core_tx_record` guarantees). + fn tx_record(txid: dashcore::Txid, context: TransactionContext) -> TransactionRecord { + let tx = Transaction { + version: 2, + lock_time: 0, + input: Vec::new(), + output: Vec::new(), + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + context, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + record.txid = txid; + record + } + + let persister = Arc::new(RecordStorePersister::default()); + let (manager, wallet_id) = make_wallet_with(Arc::clone(&persister)).await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + + let mined_txid = dashcore::Txid::from([0x21; 32]); + let mempool_txid = dashcore::Txid::from([0x22; 32]); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + managed + .record_dashpay_payment( + mined_txid.to_string(), + PaymentEntry::new_sent(contact, 1_000, None), + &p, + ) + .expect("record mined-pending"); + managed + .record_dashpay_payment( + mempool_txid.to_string(), + PaymentEntry::new_sent(contact, 2_000, None), + &p, + ) + .expect("record mempool-pending"); + } + { + let mut recs = persister.records.lock().unwrap(); + recs.insert( + mined_txid, + tx_record( + mined_txid, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 100, + BlockHash::all_zeros(), + 0, + )), + ), + ); + recs.insert( + mempool_txid, + tx_record(mempool_txid, TransactionContext::Mempool), + ); + } + + let n = iw.reconcile_sent_payments().await.expect("reconcile"); + assert_eq!(n, 1, "only the mined payment is confirmed this pass"); + + { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let managed = info + .identity_manager + .managed_identity(&owner) + .expect("managed"); + assert_eq!( + managed + .dashpay_payments + .get(&mined_txid.to_string()) + .expect("mined entry") + .status, + PaymentStatus::Confirmed, + "a mined tx record must confirm the sent payment" + ); + assert_eq!( + managed + .dashpay_payments + .get(&mempool_txid.to_string()) + .expect("mempool entry") + .status, + PaymentStatus::Pending, + "a not-yet-final tx must leave the sent payment Pending" + ); + } + + // Idempotent: a second pass confirms nothing new (the mined entry + // is already Confirmed, the mempool one is still not final). + assert_eq!( + iw.reconcile_sent_payments().await.expect("second pass"), + 0, + "reconcile must be idempotent" + ); + } + + /// The seedless drain path: `register_external_contact_account` with a + /// **precomputed** ECDH shared secret (the Keychain signer computed it; the + /// scalar never entered this crate) decrypts the contact's xpub and builds + /// the `DashpayExternalAccount` — same result as the resident path. Pins the + /// reuse that lets the deferred-crypto drain complete an external-account + /// build once a signer is available. The contact identity is `bare` here, + /// proving the `Some` path skips the peer-key derivation entirely. + #[tokio::test] + async fn register_external_with_precomputed_shared_key_builds_account() { + let (manager, persister, wallet_id) = make_wallet().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + + let owner_id = Identifier::from([0x11; 32]); + let contact_id = Identifier::from([0x22; 32]); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity( + bare_identity([0x11; 32]), + 0, + wallet_id, + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add owner"); + } + + // A real 69-byte compact xpub encrypted under a known shared key — the + // wire shape a contact would have sent us. + let shared_key = [0x55u8; 32]; + let iv = [0x11u8; 16]; + let compact = { + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("mnemonic") + .to_seed(""); + let w = key_wallet::wallet::Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("seed wallet"); + crate::wallet::identity::crypto::dip14::derive_contact_xpub( + &w, + Network::Testnet, + 0, + &owner_id, + &contact_id, + ) + .expect("derive a valid compact xpub") + .compact + .to_bytes() + }; + let encrypted = + platform_encryption::encrypt_extended_public_key(&shared_key, &iv, &compact); + + // Bare contact identity: the `Some` path must NOT touch the contact's + // encryption key (the signer derives the secret out-of-crate). + let contact = bare_identity([0x22; 32]); + iw.register_external_contact_account(&owner_id, &contact, &encrypted, shared_key) + .await + .expect("register external with a signer-derived shared key"); + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + use key_wallet::account::account_collection::DashpayAccountKey; + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }; + assert!( + info.core_wallet + .accounts + .dashpay_external_accounts + .contains_key(&key), + "the precomputed-shared-key path must build the external account (the drain's path)" + ); + } + + /// The seedless drain's RegisterReceiving path: `register_contact_account` + /// with a **precomputed** receiving xpub (the Keychain signer derived our + /// friendship key) builds the `DashpayReceivingFunds` account without + /// touching the wallet seed. Pins the reuse the drain needs when the + /// receiving account was never persisted (restore / first-time edge). + #[tokio::test] + async fn register_contact_account_with_precomputed_xpub_builds_account() { + let (manager, _persister, wallet_id) = make_wallet().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + + let owner = Identifier::from([0x11; 32]); + let contact = Identifier::from([0x22; 32]); + + // A valid ExtendedPubKey to supply as the signer would. + let supplied_xpub = test_receiving_xpub(&owner, &contact); + + iw.register_contact_account(&owner, &contact, 0, supplied_xpub) + .await + .expect("register receiving account with a precomputed xpub"); + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + use key_wallet::account::account_collection::DashpayAccountKey; + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + assert!( + info.core_wallet + .accounts + .dashpay_receival_accounts + .contains_key(&key), + "the precomputed-xpub path must build the receiving account (the drain's RegisterReceiving)" + ); + } + + /// End-to-end drain of a `RegisterReceiving` entry on a SEEDLESS wallet: the + /// `SeedCryptoProvider` (the faithful test stand-in for the Keychain signer) + /// supplies the receiving xpub, the drain builds the receiving account with + /// that EXACT signer-derived xpub, and the entry is cleared from the queue. + /// Pins that a wallet with no resident seed becomes payable purely through + /// the signer-backed drain. + #[tokio::test] + async fn drain_completes_register_receiving_and_clears_queue() { + use crate::changeset::{PendingContactCrypto, PendingContactCryptoOp}; + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + + let (manager, _persister, wallet_id) = make_watch_only_wallet().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + + let owner = Identifier::from([0x11; 32]); + let contact = Identifier::from([0x22; 32]); + + // The signer's seed (the faithful test stand-in derives from it). + let seed = { + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + mnemonic.to_seed("") + }; + + // Enqueue a RegisterReceiving op (as the seedless sweep would). + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.pending_contact_crypto.push(PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: 0, + }); + } + + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let drained = iw.drain_pending_contact_crypto(&provider).await; + assert_eq!(drained, 1, "the RegisterReceiving entry must be drained"); + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert!( + info.pending_contact_crypto.is_empty(), + "the queue must be cleared after a successful drain" + ); + use key_wallet::account::account_collection::DashpayAccountKey; + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + assert!( + info.core_wallet + .accounts + .dashpay_receival_accounts + .contains_key(&key), + "the seedless drain must build the receiving account via the signer provider" + ); + } + + /// A `RegisterExternal` entry the drain cannot complete (here: the owner + /// isn't wallet-owned, so no HD index → it bails before any network fetch) + /// must be **left queued**, never dropped or crashed — so a later drain can + /// retry. Pins the deferral safety of the external op without needing a + /// configured mock fetch. + #[tokio::test] + async fn drain_leaves_register_external_it_cannot_complete() { + use crate::changeset::{PendingContactCrypto, PendingContactCryptoOp}; + use crate::wallet::identity::network::contact_requests::ContactCryptoProvider; + + let (manager, _persister, wallet_id) = make_wallet().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + let owner = Identifier::from([0x11; 32]); + let contact = Identifier::from([0x22; 32]); + + // Owner is NOT added as a managed identity → identity_index lookup + // fails → the drain leaves the entry before reaching the fetch. + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.pending_contact_crypto.push(PendingContactCrypto { + owner_identity_id: owner, + contact_id: contact, + op: PendingContactCryptoOp::RegisterExternal { + encrypted_public_key: vec![7u8; 96], + our_decryption_key_index: 0, + contact_encryption_key_index: 0, + }, + enqueued_at_ms: 0, + }); + } + + struct UnusedProvider; + #[async_trait::async_trait] + impl ContactCryptoProvider for UnusedProvider { + async fn receiving_xpub( + &self, + _path: &key_wallet::bip32::DerivationPath, + ) -> Result + { + Err(crate::error::PlatformWalletError::InvalidIdentityData( + "unused in this test".to_string(), + )) + } + async fn ecdh_shared_secret( + &self, + _path: &key_wallet::bip32::DerivationPath, + _peer: &dashcore::secp256k1::PublicKey, + ) -> Result<[u8; 32], crate::error::PlatformWalletError> { + Ok([0u8; 32]) + } + async fn export_auto_accept_private_key( + &self, + _path: &key_wallet::bip32::DerivationPath, + ) -> Result { + unimplemented!("auto-accept QR is a send-path method, not exercised by the drain") + } + async fn account_reference( + &self, + _path: &key_wallet::bip32::DerivationPath, + _compact_xpub: &[u8], + _account_index: u32, + _version: u32, + ) -> Result { + unimplemented!("accountReference is a send-path method, not exercised by the drain") + } + async fn unmask_account_reference( + &self, + _path: &key_wallet::bip32::DerivationPath, + _compact_xpub: &[u8], + _account_reference: u32, + ) -> Result<(u32, u32), crate::error::PlatformWalletError> { + unimplemented!("accountReference is a send-path method, not exercised by the drain") + } + async fn contact_info_seal( + &self, + _root_path: &key_wallet::bip32::DerivationPath, + _derivation_index: u32, + _contact_id: &[u8; 32], + _private_data_plaintext: &[u8], + _private_data_iv: &[u8; 16], + ) -> Result< + crate::wallet::identity::network::ContactInfoSealed, + crate::error::PlatformWalletError, + > { + unimplemented!("contactInfo is not exercised by this drain test") + } + async fn contact_info_open( + &self, + _root_path: &key_wallet::bip32::DerivationPath, + _derivation_index: u32, + _enc_to_user_id: &[u8; 32], + _private_data_blob: &[u8], + ) -> Result< + crate::wallet::identity::network::ContactInfoOpened, + crate::error::PlatformWalletError, + > { + unimplemented!("contactInfo is not exercised by this drain test") + } + } + + let drained = iw.drain_pending_contact_crypto(&UnusedProvider).await; + assert_eq!( + drained, 0, + "an un-completable RegisterExternal entry must not be counted as drained" + ); + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!( + info.pending_contact_crypto.len(), + 1, + "the deferred entry must remain in the queue for a later drain" + ); + } + + /// Confused-deputy guard (security MUST-FIX): the `ContactInfoDecrypt` drain + /// refuses an identity this wallet does not own — it errors at the ownership + /// check BEFORE any fetch/decrypt, so a poisoned/mis-attributed queue entry + /// can never drive a decrypt under the wrong identity. + #[tokio::test] + async fn contact_info_decrypt_drain_rejects_non_owned_identity() { + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + let (manager, _persister, wallet_id) = make_watch_only_wallet().await; + let iw = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = iw.identity(); + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("mnemonic") + .to_seed(""); + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let not_ours = Identifier::from([0x99; 32]); + let err = iw + .drain_contact_info_decrypt(¬_ours, &provider) + .await + .expect_err("a non-owned identity must be rejected"); + assert!( + matches!(err, PlatformWalletError::InvalidIdentityData(_)), + "confused-deputy guard must reject a non-owned identity, got {err:?}" + ); + } + + /// The signerless sweep on a seedless wallet ENQUEUES a per-owner + /// `ContactInfoDecrypt` op (no inline decrypt, no network) for the drain to + /// complete when a signer is available. + #[tokio::test] + async fn seedless_sweep_enqueues_contact_info_decrypt() { + use crate::changeset::PendingContactCryptoKind; + let (manager, persister, wallet_id) = make_watch_only_wallet().await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let owner = Identifier::from([0x11; 32]); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity( + bare_identity([0x11; 32]), + 0, + wallet_id, + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add owner"); + } + let applied = iw.sync_contact_infos().await.expect("sync"); + assert_eq!(applied, 0, "seedless sweep applies nothing inline — it defers"); + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert!( + info.pending_contact_crypto.iter().any(|e| { + e.owner_identity_id == owner + && e.op.kind() == PendingContactCryptoKind::ContactInfoDecrypt + }), + "the seedless sweep must enqueue a ContactInfoDecrypt op for the owner" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index f805aa7dd01..b2433944c9c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -2,8 +2,6 @@ use std::sync::Arc; -use async_trait::async_trait; -use dpp::address_funds::AddressWitness; use dpp::document::DocumentV0Getters; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::Purpose; @@ -11,46 +9,13 @@ use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; use dpp::identity::KeyType; use dpp::identity::SecurityLevel; -use dpp::platform_value::BinaryData; use dpp::platform_value::Value; use dpp::prelude::Identifier; -use dpp::ProtocolError; use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; - -// Borrowed-signer adapter — see `dpns.rs` for the pattern. -struct SignerRef<'a, S: ?Sized>(&'a S); - -impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("SignerRef") - } -} - -#[async_trait] -impl<'a, K, S> Signer for SignerRef<'a, S> -where - K: Send + Sync, - S: Signer + ?Sized + Send + Sync, -{ - async fn sign(&self, key: &K, data: &[u8]) -> Result { - self.0.sign(key, data).await - } - - async fn sign_create_witness( - &self, - key: &K, - data: &[u8], - ) -> Result { - self.0.sign_create_witness(key, data).await - } - - fn can_sign_with(&self, key: &K) -> bool { - self.0.can_sign_with(key) - } -} +use crate::wallet::identity::{ContactProfileEntry, DashPayProfile}; // --------------------------------------------------------------------------- // Sync profiles @@ -79,18 +44,9 @@ impl IdentityWallet { return Ok(0); } - // 2. Load the DashPay contract locally (no network round-trip needed). - let dashpay_contract = Arc::new( - dpp::system_data_contracts::load_system_data_contract( - dpp::data_contracts::SystemDataContract::Dashpay, - dpp::version::PlatformVersion::latest(), - ) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to load DashPay contract: {e}" - )) - })?, - ); + // 2. The DashPay contract (process-wide cache — no + // per-call re-parse, no network round-trip). + let dashpay_contract = super::dashpay_contract()?; let mut profiles_synced = 0u32; @@ -180,42 +136,7 @@ impl IdentityWallet { _ => return Ok(None), }; - let props = doc.properties(); - - let display_name = props - .get("displayName") - .and_then(|v: &Value| v.as_str().map(ToString::to_string)) - .filter(|s| !s.is_empty()); - - let public_message = props - .get("publicMessage") - .and_then(|v: &Value| v.as_str().map(ToString::to_string)) - .filter(|s| !s.is_empty()); - - let avatar_url = props - .get("avatarUrl") - .and_then(|v: &Value| v.as_str().map(ToString::to_string)) - .filter(|s| !s.is_empty()); - - let avatar_hash = props - .get("avatarHash") - .and_then(|v: &Value| v.as_bytes()) - .and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()); - - let avatar_fingerprint = props - .get("avatarFingerprint") - .and_then(|v: &Value| v.as_bytes()) - .and_then(|bytes| <[u8; 8]>::try_from(bytes.as_slice()).ok()); - - Ok(Some(crate::wallet::identity::DashPayProfile { - display_name, - // `publicMessage` from the contract is the bio/about-me field. - bio: public_message.clone(), - avatar_url, - avatar_hash, - avatar_fingerprint, - public_message, - })) + Ok(Some(profile_from_properties(doc.properties()))) } } @@ -246,23 +167,12 @@ impl IdentityWallet { where S: Signer + Send + Sync, { - use dash_sdk::platform::transition::put_document::PutDocument; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::document::Document; use dpp::document::DocumentV0; - // 1. Load the DashPay data contract. - let dashpay_contract = Arc::new( - dpp::system_data_contracts::load_system_data_contract( - dpp::data_contracts::SystemDataContract::Dashpay, - dpp::version::PlatformVersion::latest(), - ) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to load DashPay contract: {e}" - )) - })?, - ); + // 1. The DashPay data contract (process-wide cache). + let dashpay_contract = super::dashpay_contract()?; // 2. Compute avatar hashes when raw bytes are provided. let (avatar_hash, avatar_fingerprint) = if let Some(ref bytes) = input.avatar_bytes { @@ -353,18 +263,15 @@ impl IdentityWallet { })? .to_owned_document_type(); - let _result_doc = stub_document - .put_to_platform_and_wait_for_response( - &self.sdk, - profile_document_type, - None, - signing_key, - None, - &SignerRef(signer), - None, - ) - .await - .map_err(PlatformWalletError::Sdk)?; + let _result_doc = self + .sdk_writer + .put_document(super::sdk_writer::PutDocumentParams { + document: stub_document, + document_type: profile_document_type, + signing_public_key: signing_key, + signer: signer as &(dyn Signer + Send + Sync), + }) + .await?; let profile = crate::wallet::identity::DashPayProfile { display_name: input.display_name, @@ -401,27 +308,17 @@ impl IdentityWallet { where S: Signer + Send + Sync, { - use dash_sdk::platform::transition::put_document::PutDocument; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::document::Document; use dpp::document::DocumentV0; use dpp::document::INITIAL_REVISION; - // 1. Load the DashPay contract. - let dashpay_contract = Arc::new( - dpp::system_data_contracts::load_system_data_contract( - dpp::data_contracts::SystemDataContract::Dashpay, - dpp::version::PlatformVersion::latest(), - ) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to load DashPay contract: {e}" - )) - })?, - ); + // 1. The DashPay contract (process-wide cache). + let dashpay_contract = super::dashpay_contract()?; - // 2. Fetch existing profile document for ID + revision. - let (existing_doc_id, current_revision) = { + // 2. Fetch existing profile document for ID + revision + its + // current property map (seed for the read-modify-write merge). + let (existing_doc_id, current_revision, existing_properties) = { use dash_sdk::drive::query::WhereClause; use dash_sdk::drive::query::WhereOperator; use dash_sdk::platform::FetchMany; @@ -451,7 +348,7 @@ impl IdentityWallet { Some(Some(doc)) => { let id = doc.id(); let rev = doc.revision().unwrap_or(INITIAL_REVISION); - (id, rev) + (id, rev, doc.properties().clone()) } _ => { return Err(PlatformWalletError::InvalidIdentityData( @@ -461,41 +358,28 @@ impl IdentityWallet { } }; - // 3. Compute avatar hashes when bytes are provided. + // 3. Compute avatar hashes only when new bytes are provided. + // Without new bytes the existing avatar fields are retained by + // the read-modify-write merge below (seeded from the on-platform + // document), so no local-cache fallback is needed. let (avatar_hash, avatar_fingerprint) = if let Some(ref bytes) = input.avatar_bytes { let hash = crate::wallet::identity::calculate_avatar_hash(bytes); let fingerprint = crate::wallet::identity::calculate_dhash_fingerprint(bytes) .map_err(PlatformWalletError::InvalidIdentityData)?; (Some(hash), Some(fingerprint)) } else { - // Preserve existing avatar fields from the local cache. - let wm = self.wallet_manager.read().await; - let (h, f) = wm - .get_wallet_info(&self.wallet_id) - .and_then(|info| info.identity_manager.managed_identity(identity_id)) - .and_then(|m| m.dashpay_profile.as_ref()) - .map(|p| (p.avatar_hash, p.avatar_fingerprint)) - .unwrap_or((None, None)); - (h, f) + (None, None) }; - // 4. Build property map. - let mut properties = std::collections::BTreeMap::new(); - if let Some(ref name) = input.display_name { - properties.insert("displayName".to_string(), Value::Text(name.clone())); - } - if let Some(ref msg) = input.public_message { - properties.insert("publicMessage".to_string(), Value::Text(msg.clone())); - } - if let Some(ref url) = input.avatar_url { - properties.insert("avatarUrl".to_string(), Value::Text(url.clone())); - } - if let Some(hash) = avatar_hash { - properties.insert("avatarHash".to_string(), Value::Bytes32(hash)); - } - if let Some(fp) = avatar_fingerprint { - properties.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); - } + // 4. Read-modify-write: seed from the existing document's + // properties so a partial update preserves sibling fields, + // then overlay only the caller-provided fields. + let properties = + merge_profile_properties(existing_properties, &input, avatar_hash, avatar_fingerprint); + + // The returned profile overwrites the local cache, so it must + // reflect the merged on-platform state, not the partial input. + let returned_profile = profile_from_properties(&properties); // 5. Look up signing key. let signing_key = { @@ -557,27 +441,17 @@ impl IdentityWallet { })? .to_owned_document_type(); - let _result_doc = updated_document - .put_to_platform_and_wait_for_response( - &self.sdk, - profile_document_type, - None, - signing_key, - None, - &SignerRef(signer), - None, - ) - .await - .map_err(PlatformWalletError::Sdk)?; + let _result_doc = self + .sdk_writer + .put_document(super::sdk_writer::PutDocumentParams { + document: updated_document, + document_type: profile_document_type, + signing_public_key: signing_key, + signer: signer as &(dyn Signer + Send + Sync), + }) + .await?; - let profile = crate::wallet::identity::DashPayProfile { - display_name: input.display_name, - bio: input.public_message.clone(), - avatar_url: input.avatar_url, - avatar_hash, - avatar_fingerprint, - public_message: input.public_message, - }; + let profile = returned_profile; { let mut wm = self.wallet_manager.write().await; @@ -591,3 +465,577 @@ impl IdentityWallet { Ok(profile) } } + +// --------------------------------------------------------------------------- +// Property-map helpers (read-modify-write merge + parse) +// --------------------------------------------------------------------------- + +/// Read-modify-write merge of a [`ProfileUpdate`] onto the property map of +/// the existing on-platform profile document. +/// +/// A profile update is **partial**: only the fields the caller set change. +/// Seeding from `existing` and overlaying just the provided fields +/// preserves sibling fields (publicMessage, avatarUrl, …) that a +/// fresh-map build would silently drop — the on-platform data loss this +/// guards against. Avatar hash/fingerprint are overlaid only when the +/// caller supplied new avatar bytes (`avatar_hash`/`avatar_fingerprint` +/// are `Some`); otherwise the existing avatar fields are retained. +fn merge_profile_properties( + mut existing: std::collections::BTreeMap, + input: &crate::wallet::identity::ProfileUpdate, + avatar_hash: Option<[u8; 32]>, + avatar_fingerprint: Option<[u8; 8]>, +) -> std::collections::BTreeMap { + if let Some(name) = &input.display_name { + existing.insert("displayName".to_string(), Value::Text(name.clone())); + } + if let Some(msg) = &input.public_message { + existing.insert("publicMessage".to_string(), Value::Text(msg.clone())); + } + if let Some(url) = &input.avatar_url { + existing.insert("avatarUrl".to_string(), Value::Text(url.clone())); + } + if let Some(hash) = avatar_hash { + existing.insert("avatarHash".to_string(), Value::Bytes32(hash)); + } + if let Some(fp) = avatar_fingerprint { + existing.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); + } + existing +} + +/// Parse a profile document's property map into a [`DashPayProfile`]. +/// Empty strings are normalized to `None`. `avatarHash`/`avatarFingerprint` +/// are read via `as_bytes_slice` so both `Bytes` and the sized `Bytes32` +/// representation round-trip. +fn profile_from_properties( + props: &std::collections::BTreeMap, +) -> crate::wallet::identity::DashPayProfile { + let text = |key: &str| { + props + .get(key) + .and_then(|v: &Value| v.as_str().map(ToString::to_string)) + .filter(|s| !s.is_empty()) + }; + // `publicMessage` from the contract is the bio/about-me field. + let public_message = text("publicMessage"); + let avatar_hash = props + .get("avatarHash") + .and_then(|v: &Value| v.as_bytes_slice().ok()) + .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok()); + let avatar_fingerprint = props + .get("avatarFingerprint") + .and_then(|v: &Value| v.as_bytes_slice().ok()) + .and_then(|bytes| <[u8; 8]>::try_from(bytes).ok()); + + crate::wallet::identity::DashPayProfile { + display_name: text("displayName"), + bio: public_message.clone(), + avatar_url: text("avatarUrl"), + avatar_hash, + avatar_fingerprint, + public_message, + } +} + +// --------------------------------------------------------------------------- +// Contact-profile sync +// --------------------------------------------------------------------------- + +/// Max length of a DashPay `avatarUrl` (DIP-15). Longer is rejected. +const MAX_AVATAR_URL_LEN: usize = 2048; +/// Platform `In`-clause cardinality cap; also the profile-fetch chunk size. +const CONTACT_PROFILE_IN_CAP: usize = 100; +/// Re-fetch / re-check window for a cached contact profile. A present profile +/// is refreshed and a confirmed-absent one re-checked at most once per window, +/// bounding sync cost without the (unprovable-as-a-batch) `$updatedAt` +/// incremental query. +const CONTACT_PROFILE_REFRESH_MS: u64 = 60 * 60_000; + +/// Current UNIX time in ms. Used only to rate-limit re-fetches (gates cost, +/// never correctness), so a clock anomaly is harmless. +fn unix_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// An `avatarUrl` is cached only if it is a bounded `https://` URL. An +/// attacker-controlled `http:` / `file:` / `javascript:` / oversized URL is +/// dropped before it can reach the persistent cache and the UI's image loader +/// (SSRF / tracking-pixel vector). +fn is_valid_avatar_url(url: &str) -> bool { + !url.is_empty() && url.len() <= MAX_AVATAR_URL_LEN && url.starts_with("https://") +} + +/// Whether a contact id should be (re)fetched this sweep: never-checked ids +/// always, otherwise only past the refresh window. The window applies equally +/// to present and confirmed-absent entries — for the latter it is the negative +/// cache that stops a profile-less contact being re-queried every sweep. +fn should_fetch_profile(entry: Option<&ContactProfileEntry>, now_ms: u64) -> bool { + match entry { + None => true, + Some(e) => now_ms.saturating_sub(e.checked_at_ms) >= CONTACT_PROFILE_REFRESH_MS, + } +} + +/// Apply a freshly-fetched profile (`Some`) or confirmed-absent result +/// (`None`) to the cache with **full-replace** semantics (NOT a field merge — +/// a contact who removed a field must lose it), returning whether the stored +/// profile changed so the caller persists only on change. `checked_at_ms` is +/// always refreshed; a pure timestamp bump is not a change. +fn apply_fetched_profile( + cache: &mut std::collections::BTreeMap, + contact_id: Identifier, + fetched: Option, + now_ms: u64, +) -> bool { + let changed = cache.get(&contact_id).map(|e| &e.profile) != Some(&fetched); + cache.insert( + contact_id, + ContactProfileEntry { + profile: fetched, + checked_at_ms: now_ms, + }, + ); + changed +} + +impl IdentityWallet { + /// Fetch and cache **contact** profiles — established contacts + pending + /// incoming-request senders — so the UI can show their name/avatar. + /// + /// Mirrors Android's + /// `updateContactProfiles`: iterate the full contact set every sweep + /// (so a contact established before this shipped is backfilled, and a + /// dropped fetch self-heals next sweep), skip recently-checked ids, + /// fetch in `In`-chunks with per-chunk failure isolation, and write the + /// per-owner cache with full-replace + persist-on-change. Contacts that + /// are themselves managed identities are skipped (their own + /// `dashpay_profile` is authoritative). Display-only: a failure never + /// aborts the sweep. Returns the number of cache entries changed. + pub async fn sync_contact_profiles(&self) -> Result { + let now_ms = unix_now_ms(); + let dashpay_contract = super::dashpay_contract()?; + + // 1. Under a read guard: per owner, the contact ids worth fetching + // this sweep (established ∪ pending senders, minus own identities, + // minus recently-checked). + let plan: Vec<(Identifier, Vec)> = { + let wm = self.wallet_manager.read().await; + let Some(info) = wm.get_wallet_info(&self.wallet_id) else { + return Ok(0); + }; + let own: std::collections::BTreeSet = info + .identity_manager + .all_identities() + .into_iter() + .map(|i| i.id()) + .collect(); + + own.iter() + .filter_map(|owner_id| { + let managed = info.identity_manager.managed_identity(owner_id)?; + let mut targets: std::collections::BTreeSet = + managed.established_contacts.keys().copied().collect(); + targets.extend(managed.incoming_contact_requests.keys().copied()); + let to_fetch: Vec = targets + .into_iter() + .filter(|id| !own.contains(id)) + .filter(|id| should_fetch_profile(managed.contact_profiles.get(id), now_ms)) + .collect(); + (!to_fetch.is_empty()).then_some((*owner_id, to_fetch)) + }) + .collect() + }; + + if plan.is_empty() { + return Ok(0); + } + + // 2. Fetch (no guard held). Per chunk: one `In` query over ≤IN_CAP + // owner ids; a chunk failure logs and continues so the others + // still land. An id present in the chunk but absent from the + // result is confirmed-absent (cached as `None` — the negative + // cache). + // One owner's fetched contacts: each contact id paired with its profile, or + // `None` when confirmed-absent (the negative cache). + type OwnerContactProfiles = Vec<(Identifier, Option)>; + let mut results: Vec<(Identifier, OwnerContactProfiles)> = Vec::new(); + for (owner_id, to_fetch) in plan { + let mut owner_results: OwnerContactProfiles = Vec::new(); + for chunk in to_fetch.chunks(CONTACT_PROFILE_IN_CAP) { + match self + .fetch_contact_profiles_chunk(&dashpay_contract, chunk) + .await + { + Ok(found) => { + for id in chunk { + owner_results.push((*id, found.get(id).cloned().flatten())); + } + } + Err(e) => { + tracing::warn!( + owner = %owner_id, + error = %e, + "Failed to fetch a contact-profile chunk; will retry next sweep" + ); + } + } + } + if !owner_results.is_empty() { + results.push((owner_id, owner_results)); + } + } + + // 3. Under the write guard: full-replace, persist-on-change. + let mut written = 0u32; + { + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return Ok(0); + }; + for (owner_id, owner_results) in results { + let Some(managed) = info.identity_manager.managed_identity_mut(&owner_id) else { + continue; + }; + let mut any_changed = false; + for (contact_id, profile) in owner_results { + if apply_fetched_profile( + &mut managed.contact_profiles, + contact_id, + profile, + now_ms, + ) { + written += 1; + any_changed = true; + } + } + // Persist one changeset per owner, only when something changed — + // the refetch-all-each-sweep first cut stays a persistence + // fixpoint. A failed store self-heals on the next sweep. + if any_changed { + if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { + tracing::warn!( + owner = %owner_id, + error = %e, + "Failed to persist contact profiles; will retry next sweep" + ); + } + } + } + } + + Ok(written) + } + + /// Run one `$ownerId In [chunk]` profile query, returning the present + /// profiles keyed by owner id (absent ids are simply missing). The + /// `profile` `ownerId` index is unique, so the set lookup needs no + /// pagination (≤1 profile per owner). The query is built by + /// [`contact_profiles_chunk_query`]. + async fn fetch_contact_profiles_chunk( + &self, + dashpay_contract: &Arc, + chunk: &[Identifier], + ) -> Result>, PlatformWalletError> + { + use dash_sdk::platform::FetchMany; + use dpp::document::Document; + + if chunk.is_empty() { + return Ok(Default::default()); + } + let query = contact_profiles_chunk_query(dashpay_contract, chunk); + + let docs = Document::fetch_many(&self.sdk, query) + .await + .map_err(PlatformWalletError::Sdk)?; + + let mut out = std::collections::BTreeMap::new(); + for (_doc_id, maybe_doc) in docs { + let Some(doc) = maybe_doc else { continue }; + let owner = doc.owner_id(); + let mut profile = profile_from_properties(doc.properties()); + // Drop an untrusted avatar URL rather than caching it. + if profile + .avatar_url + .as_deref() + .is_some_and(|u| !is_valid_avatar_url(u)) + { + profile.avatar_url = None; + } + // A doc that parses to no populated field is treated as + // confirmed-absent (negative cache), not a cached-present empty + // profile — so self-heal keeps it honest. + let entry = (profile != DashPayProfile::default()).then_some(profile); + out.insert(owner, entry); + } + Ok(out) + } +} + +/// Build the `$ownerId In [chunk]` profile fetch query for one chunk. +/// +/// `In` is a range operator, so DAPI requires a matching `orderBy` on the +/// range field or it rejects the query with "missing order by for range". +/// The `$ownerId` index is unique, so ordering does not change the result +/// set (≤1 profile per owner) — the `orderBy` is only there to satisfy that +/// range-orderBy rule. +fn contact_profiles_chunk_query( + dashpay_contract: &Arc, + chunk: &[Identifier], +) -> dash_sdk::platform::DocumentQuery { + use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; + use dpp::platform_value::{platform_value, Value}; + + let in_values = Value::Array(chunk.iter().map(|id| platform_value!(id)).collect()); + dash_sdk::platform::DocumentQuery { + select: dash_sdk::drive::query::SelectProjection::documents(), + data_contract: Arc::clone(dashpay_contract), + document_type_name: "profile".to_string(), + where_clauses: vec![WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::In, + value: in_values, + }], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$ownerId".to_string(), + ascending: true, + }], + limit: CONTACT_PROFILE_IN_CAP as u32, + start: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet::identity::ProfileUpdate; + use std::collections::BTreeMap; + + fn existing_full() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert("displayName".to_string(), Value::Text("Alice".into())); + m.insert( + "publicMessage".to_string(), + Value::Text("hello world".into()), + ); + m.insert( + "avatarUrl".to_string(), + Value::Text("https://x/a.png".into()), + ); + m.insert("avatarHash".to_string(), Value::Bytes32([7u8; 32])); + m.insert( + "avatarFingerprint".to_string(), + Value::Bytes(vec![1, 2, 3, 4, 5, 6, 7, 8]), + ); + m + } + + /// A partial update (only `displayName`) must NOT wipe sibling fields. + /// Contrasts the fixed read-modify-write (seed from existing) against + /// the old fresh-map build (empty seed), which dropped them — so the + /// test would fail against the pre-fix behavior. + #[test] + fn partial_update_preserves_sibling_fields() { + let input = ProfileUpdate { + display_name: Some("Alice 2".to_string()), + ..Default::default() + }; + + // Fixed: seed from the existing on-platform properties. + let merged = merge_profile_properties(existing_full(), &input, None, None); + assert_eq!( + merged.get("displayName").and_then(|v| v.as_str()), + Some("Alice 2") + ); + assert_eq!( + merged.get("publicMessage").and_then(|v| v.as_str()), + Some("hello world"), + ); + assert_eq!( + merged.get("avatarUrl").and_then(|v| v.as_str()), + Some("https://x/a.png"), + ); + assert!(merged.contains_key("avatarHash")); + assert!(merged.contains_key("avatarFingerprint")); + + // The old behavior — building a fresh/empty map — is exactly what + // caused the data loss: the same overlay drops every field the + // caller didn't set. + let buggy = merge_profile_properties(BTreeMap::new(), &input, None, None); + assert!( + !buggy.contains_key("publicMessage"), + "regression guard: a fresh/empty seed wipes sibling fields" + ); + assert!(!buggy.contains_key("avatarUrl")); + } + + /// Avatar fields are overlaid only when new bytes are supplied; + /// otherwise the existing avatar is retained through the merge. + #[test] + fn avatar_overlaid_only_when_new_bytes_present() { + let input = ProfileUpdate { + display_name: Some("x".into()), + ..Default::default() + }; + + // No new avatar bytes => existing avatar retained. + let merged = merge_profile_properties(existing_full(), &input, None, None); + let prof = profile_from_properties(&merged); + assert_eq!(prof.avatar_hash, Some([7u8; 32])); + assert_eq!(prof.avatar_fingerprint, Some([1, 2, 3, 4, 5, 6, 7, 8])); + + // New avatar bytes => overlaid. + let merged2 = + merge_profile_properties(existing_full(), &input, Some([9u8; 32]), Some([9u8; 8])); + let prof2 = profile_from_properties(&merged2); + assert_eq!(prof2.avatar_hash, Some([9u8; 32])); + assert_eq!(prof2.avatar_fingerprint, Some([9u8; 8])); + } + + /// The returned profile (which overwrites the local cache) reflects + /// the merged state, not the partial input — so a partial update does + /// not wipe the local mirror either. + #[test] + fn returned_profile_reflects_merge_not_input() { + let input = ProfileUpdate { + display_name: Some("Alice 2".into()), + ..Default::default() + }; + let merged = merge_profile_properties(existing_full(), &input, None, None); + let prof = profile_from_properties(&merged); + assert_eq!(prof.display_name.as_deref(), Some("Alice 2")); + assert_eq!(prof.public_message.as_deref(), Some("hello world")); + assert_eq!(prof.bio.as_deref(), Some("hello world")); + assert_eq!(prof.avatar_url.as_deref(), Some("https://x/a.png")); + } + + // --- contact-profile sync helpers --- + + /// Only bounded `https://` avatar URLs are cached — `http:`, scheme + /// tricks, oversized, and empty are rejected (SSRF / tracking-pixel). + #[test] + fn avatar_url_validation_allows_only_bounded_https() { + assert!(is_valid_avatar_url("https://example.com/a.png")); + assert!(!is_valid_avatar_url("http://example.com/a.png")); + assert!(!is_valid_avatar_url("javascript:alert(1)")); + assert!(!is_valid_avatar_url("file:///etc/passwd")); + assert!(!is_valid_avatar_url("")); + let too_long = format!("https://x/{}", "a".repeat(MAX_AVATAR_URL_LEN)); + assert!(!is_valid_avatar_url(&too_long)); + } + + /// A never-checked id is fetched; a recently-checked one is skipped; a + /// stale one (past the window) is re-fetched. Holds for both a present + /// and a confirmed-absent (negative-cache) entry. + #[test] + fn should_fetch_respects_refresh_window_for_present_and_absent() { + let now = 10 * CONTACT_PROFILE_REFRESH_MS; + assert!(should_fetch_profile(None, now), "never-checked => fetch"); + + for profile in [Some(DashPayProfile::default()), None] { + let recent = ContactProfileEntry { + profile: profile.clone(), + checked_at_ms: now - 1, // just checked + }; + assert!( + !should_fetch_profile(Some(&recent), now), + "recently-checked => skip (negative cache for absent)" + ); + let stale = ContactProfileEntry { + profile, + checked_at_ms: now - CONTACT_PROFILE_REFRESH_MS, + }; + assert!( + should_fetch_profile(Some(&stale), now), + "past the window => re-fetch / re-check" + ); + } + } + + /// Full-replace + persist-on-change: a new id changes; the same profile + /// again does not (only the timestamp bumps); a different profile and a + /// present→absent transition both change. Removed fields disappear. + #[test] + fn apply_fetched_profile_full_replace_and_change_detection() { + let mut cache: BTreeMap = BTreeMap::new(); + let id = Identifier::from([0xC1; 32]); + let with_avatar = DashPayProfile { + display_name: Some("Bob".into()), + avatar_url: Some("https://x/b.png".into()), + ..Default::default() + }; + + // First write changes; checked_at recorded. + assert!(apply_fetched_profile( + &mut cache, + id, + Some(with_avatar.clone()), + 100 + )); + assert_eq!(cache[&id].checked_at_ms, 100); + + // Identical profile again: no change, but the timestamp advances. + assert!(!apply_fetched_profile( + &mut cache, + id, + Some(with_avatar), + 200 + )); + assert_eq!(cache[&id].checked_at_ms, 200); + + // Contact removed their avatar: full-replace drops it (a merge would + // have kept it) — this is a change. + let no_avatar = DashPayProfile { + display_name: Some("Bob".into()), + avatar_url: None, + ..Default::default() + }; + assert!(apply_fetched_profile(&mut cache, id, Some(no_avatar), 300)); + assert_eq!(cache[&id].profile.as_ref().unwrap().avatar_url, None); + + // Present -> confirmed-absent is a change and caches the negative. + assert!(apply_fetched_profile(&mut cache, id, None, 400)); + assert!(cache[&id].profile.is_none()); + } + + /// DAPI rejects any query that uses a range where-operator without a + /// matching `orderBy` on the range field ("missing order by for range"). + /// The profile chunk query filters by `$ownerId In [...]` — a range op — + /// so every range clause it builds must carry an `orderBy` on its field. + /// Guarded against vacuous truth: the query must actually contain a range + /// clause, otherwise the invariant would pass trivially. + #[test] + fn contact_profiles_chunk_query_orders_by_every_range_field() { + let contract = crate::wallet::identity::network::dashpay_contract() + .expect("DashPay system contract loads"); + let chunk = vec![Identifier::new([1u8; 32]), Identifier::new([2u8; 32])]; + + let query = contact_profiles_chunk_query(&contract, &chunk); + + // Non-vacuous: there is at least one range where-clause to satisfy. + assert!( + query.where_clauses.iter().any(|wc| wc.operator.is_range()), + "expected a range where-clause (e.g. $ownerId In [...])" + ); + + for wc in &query.where_clauses { + if wc.operator.is_range() { + assert!( + query + .order_by_clauses + .iter() + .any(|oc| oc.field == wc.field), + "range clause on `{}` has no matching orderBy; DAPI rejects \ + this with \"missing order by for range\"", + wc.field + ); + } + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs new file mode 100644 index 00000000000..aa8602bd2c0 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs @@ -0,0 +1,321 @@ +//! Object-safe seam over the SDK's DashPay write/broadcast surface. +//! +//! The fetch half of the DashPay network layer is already testable +//! through the dash-sdk built-in mock (`SdkBuilder::new_mock` + +//! `expect_fetch`/`expect_fetch_many`, as the `identity_sync.rs` tests +//! demonstrate). The *write* half is not: the two operations +//! `IdentityWallet` performs over the SDK — +//! [`Sdk::send_contact_request`](dash_sdk::Sdk::send_contact_request) +//! and a document put — cannot be reached through the mock and cannot +//! be wrapped behind a `dyn` trait *as the SDK exposes them*. +//! `send_contact_request` is generic over **seven** type parameters +//! (the signer plus three ECDH/xpub closure pairs), so it is not +//! object-safe; the document put rides on the signer-generic +//! [`PutDocument`](dash_sdk::platform::transition::put_document::PutDocument) +//! trait. +//! +//! This module defines ONE object-safe trait, +//! [`DashPaySdkWriter`], exposing exactly those two concrete +//! operations. `IdentityWallet` keeps doing all of the derivation +//! (key-index resolution, ECDH-key derivation, xpub derivation, +//! avatar hashing, document construction); the seam receives the +//! already-derived primitives plus a borrowed `&dyn +//! Signer` and performs the final SDK call. +//! Production wallets hold the default [`SdkWriter`] (an `Arc` +//! wrapper); tests substitute a recording / stubbing implementation +//! to assert the broadcast inputs without a live network. +//! +//! Keeping the trait to two methods is deliberate: this is a test +//! seam, not a refactor. Everything the SDK call needs that +//! `IdentityWallet` can compute up front travels in by value so the +//! trait stays `dyn`-compatible. + +use std::sync::Arc; + +use async_trait::async_trait; +use dpp::data_contract::document_type::DocumentType; +use dpp::document::Document; +use dpp::identity::signer::Signer; +use dpp::identity::{Identity, IdentityPublicKey}; + +use dash_sdk::platform::dashpay::{ + ContactRequestInput, EcdhProvider, RecipientIdentity, SendContactRequestInput, + SendContactRequestResult, +}; + +use crate::error::PlatformWalletError; + +// Borrowed-signer adapter — same pattern used by `contact_requests.rs` +// / `profile.rs`. Lets a `&dyn Signer` satisfy the +// owned, `Sized` `S: Signer` bound the SDK input +// types require, so the trait method below can stay object-safe while +// still threading the host's signer to the SDK. +struct SignerRef<'a, S: ?Sized>(&'a S); + +impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignerRef") + } +} + +#[async_trait] +impl<'a, K, S> Signer for SignerRef<'a, S> +where + K: Send + Sync, + S: Signer + ?Sized + Send + Sync, +{ + async fn sign( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign(key, data).await + } + + async fn sign_create_witness( + &self, + key: &K, + data: &[u8], + ) -> Result { + self.0.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &K) -> bool { + self.0.can_sign_with(key) + } +} + +/// Pre-derived inputs for a single contact-request broadcast. +/// +/// Every field is resolved by `IdentityWallet` before the seam is +/// called: key indices come from the sender / recipient identities, +/// `shared_secret` is the ECDH secret already derived against the +/// recipient's encryption key, `xpub_bytes` is the DashPay +/// receiving-account xpub to share, and `signing_public_key` is the +/// HIGH/CRITICAL authentication key the document state transition is +/// signed with. The seam only assembles the SDK `EcdhProvider` + xpub +/// closure and dispatches. +/// +/// The ECDH is performed by the caller (client-side), so the seam hands +/// the SDK the finished shared secret via [`EcdhProvider::ClientSide`] — +/// no private key crosses into the SDK. Carrying the precomputed secret +/// (rather than a derivation closure) keeps the params object-safe and +/// lets the caller source it from either the resident seed or the +/// Keychain signer without the seam knowing which. +pub(crate) struct SendContactRequestParams<'a> { + /// Sender (owner) identity — already loaded from local state. + pub sender_identity: Identity, + /// Recipient identity — already fetched from Platform. + pub recipient_identity: Identity, + /// Sender encryption-key id used for ECDH. + pub sender_key_index: u32, + /// Recipient decryption-key id used for ECDH. + pub recipient_key_index: u32, + /// DashPay account reference (currently `0`). + pub account_reference: u32, + /// Optional unencrypted account label (SDK encrypts it). + pub account_label: Option, + /// Optional unencrypted auto-accept proof. + pub auto_accept_proof: Option>, + /// ECDH shared secret, already derived by the caller against the + /// recipient's encryption key (client-side ECDH). The SDK encrypts + /// the shared xpub with this directly; no private key crosses the + /// seam. + pub shared_secret: [u8; 32], + /// The recipient encryption public key the `shared_secret` was + /// derived against. The seam's `ClientSide` closure asserts the SDK + /// asks for ECDH against this exact key before handing back the + /// secret — the client-side equivalent of the old SdkSide key-id + /// guard, so a recipient-key mismatch fails loudly instead of + /// silently encrypting with the wrong secret. + pub expected_recipient_pubkey: dashcore::secp256k1::PublicKey, + /// DashPay receiving-account xpub to share with the recipient, in the + /// **69-byte DIP-15 compact form** (`parentFingerprint ‖ chainCode ‖ + /// pubKey`) — NOT `ExtendedPubKey::encode()`. The SDK validates len == 69 + /// before encrypting. + pub xpub_bytes: Vec, + /// HIGH/CRITICAL authentication key the transition is signed with. + pub signing_public_key: IdentityPublicKey, + /// Borrowed host signer for the document state transition. + pub signer: &'a (dyn Signer + Send + Sync), +} + +/// Pre-built inputs for a single DashPay document put. +/// +/// `IdentityWallet` builds the [`Document`] (profile create/update) and +/// resolves the signing key + document type; the seam performs the +/// `put_to_platform_and_wait_for_response` broadcast. +pub(crate) struct PutDocumentParams<'a> { + /// Fully-built document to broadcast. + pub document: Document, + /// Owned document type for the target document. + pub document_type: DocumentType, + /// HIGH/CRITICAL authentication key the transition is signed with. + pub signing_public_key: IdentityPublicKey, + /// Borrowed host signer for the document state transition. + pub signer: &'a (dyn Signer + Send + Sync), +} + +/// Object-safe seam over the SDK's DashPay write operations. +/// +/// Held as a field on [`IdentityWallet`](super::IdentityWallet), +/// defaulting to the [`SdkWriter`] `Arc` wrapper so public +/// construction paths and the FFI are untouched. Tests inject a +/// stub/recording implementation. +/// +/// The returned futures are `Send` (the default `#[async_trait]` +/// boxing): the write paths this seam serves +/// (`send_contact_request_with_external_signer`, profile create/update) +/// are driven through the FFI's `block_on_worker`, which requires +/// `Future: Send`. (The DashPay *read*/sync path, which is `!Send` and +/// runs on a dedicated thread, does not go through this seam.) +#[async_trait] +pub(crate) trait DashPaySdkWriter: std::fmt::Debug + Send + Sync { + /// Build the ECDH provider + xpub closure from the pre-derived + /// inputs and broadcast the contact-request document. + async fn send_contact_request( + &self, + params: SendContactRequestParams<'_>, + ) -> Result; + + /// Broadcast a pre-built DashPay document and wait for the + /// confirmation proof. + async fn put_document( + &self, + params: PutDocumentParams<'_>, + ) -> Result; +} + +/// Default [`DashPaySdkWriter`] backed by a live [`Sdk`](dash_sdk::Sdk). +/// +/// This is the production implementation; it simply forwards to the +/// real SDK. Holding it behind the trait is what lets the network-layer +/// tests substitute a mock writer. +#[derive(Clone)] +pub(crate) struct SdkWriter { + sdk: Arc, +} + +impl SdkWriter { + /// Wrap an SDK handle as the default writer. + pub(crate) fn new(sdk: Arc) -> Self { + Self { sdk } + } +} + +impl std::fmt::Debug for SdkWriter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SdkWriter").finish() + } +} + +#[async_trait] +impl DashPaySdkWriter for SdkWriter { + async fn send_contact_request( + &self, + params: SendContactRequestParams<'_>, + ) -> Result { + let SendContactRequestParams { + sender_identity, + recipient_identity, + sender_key_index, + recipient_key_index, + account_reference, + account_label, + auto_accept_proof, + shared_secret, + expected_recipient_pubkey, + xpub_bytes, + signing_public_key, + signer, + } = params; + + let contact_request_input = ContactRequestInput { + sender_identity, + recipient: RecipientIdentity::Identity(recipient_identity), + sender_key_index, + recipient_key_index, + account_reference, + account_label, + auto_accept_proof, + }; + + let send_input = SendContactRequestInput { + contact_request: contact_request_input, + identity_public_key: signing_public_key, + signer: SignerRef(signer), + }; + + // Client-side ECDH: the caller already derived the shared secret + // against `expected_recipient_pubkey`; hand it back, guarding that + // the SDK asks for ECDH against that exact recipient key (the + // client-side equivalent of the old SdkSide key-id guard). The + // `F`/`Fut` (SdkSide) type params are unused here, so a never-called + // `fn` placeholder satisfies their bounds. + let ecdh_provider: EcdhProvider< + fn( + &IdentityPublicKey, + u32, + ) -> std::future::Ready< + Result, + >, + _, + _, + _, + > = EcdhProvider::ClientSide { + get_shared_secret: move |peer: &dashcore::secp256k1::PublicKey| { + let peer_matches = *peer == expected_recipient_pubkey; + async move { + if !peer_matches { + return Err(dash_sdk::Error::Generic( + "ECDH recipient-key mismatch: the SDK resolved a recipient \ + encryption key different from the one the shared secret was \ + derived against" + .to_string(), + )); + } + Ok(shared_secret) + } + }, + }; + + let xpub_bytes_clone = xpub_bytes.clone(); + self.sdk + .send_contact_request(send_input, ecdh_provider, |_account_ref: u32| async move { + Ok::, dash_sdk::Error>(xpub_bytes_clone) + }) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to send contact request: {e}" + )) + }) + } + + async fn put_document( + &self, + params: PutDocumentParams<'_>, + ) -> Result { + use dash_sdk::platform::transition::put_document::PutDocument; + + let PutDocumentParams { + document, + document_type, + signing_public_key, + signer, + } = params; + + document + .put_to_platform_and_wait_for_response( + &self.sdk, + document_type, + None, + signing_public_key, + None, + &SignerRef(signer), + None, + ) + .await + .map_err(PlatformWalletError::Sdk) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs new file mode 100644 index 00000000000..32b4dd51b45 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs @@ -0,0 +1,201 @@ +//! Wrong-seed / wrong-wallet self-check for a seedless wallet at unlock. +//! +//! The persisted-restore path rehydrates every wallet external-signable — +//! per-account xpubs only, no resident key material. Signing runs through the +//! host's Keychain-backed signer rather than a seed grafted onto the wallet. +//! Before trusting that signer, the host verifies it actually resolves *this* +//! wallet's seed: derive the BIP44 account-0 extended public key through the +//! signer and compare it to the wallet's persisted account xpub. A mis-mapped +//! Keychain slot — the signer resolving some other wallet's mnemonic — derives +//! a different xpub and is refused, so it can never sign for the wrong wallet. +//! This is the wrong-seed detection without ever holding a resident seed. + +use crate::error::PlatformWalletError; +use crate::wallet::identity::network::contact_requests::ContactCryptoProvider; +use crate::wallet::platform_wallet::PlatformWallet; + +impl PlatformWallet { + /// Verify the signer behind `crypto` resolves the seed that owns this wallet. + /// + /// Reads the wallet's persisted BIP44 account-0 xpub and the path it was + /// rooted at, derives the xpub at that same path through `crypto` (the host + /// signer, which holds the seed), and compares. The wallet itself may be + /// watch-only / external-signable — it only supplies its stored xpub, never + /// a private key. + /// + /// Returns [`PlatformWalletError::SeedMismatch`] if the derived xpub differs + /// from the persisted one (the signer is mapped to the wrong wallet), so a + /// wrong-seed signer fails loud at unlock instead of silently signing for a + /// wallet it does not own. + pub async fn verify_seed_binds( + &self, + crypto: &C, + ) -> Result<(), PlatformWalletError> { + // Read the binding xpub and its exact derivation path from the same + // account, so the two can never drift. Drop the lock before awaiting the + // signer — the guard is not held across `.await`. + let (path, expected) = { + let guard = self.state().await; + let wallet = guard.wallet(); + let account = wallet.get_bip44_account(0).ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "wallet has no BIP44 account 0 to verify the seed against".to_string(), + ) + })?; + let path = account + .account_type + .derivation_path(wallet.network) + .map_err(|e| PlatformWalletError::KeyDerivation(e.to_string()))?; + (path, account.account_xpub) + }; + + let derived = crypto.receiving_xpub(&path).await?; + if derived == expected { + Ok(()) + } else { + Err(PlatformWalletError::SeedMismatch { + wallet_id: hex::encode(self.wallet_id()), + }) + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::error::PlatformWalletError; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWalletManager; + + // Canonical all-`abandon` BIP-39 test vector. + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + struct NoopPersister; + impl PlatformWalletPersistence for NoopPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + fn make_manager() -> Arc> { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(NoopPersister); + let event_handler: Arc = Arc::new(NoopEventHandler); + Arc::new(PlatformWalletManager::new(sdk, persister, event_handler)) + } + + fn seed_for(phrase: &str) -> [u8; 64] { + Mnemonic::from_phrase(phrase, Language::English) + .expect("valid test mnemonic") + .to_seed("") + } + + /// The signer that resolves the wallet's own seed binds: the BIP44 + /// account-0 xpub it derives matches the wallet's persisted account xpub. + #[tokio::test] + async fn verify_seed_binds_accepts_matching_signer() { + let manager = make_manager(); + let network = Network::Testnet; + let seed = seed_for(TEST_MNEMONIC); + + // Created external-signable (no resident key material), exactly the + // persisted-restore posture the unlock check runs against. + let wallet = manager + .create_wallet_from_seed_bytes(network, &seed, WalletAccountCreationOptions::Default, Some(0)) + .await + .expect("wallet creation"); + assert!( + !wallet.state().await.wallet().has_seed(), + "precondition: wallet must be seedless / external-signable" + ); + + let crypto = SeedCryptoProvider::from_seed(seed, network); + wallet + .verify_seed_binds(&crypto) + .await + .expect("the wallet's own seed must bind"); + } + + /// A signer resolving a *different* seed derives a different BIP44 + /// account-0 xpub and is rejected with `SeedMismatch` — the wrong-seed + /// detection that protects against a mis-mapped Keychain slot. + #[tokio::test] + async fn verify_seed_binds_rejects_wrong_signer() { + let manager = make_manager(); + let network = Network::Testnet; + let seed = seed_for(TEST_MNEMONIC); + + let wallet = manager + .create_wallet_from_seed_bytes(network, &seed, WalletAccountCreationOptions::Default, Some(0)) + .await + .expect("wallet creation"); + + // A different mnemonic → a signer for the wrong wallet. + let wrong_seed = + seed_for("legal winner thank year wave sausage worth useful legal winner thank yellow"); + let wrong_crypto = SeedCryptoProvider::from_seed(wrong_seed, network); + + let err = wallet + .verify_seed_binds(&wrong_crypto) + .await + .expect_err("a signer for a different seed must be rejected"); + assert!( + matches!(err, PlatformWalletError::SeedMismatch { .. }), + "expected SeedMismatch, got: {err:?}" + ); + } + + /// A wallet with no BIP44 account 0 (created without the default account + /// set) is refused with `InvalidIdentityData` — the gate has no persisted + /// xpub to bind against, so it must fail closed rather than pass silently. + #[tokio::test] + async fn verify_seed_binds_rejects_wallet_without_bip44_account() { + let manager = make_manager(); + let network = Network::Testnet; + let seed = seed_for(TEST_MNEMONIC); + + let wallet = manager + .create_wallet_from_seed_bytes(network, &seed, WalletAccountCreationOptions::None, Some(0)) + .await + .expect("wallet creation"); + assert!( + wallet.state().await.wallet().get_bip44_account(0).is_none(), + "precondition: wallet has no BIP44 account 0" + ); + + let crypto = SeedCryptoProvider::from_seed(seed, network); + let err = wallet + .verify_seed_binds(&crypto) + .await + .expect_err("a wallet with no BIP44 account 0 cannot be bound"); + assert!( + matches!(err, PlatformWalletError::InvalidIdentityData(_)), + "expected InvalidIdentityData, got: {err:?}" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index cbcf0e1be78..afad7117b7c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -9,16 +9,53 @@ use super::ManagedIdentity; use crate::changeset::{ ContactChangeSet, ContactRequestEntry, ReceivedContactRequestKey, SentContactRequestKey, }; +use crate::wallet::identity::crypto::contact_info::ContactInfoPrivateData; use crate::wallet::persister::WalletPersister; use crate::{ContactRequest, EstablishedContact}; use dpp::prelude::Identifier; impl ManagedIdentity { + /// The masked `accountReference` of the most recent request WE sent + /// to `recipient`, or `None` if we've never sent one. + /// + /// Load-bearing for rotation: the next request's rotation version + /// is derived by un-masking this prior reference and bumping it. The + /// prior request lives in `sent_contact_requests` while pending but is + /// moved into `established_contacts[..].outgoing_request` once the + /// contact establishes — and rotation (re-keying) happens precisely on + /// an established relationship. Consulting only the pending map would + /// return `None` for every established contact, resetting the version + /// to 0 and reproducing the original reference, which the contract's + /// `($ownerId, toUserId, accountReference)` unique index rejects. So + /// this checks both maps. + pub fn prior_sent_account_reference(&self, recipient: &Identifier) -> Option { + self.sent_contact_requests + .get(recipient) + .map(|r| r.account_reference) + .or_else(|| { + self.established_contacts + .get(recipient) + .map(|c| c.outgoing_request.account_reference) + }) + } + /// Add a sent contact request. /// /// If there's already an incoming request from the recipient, the /// contact is auto-established. Persists the resulting /// [`ContactChangeSet`] via `persister` and returns `()`. + /// + /// **Sent-side ingest guard.** A recurring sweep re-ingests the + /// identity's own sent requests on every pass; without a guard that + /// would create a phantom pending-sent row + a changeset write per + /// contact per sweep, and an `EstablishedContact::new` for an + /// already-established pair would wipe the user's alias / note / + /// hide-flag / accepted-accounts. So this method is a **no-op** when + /// the recipient is already tracked as established or already in the + /// sent map (symmetric to the received-side dedup in + /// `sync_contact_requests`). When it must (re-)establish against a + /// pre-existing incoming request, it MERGES into any existing + /// `EstablishedContact` to preserve metadata. pub fn add_sent_contact_request( &mut self, request: ContactRequest, @@ -26,6 +63,19 @@ impl ManagedIdentity { ) { let owner_id = self.id(); let recipient_id = request.recipient_id; + + // Sent-side guard: already established → nothing to do. The + // on-platform request is immutable, so a re-ingest carries no new + // information; re-establishing would wipe user metadata. + if self.established_contacts.contains_key(&recipient_id) { + return; + } + // Already tracked as a pending sent request → no-op (no phantom + // row, no redundant changeset write). + if self.sent_contact_requests.contains_key(&recipient_id) { + return; + } + let mut cs = ContactChangeSet::default(); // Check if there's already an incoming request from this recipient @@ -33,8 +83,16 @@ impl ManagedIdentity { // Automatically establish the contact — per the ContactChangeSet // auto-establishment contract, `established` implies the matching // pending entries are dropped, so we don't also emit a - // `removed_incoming` tombstone here. - let contact = EstablishedContact::new(recipient_id, request, incoming_request); + // `removed_incoming` tombstone here. Preserve metadata if a + // prior `EstablishedContact` exists for this pair. + let contact = match self.established_contacts.get(&recipient_id) { + Some(existing) => EstablishedContact::reestablish_preserving_metadata( + existing, + request, + incoming_request, + ), + None => EstablishedContact::new(recipient_id, request, incoming_request), + }; cs.established.insert( SentContactRequestKey { owner_id, @@ -61,6 +119,77 @@ impl ManagedIdentity { } } + /// Ignore `sender_id` (per-sender mute, = block, reversible). + /// + /// Drops the sender's pending incoming entry (if present) and records + /// the sender in `ignored_senders` so the recurring sync ingest path + /// won't resurrect *any* of that sender's still-on-platform immutable + /// `contactRequest` documents — including rotated ones with a bumped + /// `accountReference`. Suppression is per-sender by design (unlike the + /// old per-`(sender, accountReference)` reject). Returns the + /// [`ContactChangeSet`] carrying the ignore (the caller is responsible + /// for persisting it through the same write guard it holds). + pub fn ignore_sender(&mut self, sender_id: &Identifier) -> ContactChangeSet { + let owner_id = self.id(); + self.incoming_contact_requests.remove(sender_id); + self.ignored_senders.insert(*sender_id); + + let mut cs = ContactChangeSet::default(); + // Emit `removed_incoming` too — NOT just the ignore entry. The + // Rust SQLite contacts writer DELETEs the persisted + // `state='received'` row only on a `removed_incoming` entry; its + // `ignored` branch upserts solely the ignored-senders table. + // Without this the ignored sender's row survives in SQLite and + // rehydrates as a live incoming entry on the next load — the + // user's ignore is silently undone on that backend. (The SwiftData + // persister already deletes the row via its `ignored` handler, so + // this makes the two backends consistent.) + cs.removed_incoming.insert(ReceivedContactRequestKey { + owner_id, + sender_id: *sender_id, + }); + cs.ignored.insert((owner_id, *sender_id)); + cs + } + + /// Whether `sender_id` is ignored (per-sender). When `true`, ALL of + /// the sender's incoming requests are suppressed from the main pending + /// list — including rotated (bumped-`accountReference`) ones. + pub fn is_sender_ignored(&self, sender_id: &Identifier) -> bool { + self.ignored_senders.contains(sender_id) + } + + /// Un-ignore `sender_id` (reverse [`Self::ignore_sender`]). + /// + /// Removes the sender from `ignored_senders` AND rewinds the received + /// high-water cursor to `None`. The rewind is load-bearing: while the + /// sender was ignored, the recurring sweep kept advancing the cursor + /// past their on-chain requests, so without resetting it the next + /// sweep's incremental `$createdAt >` query would never re-fetch them + /// and the un-ignored sender's request would never reappear. `None` + /// forces one full re-fetch (safe — ingest is a fixpoint). + /// + /// Returns a [`ContactChangeSet`] carrying the ignore tombstone removal + /// (the caller persists it through its write guard). The cursor reset + /// is in-memory only (the high-water mark is not itself persisted; it + /// resets to `None` on cold restart anyway), so no changeset field is + /// needed for it. A no-op (empty changeset) when the sender wasn't + /// ignored. + pub fn unignore_sender(&mut self, sender_id: &Identifier) -> ContactChangeSet { + let owner_id = self.id(); + let was_ignored = self.ignored_senders.remove(sender_id); + if !was_ignored { + return ContactChangeSet::default(); + } + // Rewind the receive cursor so the next sweep re-fetches the + // now-un-ignored sender's on-chain requests. + self.high_water_received_ms = None; + + let mut cs = ContactChangeSet::default(); + cs.unignored.insert((owner_id, *sender_id)); + cs + } + /// Remove a sent contact request. /// /// Returns the removed request (if any) and a tombstone changeset. @@ -98,8 +227,18 @@ impl ManagedIdentity { // Automatically establish the contact — per the ContactChangeSet // auto-establishment contract, `established` implies the matching // pending entries are dropped, so we don't also emit a - // `removed_sent` tombstone here. - let contact = EstablishedContact::new(sender_id, outgoing_request, request); + // `removed_sent` tombstone here. Preserve metadata if a prior + // `EstablishedContact` exists for this pair (a recurring sweep + // can re-ingest a reciprocal while the relationship already + // exists — naive re-establish would wipe the user's metadata). + let contact = match self.established_contacts.get(&sender_id) { + Some(existing) => EstablishedContact::reestablish_preserving_metadata( + existing, + outgoing_request, + request, + ), + None => EstablishedContact::new(sender_id, outgoing_request, request), + }; cs.established.insert( SentContactRequestKey { owner_id, @@ -126,6 +265,147 @@ impl ManagedIdentity { } } + /// Set the owner-private metadata (alias / note / hidden) on an + /// established contact and persist the changeset. + /// + /// Takes the decrypted [`ContactInfoPrivateData`] payload directly — + /// the same struct the `contactInfo` codec produces — so callers don't + /// explode it into positional args. This is the local half of + /// `contactInfo`: callers route user edits AND decrypted + /// on-platform `contactInfo` payloads through here so SwiftData mirrors + /// either source. The wire field names (`alias_name` / `display_hidden`) + /// map onto the domain names (`alias` / `is_hidden`) on the contact. + /// Returns `false` (no-op) when the contact isn't established. + pub fn set_contact_metadata( + &mut self, + contact_id: &Identifier, + metadata: ContactInfoPrivateData, + persister: &WalletPersister, + ) -> bool { + let owner_id = self.id(); + let Some(contact) = self.established_contacts.get_mut(contact_id) else { + return false; + }; + if contact.alias == metadata.alias_name + && contact.note == metadata.note + && contact.is_hidden == metadata.display_hidden + { + // Unchanged — skip the persister round (the recurring sync + // calls this for every decrypted doc on every pass). + return true; + } + contact.alias = metadata.alias_name; + contact.note = metadata.note; + contact.is_hidden = metadata.display_hidden; + + let mut cs = ContactChangeSet::default(); + cs.established.insert( + SentContactRequestKey { + owner_id, + recipient_id: *contact_id, + }, + contact.clone(), + ); + if let Err(e) = persister.store(cs.into()) { + tracing::error!("Failed to persist changeset: {}", e); + } + true + } + + /// Apply a **rotation** contact request (receive side, DIP-15 + /// §"sender rotated their addresses"): a request from a sender we + /// already track, carrying a *different* `accountReference` than + /// the tracked one. The new request supersedes the old — + /// last-write-wins per pair; simultaneous multi-account + /// relationships ride `accepted_accounts` later. + /// + /// - **Established contact**: replace `incoming_request` (the new + /// encrypted xpub + key indices) and clear + /// `payment_channel_broken` — a superseding request is exactly + /// the "wait for a new request" recovery the broken flag's + /// docs promise. The caller is responsible for tearing down the + /// stale external account so the build sweep re-registers it + /// from the new xpub. + /// - **Pending incoming** (not yet accepted): replace the entry — + /// accepting later uses the freshest key material. + /// + /// No-op if the sender isn't tracked at all (callers route fresh + /// requests through [`Self::add_incoming_contact_request`]). + /// Persists the resulting changeset. Returns `true` when an + /// established contact was re-keyed (the caller's signal to tear + /// down the stale external account). + pub fn apply_rotated_incoming_request( + &mut self, + request: ContactRequest, + persister: &WalletPersister, + ) -> bool { + let owner_id = self.id(); + let sender_id = request.sender_id; + + // Idempotency guard: if the incoming request already stored for + // this sender is byte-identical, this is a re-ingest of a doc we + // already applied — do NOT persist a changeset or report a re-key. + // The sync sweep collapses to the newest doc per sender, so this + // shouldn't normally fire, but the state method must be safe to + // call repeatedly with the same request without thrashing the + // persister or re-tearing-down the external account. + let already_applied = self + .established_contacts + .get(&sender_id) + .map(|c| c.incoming_request == request) + .or_else(|| { + self.incoming_contact_requests + .get(&sender_id) + .map(|r| *r == request) + }) + .unwrap_or(false); + if already_applied { + return false; + } + + let mut cs = ContactChangeSet::default(); + + let rekeyed_established = + if let Some(contact) = self.established_contacts.get_mut(&sender_id) { + tracing::info!( + owner = %owner_id, + sender = %sender_id, + old_reference = contact.incoming_request.account_reference, + new_reference = request.account_reference, + "Contact rotated their addresses — re-keying the established contact" + ); + contact.incoming_request = request; + contact.payment_channel_broken = false; + cs.established.insert( + SentContactRequestKey { + owner_id, + recipient_id: sender_id, + }, + contact.clone(), + ); + true + } else if let Some(slot) = self.incoming_contact_requests.get_mut(&sender_id) { + // Pending (not-yet-accepted) incoming request — replace it + // in place so a later Accept uses the freshest key material. + *slot = request.clone(); + cs.incoming_requests.insert( + ReceivedContactRequestKey { + owner_id, + sender_id, + }, + ContactRequestEntry { request }, + ); + false + } else { + return false; + }; + + if let Err(e) = persister.store(cs.into()) { + tracing::error!("Failed to persist changeset: {}", e); + } + rekeyed_established + } + /// Remove an incoming contact request. /// /// Returns the removed request (if any) and a tombstone changeset. @@ -273,6 +553,42 @@ mod tests { assert_eq!(managed.established_contacts.len(), 0); } + /// **Blocking — ignore must DELETE the persisted incoming row, not only + /// record the suppression.** The Rust SQLite contacts writer issues + /// `DELETE FROM contacts` only on a `removed_incoming` changeset entry; + /// its `ignored` branch upserts solely the ignored-senders table. So if + /// `ignore_sender` emits only `ignored`, the `state='received'` row + /// survives in SQLite and the ignored request rehydrates as live on the + /// next load — the user's ignore silently undone on that backend. Pin + /// that BOTH are emitted. + #[test] + fn ignore_sender_emits_removed_incoming_so_sqlite_deletes_the_row() { + let mut managed = create_test_identity([1u8; 32]); + let owner_id = managed.id(); + let sender_id = Identifier::from([2u8; 32]); + let p = noop_persister(); + + managed.add_incoming_contact_request(create_contact_request(sender_id, owner_id, 1234), &p); + assert_eq!(managed.incoming_contact_requests.len(), 1); + + let cs = managed.ignore_sender(&sender_id); + + // The per-sender ignore is recorded... + assert!( + cs.ignored.contains(&(owner_id, sender_id)), + "ignore must record the per-sender suppression" + ); + // ...AND the incoming-row deletion is emitted, so the SQLite writer + // actually removes the persisted `state='received'` row. + assert!( + cs.removed_incoming.contains(&ReceivedContactRequestKey { + owner_id, + sender_id, + }), + "ignore must emit removed_incoming so the persisted contacts row is DELETEd" + ); + } + #[test] fn test_add_incoming_contact_request_without_reciprocal() { let mut managed = create_test_identity([1u8; 32]); @@ -478,6 +794,157 @@ mod tests { assert!(::is_empty(&cs)); } + /// Re-ingesting one's own already-tracked sent request must be a + /// no-op — no phantom pending-sent row, no second changeset write. The + /// sent-side guard mirrors the received-side dedup. + #[test] + fn test_add_sent_contact_request_is_idempotent_when_already_tracked() { + let mut managed = create_test_identity([1u8; 32]); + let our_id = Identifier::from([1u8; 32]); + let recipient_id = Identifier::from([2u8; 32]); + let p = noop_persister(); + + let request = create_contact_request(our_id, recipient_id, 1234567890); + managed.add_sent_contact_request(request.clone(), &p); + assert_eq!(managed.sent_contact_requests.len(), 1); + + // Re-ingest the SAME sent request (recurring sweep). It must not + // create a duplicate / phantom row. + managed.add_sent_contact_request(request, &p); + assert_eq!( + managed.sent_contact_requests.len(), + 1, + "re-ingesting an already-tracked sent request must not duplicate it" + ); + assert_eq!(managed.established_contacts.len(), 0); + } + + /// Re-ingesting a sent request to an ALREADY-established contact + /// must be a no-op — it must NOT wipe the existing contact's user + /// metadata (alias/note/is_hidden/accepted_accounts). + #[test] + fn test_add_sent_contact_request_preserves_established_metadata() { + let mut managed = create_test_identity([1u8; 32]); + let our_id = Identifier::from([1u8; 32]); + let contact_id = Identifier::from([2u8; 32]); + let p = noop_persister(); + + // Establish a contact and attach user metadata. + managed.add_incoming_contact_request(create_contact_request(contact_id, our_id, 1), &p); + managed.add_sent_contact_request(create_contact_request(our_id, contact_id, 2), &p); + assert_eq!(managed.established_contacts.len(), 1); + let established = managed.established_contacts.get_mut(&contact_id).unwrap(); + established.set_alias("Alice".to_string()); + established.set_note("from work".to_string()); + established.hide(); + + // Recurring sweep re-ingests our own sent request for an already + // established contact — must not reset metadata. + managed.add_sent_contact_request(create_contact_request(our_id, contact_id, 3), &p); + + let established = managed.established_contacts.get(&contact_id).unwrap(); + assert_eq!(established.alias, Some("Alice".to_string())); + assert_eq!(established.note, Some("from work".to_string())); + assert_eq!(established.is_hidden, true); + } + + /// When a sent request auto-establishes against a pre-existing + /// incoming, but the pair was previously established and we carry + /// forward metadata — the re-establish must preserve it. (Covers the + /// case where the incoming map still holds a request because a sweep + /// re-ingested it.) + #[test] + fn test_reestablish_via_incoming_preserves_metadata() { + let mut managed = create_test_identity([1u8; 32]); + let our_id = Identifier::from([1u8; 32]); + let contact_id = Identifier::from([2u8; 32]); + let p = noop_persister(); + + // Establish, then attach metadata. + managed.add_incoming_contact_request(create_contact_request(contact_id, our_id, 1), &p); + managed.add_sent_contact_request(create_contact_request(our_id, contact_id, 2), &p); + let est = managed.established_contacts.get_mut(&contact_id).unwrap(); + est.set_alias("Bob".to_string()); + + // Simulate a re-ingested incoming reciprocal landing while a sent + // request also exists in the map (forced state). + managed + .sent_contact_requests + .insert(contact_id, create_contact_request(our_id, contact_id, 4)); + managed.add_incoming_contact_request(create_contact_request(contact_id, our_id, 5), &p); + + let est = managed.established_contacts.get(&contact_id).unwrap(); + assert_eq!( + est.alias, + Some("Bob".to_string()), + "re-establish must preserve the alias" + ); + } + + /// Ignoring a sender drops the pending incoming entry and records the + /// sender in `ignored_senders` — per-sender, NOT per-accountReference. + /// A rotated request (bumped `accountReference`) from the same sender + /// is ALSO suppressed (the per-sender semantics). + #[test] + fn test_ignore_sender_suppresses_sender_per_sender() { + let mut managed = create_test_identity([1u8; 32]); + let our_id = Identifier::from([1u8; 32]); + let sender_id = Identifier::from([2u8; 32]); + let p = noop_persister(); + + let mut request = create_contact_request(sender_id, our_id, 1); + request.account_reference = 0; + managed.add_incoming_contact_request(request, &p); + assert_eq!(managed.incoming_contact_requests.len(), 1); + + let cs = managed.ignore_sender(&sender_id); + + // Incoming dropped, sender recorded as ignored. + assert_eq!(managed.incoming_contact_requests.len(), 0); + assert!(managed.ignored_senders.contains(&sender_id)); + assert!(cs.ignored.contains(&(our_id, sender_id))); + // The sender is ignored regardless of accountReference — both the + // original (0) and a rotated (1) request are suppressed. + assert!(managed.is_sender_ignored(&sender_id)); + } + + /// Un-ignoring a sender removes them from `ignored_senders`, rewinds + /// the received high-water cursor to `None` (so the next sweep + /// re-fetches their requests), and emits the un-ignore changeset. + /// Un-ignoring a sender who wasn't ignored is a no-op (empty + /// changeset, cursor untouched). + #[test] + fn test_unignore_sender_clears_cursor_and_removes() { + let mut managed = create_test_identity([1u8; 32]); + let our_id = Identifier::from([1u8; 32]); + let sender_id = Identifier::from([2u8; 32]); + + managed.ignore_sender(&sender_id); + assert!(managed.is_sender_ignored(&sender_id)); + // Simulate the sweep having advanced the cursor past the sender's + // requests while they were ignored. + managed.high_water_received_ms = Some(123_456); + + let cs = managed.unignore_sender(&sender_id); + + assert!(!managed.is_sender_ignored(&sender_id)); + assert!(cs.unignored.contains(&(our_id, sender_id))); + assert_eq!( + managed.high_water_received_ms, None, + "un-ignore must rewind the receive cursor so the sender's requests re-fetch" + ); + + // Un-ignoring again (no longer ignored) is a no-op and does NOT + // touch the cursor a second time. + managed.high_water_received_ms = Some(999); + let cs2 = managed.unignore_sender(&sender_id); + assert!( + ::is_empty(&cs2), + "un-ignoring a non-ignored sender must be a no-op" + ); + assert_eq!(managed.high_water_received_ms, Some(999)); + } + #[test] fn test_multiple_contact_requests() { let mut managed = create_test_identity([1u8; 32]); diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index e7e2949220d..51fd393fe05 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -62,6 +62,8 @@ impl ManagedIdentity { public_key_hash: pubkey_hash_of(pub_key), wallet_id: None, derivation_indices: None, + // Watch-only snapshot — no secret rides this path. + private_key: None, }, ); } @@ -84,12 +86,16 @@ impl ManagedIdentity { established_contacts: Default::default(), sent_contact_requests: Default::default(), incoming_contact_requests: Default::default(), + ignored_senders: Default::default(), status: Default::default(), dpns_names: Vec::new(), contested_dpns_names: Vec::new(), wallet_id: None, dashpay_profile: None, dashpay_payments: BTreeMap::new(), + high_water_received_ms: None, + high_water_sent_ms: None, + contact_profiles: BTreeMap::new(), } } @@ -108,12 +114,16 @@ impl ManagedIdentity { established_contacts: Default::default(), sent_contact_requests: Default::default(), incoming_contact_requests: Default::default(), + ignored_senders: Default::default(), status: Default::default(), dpns_names: Vec::new(), contested_dpns_names: Vec::new(), wallet_id: None, dashpay_profile: None, dashpay_payments: BTreeMap::new(), + high_water_received_ms: None, + high_water_sent_ms: None, + contact_profiles: BTreeMap::new(), } } @@ -155,12 +165,31 @@ impl ManagedIdentity { tx_id: String, entry: crate::wallet::identity::PaymentEntry, persister: &WalletPersister, - ) { + ) -> Result<(), crate::changeset::PersistenceError> { self.dashpay_payments.insert(tx_id, entry); let cs = self.snapshot_changeset(); - if let Err(e) = persister.store(cs.into()) { - tracing::error!("Failed to persist changeset: {}", e); - } + // Returns the persist result instead of swallowing it. The + // user-initiated send path (`send_payment`) propagates it so a + // failed write surfaces in the UI rather than silently dropping a + // Sent entry + memo that has no on-chain recovery. The self-healing + // sweep callers (live recorder / reconcile of Received) log and + // continue — the next sweep re-derives those from UTXOs. + persister.store(cs.into()) + } + + /// All DashPay payments to or from `contact_id` (keyed by txid), newest + /// first. Both `send_payment` and the receival recorder stamp + /// `counterparty_id`, so this is the per-contact tx history without a + /// separate tx→contact reverse-lookup table. + pub fn payments_for_contact( + &self, + contact_id: &Identifier, + ) -> Vec<(String, crate::wallet::identity::PaymentEntry)> { + self.dashpay_payments + .iter() + .filter(|(_, p)| p.counterparty_id == contact_id) + .map(|(tx_id, p)| (tx_id.clone(), p.clone())) + .collect() } /// Get the identity ID @@ -249,43 +278,87 @@ impl ManagedIdentity { pub fn add_key( &mut self, public_key: dpp::identity::IdentityPublicKey, - derivation_breadcrumb: Option<([u8; 32], u32, u32)>, + derivation_breadcrumb: Option, persister: &WalletPersister, ) { - use dpp::identity::accessors::IdentitySettersV0; - - let key_id = public_key.id(); - let public_key_hash = pubkey_hash_of(&public_key); + // Single-key form of [`Self::add_keys`] — one canonical + // key-layering + changeset path so the two can't drift. This + // entry point carries no secret (callers that have a verified + // scalar go through `add_keys` directly), so `verified_scalar` + // is `None`. + self.add_keys( + vec![crate::changeset::KeyWithBreadcrumb { + key: public_key, + breadcrumb: derivation_breadcrumb, + verified_scalar: None, + }], + persister, + ); + } - // Layer onto the DPP `Identity` itself — that's what every - // signing / introspection path reads. - let mut keys = self.identity.public_keys().clone(); - keys.insert(key_id, public_key.clone()); - self.identity.set_public_keys(keys); + /// Layer several `IdentityPublicKey`s onto this identity and emit ONE + /// batched [`IdentityKeysChangeSet`] carrying each key's derivation + /// breadcrumb (`Some((wallet_id, identity_index, key_index))`) or + /// `None` for a watch-only key the wallet can't re-derive. + /// + /// The single-write batch form of [`Self::add_key`], used by discovery + /// to materialize every re-derivable key of a freshly found identity in + /// one persist round (rather than one round per key) and to carry the + /// authoritative per-key breadcrumb set in a single changeset (no + /// order-dependent watch-only-then-override). No-op on an empty list. + pub fn add_keys( + &mut self, + keys: Vec, + persister: &WalletPersister, + ) { + use dpp::identity::accessors::IdentitySettersV0; + if keys.is_empty() { + return; + } let identity_id = self.id(); - let (wallet_id, derivation_indices) = match derivation_breadcrumb { - Some((wallet_id, identity_index, key_index)) => ( - Some(wallet_id), - Some(crate::changeset::IdentityKeyDerivationIndices { - identity_index, - key_index, - }), - ), - None => (None, None), - }; + let mut current = self.identity.public_keys().clone(); let mut keys_cs = IdentityKeysChangeSet::default(); - keys_cs.upserts.insert( - (identity_id, key_id), - IdentityKeyEntry { - identity_id, - key_id, - public_key, - public_key_hash, - wallet_id, - derivation_indices, - }, - ); + for crate::changeset::KeyWithBreadcrumb { + key: public_key, + breadcrumb, + verified_scalar, + } in keys + { + let key_id = public_key.id(); + let public_key_hash = pubkey_hash_of(&public_key); + current.insert(key_id, public_key.clone()); + // Couple the carried scalar to the breadcrumb: a scalar is only + // useful with its `(identity_index, key_index)`, and a client + // stores the bytes only when both are present, so dropping a + // stray scalar that arrived without a breadcrumb keeps the two + // from ever diverging (and stops a verified secret from being + // silently discarded downstream). + let (wallet_id, derivation_indices, private_key) = match breadcrumb { + Some((wallet_id, identity_index, key_index)) => ( + Some(wallet_id), + Some(crate::changeset::IdentityKeyDerivationIndices { + identity_index, + key_index, + }), + verified_scalar, + ), + None => (None, None, None), + }; + keys_cs.upserts.insert( + (identity_id, key_id), + IdentityKeyEntry { + identity_id, + key_id, + public_key, + public_key_hash, + wallet_id, + derivation_indices, + private_key, + }, + ); + } + self.identity.set_public_keys(current); let cs = crate::changeset::PlatformWalletChangeSet { identities: Some(self.snapshot_changeset()), identity_keys: Some(keys_cs), @@ -379,6 +452,9 @@ impl ManagedIdentity { public_key_hash, wallet_id, derivation_indices, + // Disabling an already-materialized key carries no + // scalar — the client keeps its existing Keychain item. + private_key: None, }, ); } @@ -401,3 +477,225 @@ impl ManagedIdentity { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::wallet::identity::PaymentEntry; + use crate::wallet::platform_wallet::WalletId; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; + use std::collections::BTreeMap; + use std::sync::Mutex; + + /// Persister that records every store so a test can inspect the exact + /// changeset `add_keys` emits. + #[derive(Default)] + struct CapturingPersister { + stores: Mutex>, + } + impl PlatformWalletPersistence for CapturingPersister { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.stores.lock().unwrap().push(changeset); + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + fn key(id: KeyID) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![0x02; 33]), + disabled_at: None, + }) + } + + /// `add_keys` records each key's breadcrumb (or `None` for watch-only) + /// in one batched changeset and lands every key in the DPP identity. + /// Pins the materialization side of the imported-identity-signing fix. + #[test] + fn add_keys_emits_breadcrumbs_per_key() { + let identity = Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }); + let mut managed = ManagedIdentity::new(identity, 0); + let wallet_id: WalletId = [0xAB; 32]; + let persister = std::sync::Arc::new(CapturingPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + + // Key 0 is re-derivable (breadcrumb + verified scalar), key 1 is + // watch-only (None). + let scalar = zeroize::Zeroizing::new([0x11u8; 32]); + managed.add_keys( + vec![ + crate::changeset::KeyWithBreadcrumb { + key: key(0), + breadcrumb: Some((wallet_id, 7, 0)), + verified_scalar: Some(scalar.clone()), + }, + crate::changeset::KeyWithBreadcrumb { + key: key(1), + breadcrumb: None, + verified_scalar: None, + }, + ], + &p, + ); + + // Both keys landed in the DPP identity. + assert_eq!(managed.identity.public_keys().len(), 2); + + let stores = persister.stores.lock().unwrap(); + let upserts = &stores + .last() + .expect("a changeset was stored") + .identity_keys + .as_ref() + .expect("identity_keys present") + .upserts; + let id = managed.id(); + assert_eq!( + upserts[&(id, 0)].derivation_indices, + Some(crate::changeset::IdentityKeyDerivationIndices { + identity_index: 7, + key_index: 0, + }), + "reproducible key carries its breadcrumb" + ); + assert_eq!(upserts[&(id, 0)].wallet_id, Some(wallet_id)); + // The verified scalar is moved into the changeset entry so the + // client persister stores it without re-deriving from a mnemonic. + assert_eq!( + upserts[&(id, 0)].private_key.as_deref(), + Some(&*scalar), + "reproducible key carries its verified scalar" + ); + assert_eq!( + upserts[&(id, 1)].derivation_indices, + None, + "watch-only key carries no breadcrumb" + ); + assert_eq!(upserts[&(id, 1)].wallet_id, None); + assert!( + upserts[&(id, 1)].private_key.is_none(), + "watch-only key carries no secret" + ); + } + + /// An empty `add_keys` is a no-op — no changeset stored. + #[test] + fn add_keys_empty_is_noop() { + let identity = Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }); + let mut managed = ManagedIdentity::new(identity, 0); + let persister = std::sync::Arc::new(CapturingPersister::default()); + let p = WalletPersister::new([0xAB; 32], std::sync::Arc::clone(&persister) as _); + managed.add_keys(Vec::new(), &p); + assert!( + persister.stores.lock().unwrap().is_empty(), + "empty add_keys stores nothing" + ); + } + + /// A scalar that arrives WITHOUT a breadcrumb is dropped, never carried: + /// the entry stays watch-only (no indices, no secret). Pins the + /// scalar/breadcrumb coupling so a verified secret can't reach the client + /// without the `(identity_index, key_index)` it needs to be stored. + #[test] + fn add_keys_drops_scalar_without_breadcrumb() { + let identity = Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }); + let mut managed = ManagedIdentity::new(identity, 0); + let persister = std::sync::Arc::new(CapturingPersister::default()); + let p = WalletPersister::new([0xAB; 32], std::sync::Arc::clone(&persister) as _); + + managed.add_keys( + vec![crate::changeset::KeyWithBreadcrumb { + key: key(0), + breadcrumb: None, + verified_scalar: Some(zeroize::Zeroizing::new([0x22u8; 32])), + }], + &p, + ); + + let stores = persister.stores.lock().unwrap(); + let upserts = &stores + .last() + .expect("a changeset was stored") + .identity_keys + .as_ref() + .expect("identity_keys present") + .upserts; + let entry = &upserts[&(managed.id(), 0)]; + assert_eq!(entry.derivation_indices, None); + assert_eq!(entry.wallet_id, None); + assert!( + entry.private_key.is_none(), + "a scalar without a breadcrumb must be dropped, not carried" + ); + } + + #[test] + fn payments_for_contact_filters_by_counterparty() { + let identity = Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }); + let mut managed = ManagedIdentity::new(identity, 0); + let alice = Identifier::from([0xAA; 32]); + let bob = Identifier::from([0xBB; 32]); + + managed + .dashpay_payments + .insert("t1".into(), PaymentEntry::new_sent(alice, 100, None)); + managed + .dashpay_payments + .insert("t2".into(), PaymentEntry::new_received(bob, 200, None)); + managed + .dashpay_payments + .insert("t3".into(), PaymentEntry::new_sent(alice, 300, None)); + + let for_alice = managed.payments_for_contact(&alice); + assert_eq!(for_alice.len(), 2, "both sent payments to alice"); + assert!(for_alice.iter().all(|(_, p)| p.counterparty_id == alice)); + assert_eq!(managed.payments_for_contact(&bob).len(), 1); + assert_eq!( + managed + .payments_for_contact(&Identifier::from([0xCC; 32])) + .len(), + 0, + "unknown contact has no payments" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 7792f6108b1..3588545a53e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -17,7 +17,9 @@ pub use crate::wallet::identity::types::key_storage::{ self, DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData, }; -use crate::wallet::identity::{ContactRequest, DashPayProfile, EstablishedContact, PaymentEntry}; +use crate::wallet::identity::{ + ContactProfileEntry, ContactRequest, DashPayProfile, EstablishedContact, PaymentEntry, +}; use dpp::identity::Identity; use dpp::prelude::Identifier; use std::collections::BTreeMap; @@ -65,6 +67,23 @@ pub struct ManagedIdentity { /// Map of incoming contact requests (not yet accepted) keyed by sender ID pub incoming_contact_requests: BTreeMap, + /// Senders this identity has chosen to **ignore** (per-sender mute, + /// reversible — the local-only equivalent of "block"). Keyed by the + /// sender's identity id. + /// + /// `ignore_sender` records a sender here so the recurring sync ingest + /// path won't resurrect *any* of that sender's still-on-platform + /// immutable `contactRequest` documents — including rotated ones with + /// a bumped `accountReference`. Suppression is per-sender by design: if + /// you ignored the person you ignored them; `unignore_sender` is the + /// "changed my mind" affordance, which also rewinds the receive cursor + /// so the next sweep re-fetches their requests. + /// + /// Local-only: there is no on-chain artifact (syncing it would leak who + /// you ignored via the public contact-request indices). Cross-device + /// sync is deferred to a future encrypted `profile` field. + pub ignored_senders: std::collections::BTreeSet, + /// Identity lifecycle status on Platform. pub status: IdentityStatus, @@ -102,6 +121,24 @@ pub struct ManagedIdentity { /// Each entry records a single Dash payment to or from a contact /// identity, with direction, amount, memo, and status. pub dashpay_payments: BTreeMap, + + /// Incremental-sync high-water marks (`$createdAt` ms of the newest + /// `contactRequest` fetched) per direction. `None` ⇒ never synced; the + /// next sweep does a full fetch. Held in memory: it survives across sweeps + /// within a session but resets to `None` on cold restart, triggering one + /// full re-fetch (safe — ingest is a fixpoint, so under-shoot is free). + /// Durable cross-relaunch persistence is a follow-up; when added, restore + /// must tolerate only under-shoot — never a value higher than the contact + /// state justifies. + pub high_water_received_ms: Option, + /// High-water mark for the sent direction (`$ownerId == me`). + pub high_water_sent_ms: Option, + + /// Cached **contact** profiles keyed by the contact's identity id — + /// established contacts, pending incoming-request senders, and (later) + /// ignored senders, independent of relationship state. Populated by + /// `sync_contact_profiles`; public-data only (never `contactInfo`-derived). + pub contact_profiles: BTreeMap, } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 7d04f29c538..67d4d602f88 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -65,6 +65,10 @@ impl IdentityManager { } } existing.dashpay_payments.extend(entry.dashpay_payments); + existing.contact_profiles.extend(entry.contact_profiles); + // Ignored senders: union (un-ignore is carried by an explicit + // `ContactChangeSet::unignored` removal, applied separately). + existing.ignored_senders.extend(entry.ignored_senders); return; } @@ -107,6 +111,8 @@ impl IdentityManager { managed.contested_dpns_names = entry.contested_dpns_names; managed.dashpay_profile = entry.dashpay_profile; managed.dashpay_payments = entry.dashpay_payments; + managed.contact_profiles = entry.contact_profiles; + managed.ignored_senders = entry.ignored_senders; self.wallet_identities .entry(wallet_id) @@ -133,6 +139,8 @@ impl IdentityManager { managed.contested_dpns_names = entry.contested_dpns_names; managed.dashpay_profile = entry.dashpay_profile; managed.dashpay_payments = entry.dashpay_payments; + managed.contact_profiles = entry.contact_profiles; + managed.ignored_senders = entry.ignored_senders; self.out_of_wallet_identities.insert(id, managed); self.location_index_insert(id, IdentityLocation::OutOfWallet); diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs index 49cfe288d5b..ffde65cc347 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs @@ -32,6 +32,22 @@ pub struct EstablishedContact { /// List of accepted account references beyond the default pub accepted_accounts: Vec, + + /// Whether this contact's payment channel is **permanently** broken. + /// + /// Set by the account-building sweep when registering the + /// counterparty's external sending account fails for a *permanent* + /// reason — a decrypt/decode failure of the encrypted xpub, or an + /// identity-key shape that can never satisfy the ECDH gate. A + /// transient failure (network) leaves this `false` so the next sweep + /// retries. Once `true`, the sweep skips this contact until the + /// underlying request changes, and the FFI/UI surfaces "payment + /// channel broken — ask the contact to send a new request" instead + /// of an unbounded retry loop. + /// + /// Defaults to `false`; a freshly established contact is never broken. + #[cfg_attr(feature = "serde", serde(default))] + pub payment_channel_broken: bool, } impl EstablishedContact { @@ -49,6 +65,42 @@ impl EstablishedContact { note: None, is_hidden: false, accepted_accounts: Vec::new(), + payment_channel_broken: false, + } + } + + /// (Re-)establish a contact while **preserving** any user metadata + /// already attached to a prior `EstablishedContact` for the same pair. + /// + /// [`EstablishedContact::new`] resets `alias` / `note` / `is_hidden` / + /// `accepted_accounts` / `payment_channel_broken` to their defaults — + /// so a naive re-establish on every recurring sweep (the sent-side + /// reconcile, or a re-ingested reciprocal) would wipe the user's alias, + /// note, hide flag, and accepted-accounts list each pass. This + /// constructor refreshes the two underlying [`ContactRequest`]s (the + /// authoritative on-platform documents may have been re-fetched) but + /// carries the metadata forward from `existing`. + /// + /// `payment_channel_broken` is **reset to `false`** on re-establish: + /// re-establishment means a fresh request flowed in, which is exactly + /// the "underlying request changed" condition under which the broken + /// flag should clear so the account-building sweep retries. + pub fn reestablish_preserving_metadata( + existing: &EstablishedContact, + outgoing_request: ContactRequest, + incoming_request: ContactRequest, + ) -> Self { + Self { + contact_identity_id: existing.contact_identity_id, + outgoing_request, + incoming_request, + alias: existing.alias.clone(), + note: existing.note.clone(), + is_hidden: existing.is_hidden, + accepted_accounts: existing.accepted_accounts.clone(), + // A new request superseded the old relationship — clear the + // broken flag so the sweep gives the rebuilt channel a chance. + payment_channel_broken: false, } } @@ -138,6 +190,50 @@ mod tests { assert_eq!(contact.note, None); assert_eq!(contact.is_hidden, false); assert_eq!(contact.accepted_accounts.len(), 0); + // A freshly established contact is never broken. + assert_eq!(contact.payment_channel_broken, false); + } + + /// `reestablish_preserving_metadata` must carry alias/note/is_hidden/ + /// accepted_accounts forward from the prior contact — the sweep + /// re-establishes on every pass, and `EstablishedContact::new` would + /// wipe the user's metadata each time. This pins that the + /// metadata-preserving path does NOT reset it. + #[test] + fn test_reestablish_preserves_user_metadata() { + let mut existing = EstablishedContact::new( + Identifier::from([2u8; 32]), + create_test_outgoing_request(), + create_test_incoming_request(), + ); + existing.set_alias("Best Friend".to_string()); + existing.set_note("Met at conference".to_string()); + existing.hide(); + existing.add_accepted_account(7); + existing.payment_channel_broken = true; + + // Re-establish with fresh request docs (newer timestamps). + let mut newer_outgoing = create_test_outgoing_request(); + newer_outgoing.created_at = 2_000_000_000; + let mut newer_incoming = create_test_incoming_request(); + newer_incoming.created_at = 2_000_000_001; + + let reestablished = EstablishedContact::reestablish_preserving_metadata( + &existing, + newer_outgoing.clone(), + newer_incoming.clone(), + ); + + // Metadata survives. + assert_eq!(reestablished.alias, Some("Best Friend".to_string())); + assert_eq!(reestablished.note, Some("Met at conference".to_string())); + assert_eq!(reestablished.is_hidden, true); + assert_eq!(reestablished.accepted_accounts, vec![7]); + // Fresh requests are adopted. + assert_eq!(reestablished.outgoing_request.created_at, 2_000_000_000); + assert_eq!(reestablished.incoming_request.created_at, 2_000_000_001); + // A superseding request clears the broken flag so the sweep retries. + assert_eq!(reestablished.payment_channel_broken, false); } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs index 7ab14a79f2e..339520651d7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs @@ -9,5 +9,6 @@ pub use contact_request::ContactRequest; pub use established_contact::EstablishedContact; pub use payment::{DashpayAddressMatch, PaymentDirection, PaymentEntry, PaymentStatus}; pub use profile::{ - calculate_avatar_hash, calculate_dhash_fingerprint, DashPayProfile, ProfileUpdate, + calculate_avatar_hash, calculate_dhash_fingerprint, ContactProfileEntry, DashPayProfile, + ProfileUpdate, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs index 06d6d415529..83fc5f9a5dc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs @@ -39,6 +39,30 @@ pub struct DashPayProfile { pub public_message: Option, } +/// A cached **contact** profile, keyed by the contact's identity id on the +/// owning [`ManagedIdentity`](crate::wallet::identity::ManagedIdentity). +/// +/// Unlike the owner's own `dashpay_profile`, this cache is relationship- +/// independent — it serves established contacts, pending incoming-request +/// senders, and (later) ignored senders from one map. Holds **only the public +/// profile fields** parsed from the on-chain `profile` document; it must never +/// receive anything derived from the encrypted `contactInfo` path. +/// +/// `profile` distinguishes three states: +/// - `Some(p)` — fetched and present; +/// - `None` — **confirmed absent** (the contact published no profile). This is +/// the negative cache that, together with `checked_at_ms`, stops the sweep +/// from re-querying a profile-less contact every tick forever. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ContactProfileEntry { + /// The fetched profile, or `None` for a confirmed-absent profile. + pub profile: Option, + /// Wall-clock ms of the last fetch attempt — drives the self-heal backoff + /// for absent profiles. (Gates re-query cost only, never correctness.) + pub checked_at_ms: u64, +} + /// Input for profile create/update operations. Only caller-provided /// fields — platform-wallet computes `avatar_hash` + `avatar_fingerprint` /// from `avatar_bytes` internally. diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs index 826d80d0c09..288f88a021c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs @@ -11,7 +11,7 @@ pub mod key_storage; pub use block_time::BlockTime; pub use dashpay::{ - ContactRequest, DashPayProfile, DashpayAddressMatch, EstablishedContact, PaymentDirection, - PaymentEntry, PaymentStatus, ProfileUpdate, + ContactProfileEntry, ContactRequest, DashPayProfile, DashpayAddressMatch, EstablishedContact, + PaymentDirection, PaymentEntry, PaymentStatus, ProfileUpdate, }; pub use key_storage::{DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData}; diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 7c22401f9c3..8ebeb97d9fb 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -45,6 +45,16 @@ pub struct PlatformWalletInfo { pub balance: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, + /// DashPay contact-crypto ops the unattended background sweep could not + /// perform because key material was unavailable (watch-only / signer + /// locked). Drained when a signer is available (Keychain unlock, or any + /// signer-present action). Secret-free — only on-chain ciphertext + + /// public key indices. In-memory for the live session: the queue is + /// persisted to the changeset, but cold-load restore is blocked upstream + /// (`ClientStartState::wallets` is not yet rehydrated), so a re-imported + /// wallet re-syncs from scratch and the sweep re-enqueues what it needs. + /// See [`PendingContactCrypto`](crate::changeset::PendingContactCrypto). + pub pending_contact_crypto: Vec, } /// A platform wallet that combines core UTXO functionality with identity management. @@ -294,6 +304,11 @@ impl PlatformWallet { asset_locks: Arc::clone(&asset_locks), persister: wallet_persister.clone(), broadcaster: dashpay_broadcaster, + // Default DashPay write seam: forwards to the live SDK. The + // network-layer tests swap this for a recording writer. + sdk_writer: Arc::new( + crate::wallet::identity::network::sdk_writer::SdkWriter::new(Arc::clone(&sdk)), + ), }; let platform = PlatformAddressWallet::new( diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index 5676a3976da..9eb89c81fbf 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -40,6 +40,7 @@ impl WalletInfoInterface for PlatformWalletInfo { balance: std::sync::Arc::new(super::core::WalletBalance::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + pending_contact_crypto: Vec::new(), } } @@ -52,6 +53,7 @@ impl WalletInfoInterface for PlatformWalletInfo { balance: std::sync::Arc::new(super::core::WalletBalance::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + pending_contact_crypto: Vec::new(), } } diff --git a/packages/rs-platform-wallet/tests/contact_workflow_tests.rs b/packages/rs-platform-wallet/tests/contact_workflow_tests.rs index dd989e195d4..fcd1de7e18e 100644 --- a/packages/rs-platform-wallet/tests/contact_workflow_tests.rs +++ b/packages/rs-platform-wallet/tests/contact_workflow_tests.rs @@ -404,3 +404,90 @@ fn test_concurrent_bidirectional_requests() { assert_eq!(managed_a.sent_contact_requests.len(), 0); assert_eq!(managed_b.sent_contact_requests.len(), 0); } + +/// G3 receive side: a rotation request (same sender, bumped +/// accountReference) must supersede the tracked state instead of being +/// dropped as a duplicate. +/// +/// - Established contact: the incoming request is replaced with the +/// rotated one and `payment_channel_broken` is cleared — a +/// superseding request is exactly the recovery the broken flag's +/// contract promises ("wait for the contact to send a new request"). +/// - Pending incoming (not yet accepted): the entry is replaced so a +/// later Accept uses the freshest key material. +#[test] +fn test_rotation_request_rekeys_established_contact_and_clears_broken_flag() { + let identity_a = create_test_identity([1u8; 32]); + let identity_b = create_test_identity([2u8; 32]); + let id_a = identity_a.id(); + let id_b = identity_b.id(); + + let mut managed_a = ManagedIdentity::new(identity_a, 0); + + // Establish A <-> B (A sent, then B's reciprocal arrives). + let request_a_to_b = create_contact_request(id_a, id_b, 0, 1000); + let request_b_to_a = create_contact_request(id_b, id_a, 0, 1001); + managed_a.add_sent_contact_request(request_a_to_b, &noop_persister()); + managed_a.add_incoming_contact_request(request_b_to_a, &noop_persister()); + assert_eq!(managed_a.established_contacts.len(), 1); + + // Simulate a broken payment channel — e.g. the old request's + // xpub stopped decrypting after B rotated keys. + managed_a + .established_contacts + .get_mut(&id_b) + .expect("established contact") + .payment_channel_broken = true; + + // B rotates: a new request with a different accountReference. + let rotated = create_contact_request(id_b, id_a, 7, 2000); + let rekeyed = managed_a.apply_rotated_incoming_request(rotated.clone(), &noop_persister()); + + assert!(rekeyed, "an established contact must report re-keying"); + let contact = managed_a + .established_contacts + .get(&id_b) + .expect("contact still established"); + assert_eq!( + contact.incoming_request.account_reference, 7, + "the rotated request must supersede the tracked incoming request" + ); + assert_eq!( + contact.incoming_request.created_at, 2000, + "the rotated request's payload must be the new one" + ); + assert!( + !contact.payment_channel_broken, + "a superseding request must clear the broken-channel flag" + ); + + // Pending-incoming variant: a not-yet-accepted request is replaced. + let identity_c = create_test_identity([3u8; 32]); + let id_c = identity_c.id(); + let pending = create_contact_request(id_c, id_a, 0, 3000); + managed_a.add_incoming_contact_request(pending, &noop_persister()); + let rotated_pending = create_contact_request(id_c, id_a, 4, 3001); + let rekeyed = managed_a.apply_rotated_incoming_request(rotated_pending, &noop_persister()); + assert!( + !rekeyed, + "pending (non-established) rotation is not a re-key" + ); + assert_eq!( + managed_a + .incoming_contact_requests + .get(&id_c) + .expect("pending request still tracked") + .account_reference, + 4, + "the pending entry must be replaced by the rotated request" + ); + + // Unknown sender: a no-op (fresh requests go through + // add_incoming_contact_request). + let identity_d = create_test_identity([4u8; 32]); + let stranger = create_contact_request(identity_d.id(), id_a, 0, 4000); + assert!(!managed_a.apply_rotated_incoming_request(stranger, &noop_persister())); + assert!(!managed_a + .incoming_contact_requests + .contains_key(&identity_d.id())); +} diff --git a/packages/rs-platform-wallet/tests/spv_sync.rs b/packages/rs-platform-wallet/tests/spv_sync.rs index adcbd4a3121..d5d38eea14a 100644 --- a/packages/rs-platform-wallet/tests/spv_sync.rs +++ b/packages/rs-platform-wallet/tests/spv_sync.rs @@ -183,7 +183,7 @@ async fn test_spv_sync_and_balance() { let platform_wallet = manager .create_wallet_from_seed_bytes( network, - seed_bytes, + &seed_bytes, WalletAccountCreationOptions::Default, None, ) diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 99ca8b6b279..8030a2e53f9 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -34,6 +34,11 @@ dash-async = { path = "../rs-dash-async" } # this crate. key-wallet = { workspace = true } +# DashPay host crypto primitives (ECDH / AES) for the Keychain signer's +# raw-secret operations, computed in-process so the scalar never crosses +# FFI. Single source of truth — reused, not re-implemented. +platform-encryption = { path = "../rs-platform-encryption" } + # Single source of truth for the Network enum and its `#[repr(C)]` # FFI variant, both used directly across this crate's FFI surface. dash-network = { workspace = true, features = ["ffi"] } diff --git a/packages/rs-sdk-ffi/src/dashpay/contact_request.rs b/packages/rs-sdk-ffi/src/dashpay/contact_request.rs deleted file mode 100644 index 0a8d79c69b7..00000000000 --- a/packages/rs-sdk-ffi/src/dashpay/contact_request.rs +++ /dev/null @@ -1,726 +0,0 @@ -//! DashPay contact request operations - -use crate::{ - signer::{VTableSigner, VTableSignerRef}, - utils, DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError, SDKHandle, SDKWrapper, -}; -use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, SecretKey}; -use dash_sdk::dpp::identity::{Identity, IdentityPublicKey}; -use dash_sdk::platform::dashpay::{ - ContactRequestInput, ContactRequestResult, EcdhProvider, RecipientIdentity, - SendContactRequestInput, SendContactRequestResult, -}; -use dash_sdk::{Error, Sdk}; -use std::ffi::CStr; -use std::sync::Arc; - -// Helper functions to work around Rust type inference limitations with complex generic enums - -async fn create_contact_request_with_shared_secret( - sdk: &Sdk, - input: ContactRequestInput, - shared_secret: [u8; 32], - extended_public_key: Vec, -) -> Result { - // Use turbofish to help with type inference - specify dummy types for unused F/Fut - type DummyF = fn( - &IdentityPublicKey, - u32, - ) -> std::pin::Pin< - Box> + Send>, - >; - type DummyFut = - std::pin::Pin> + Send>>; - - sdk.create_contact_request::( - input, - EcdhProvider::ClientSide { - get_shared_secret: move |_public_key: &PublicKey| async move { Ok(shared_secret) }, - }, - move |_account_ref| async move { Ok(extended_public_key.clone()) }, - ) - .await -} - -async fn create_contact_request_with_private_key( - sdk: &Sdk, - input: ContactRequestInput, - private_key: SecretKey, - extended_public_key: Vec, -) -> Result { - // Use turbofish to help with type inference - specify dummy types for unused G/Gut - type DummyG = fn( - &PublicKey, - ) -> std::pin::Pin< - Box> + Send>, - >; - type DummyGut = - std::pin::Pin> + Send>>; - - sdk.create_contact_request::<_, _, DummyG, DummyGut, _, _>( - input, - EcdhProvider::SdkSide { - get_private_key: move |_key: &IdentityPublicKey, _index: u32| async move { - Ok(private_key) - }, - }, - move |_account_ref| async move { Ok(extended_public_key.clone()) }, - ) - .await -} - -async fn send_contact_request_with_shared_secret< - S: dash_sdk::dpp::identity::signer::Signer, ->( - sdk: &Sdk, - send_input: SendContactRequestInput, - shared_secret: [u8; 32], - extended_public_key: Vec, -) -> Result { - // Use turbofish to help with type inference - specify dummy types for unused F/Fut - type DummyF = fn( - &IdentityPublicKey, - u32, - ) -> std::pin::Pin< - Box> + Send>, - >; - type DummyFut = - std::pin::Pin> + Send>>; - - sdk.send_contact_request::( - send_input, - EcdhProvider::ClientSide { - get_shared_secret: move |_public_key: &PublicKey| async move { Ok(shared_secret) }, - }, - move |_account_ref| async move { Ok(extended_public_key.clone()) }, - ) - .await -} - -async fn send_contact_request_with_private_key< - S: dash_sdk::dpp::identity::signer::Signer, ->( - sdk: &Sdk, - send_input: SendContactRequestInput, - private_key: SecretKey, - extended_public_key: Vec, -) -> Result { - // Use turbofish to help with type inference - specify dummy types for unused G/Gut - type DummyG = fn( - &PublicKey, - ) -> std::pin::Pin< - Box> + Send>, - >; - type DummyGut = - std::pin::Pin> + Send>>; - - sdk.send_contact_request::( - send_input, - EcdhProvider::SdkSide { - get_private_key: move |_key: &IdentityPublicKey, _index: u32| async move { - Ok(private_key) - }, - }, - move |_account_ref| async move { Ok(extended_public_key.clone()) }, - ) - .await -} - -/// ECDH mode for contact request encryption -#[repr(C)] -pub enum DashSDKEcdhMode { - /// Client performs ECDH and provides the shared secret (for hardware wallets) - ClientSide = 0, - /// SDK performs ECDH using the provided private key (for software wallets) - SdkSide = 1, -} - -/// Input parameters for creating a contact request -#[repr(C)] -pub struct DashSDKContactRequestParams { - /// The sender identity handle - pub sender_identity: *const std::os::raw::c_void, - /// The recipient identity ID (32 bytes) - pub recipient_id: *const u8, - /// Whether to fetch the recipient identity (true) or use provided recipient_identity - pub fetch_recipient: bool, - /// The recipient identity handle (if fetch_recipient is false) - pub recipient_identity: *const std::os::raw::c_void, - /// The sender's encryption key index - pub sender_key_index: u32, - /// The recipient's encryption key index - pub recipient_key_index: u32, - /// Reference to the DashPay receiving account - pub account_reference: u32, - /// Optional account label (NUL-terminated C string, unencrypted) - pub account_label: *const std::os::raw::c_char, - /// Optional auto-accept proof bytes - pub auto_accept_proof: *const u8, - /// Length of auto_accept_proof (0 if not provided, must be 38-102 if provided) - pub auto_accept_proof_len: usize, - /// ECDH mode (ClientSide or SdkSide) - pub ecdh_mode: DashSDKEcdhMode, - /// For SdkSide: the sender's private key (32 bytes) - /// For ClientSide: ignored (can be null) - pub sender_private_key: *const u8, - /// For ClientSide: the shared secret (32 bytes) - /// For SdkSide: ignored (can be null) - pub shared_secret: *const u8, - /// The extended public key to share (unencrypted, typically 78 bytes) - pub extended_public_key: *const u8, - /// Length of extended_public_key - pub extended_public_key_len: usize, -} - -/// Result of creating a contact request -#[repr(C)] -pub struct DashSDKContactRequestResult { - /// Document ID as hex string - pub document_id: *mut std::os::raw::c_char, - /// Owner ID (sender ID) as hex string - pub owner_id: *mut std::os::raw::c_char, - /// Document properties as JSON string - pub properties_json: *mut std::os::raw::c_char, -} - -/// Result of sending a contact request -#[repr(C)] -pub struct DashSDKSendContactRequestResult { - /// The created document as JSON string - pub document_json: *mut std::os::raw::c_char, - /// Recipient identity ID as hex string - pub recipient_id: *mut std::os::raw::c_char, - /// Account reference - pub account_reference: u32, -} - -/// Create a contact request document -/// -/// This creates a local contact request document according to DIP-15 specification. -/// The document is not yet submitted to the platform. -/// -/// # Safety -/// - `handle` must be a valid SDK handle -/// - All pointer parameters must be valid for their specified types -/// - String parameters must be NUL-terminated -/// - Byte array parameters must have valid lengths -/// -/// # Returns -/// Returns a DashSDKContactRequestResult on success -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_dashpay_create_contact_request( - handle: *const SDKHandle, - params: *const DashSDKContactRequestParams, -) -> DashSDKResult { - if handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if params.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Parameters are null".to_string(), - )); - } - - let params = &*params; - let wrapper = &*(handle as *const SDKWrapper); - let sdk = &wrapper.sdk; - - // Validate required parameters - if params.sender_identity.is_null() || params.recipient_id.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Sender identity or recipient ID is null".to_string(), - )); - } - - if params.extended_public_key.is_null() || params.extended_public_key_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Extended public key is null or empty".to_string(), - )); - } - - // Get sender identity from handle - let sender_identity_arc = Arc::from_raw(params.sender_identity as *const Identity); - let sender_identity = (*sender_identity_arc).clone(); - std::mem::forget(sender_identity_arc); - - // Parse recipient ID - let recipient_id_bytes = std::slice::from_raw_parts(params.recipient_id, 32); - let recipient_id = match dash_sdk::dpp::prelude::Identifier::from_bytes(recipient_id_bytes) { - Ok(id) => id, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid recipient ID: {}", e), - )); - } - }; - - // Determine recipient (fetch or use provided) - let recipient = if params.fetch_recipient { - RecipientIdentity::Identifier(recipient_id) - } else { - if params.recipient_identity.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Recipient identity is null but fetch_recipient is false".to_string(), - )); - } - let recipient_identity_arc = Arc::from_raw(params.recipient_identity as *const Identity); - let recipient_identity = (*recipient_identity_arc).clone(); - std::mem::forget(recipient_identity_arc); - RecipientIdentity::Identity(recipient_identity) - }; - - // Parse account label if provided - let account_label = if !params.account_label.is_null() { - match CStr::from_ptr(params.account_label).to_str() { - Ok(s) => Some(s.to_string()), - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid UTF-8 in account label: {}", e), - )); - } - } - } else { - None - }; - - // Parse auto-accept proof if provided - let auto_accept_proof = - if !params.auto_accept_proof.is_null() && params.auto_accept_proof_len > 0 { - Some( - std::slice::from_raw_parts(params.auto_accept_proof, params.auto_accept_proof_len) - .to_vec(), - ) - } else { - None - }; - - // Get extended public key - let extended_public_key = - std::slice::from_raw_parts(params.extended_public_key, params.extended_public_key_len) - .to_vec(); - - // Create input - let input = ContactRequestInput { - sender_identity, - recipient, - sender_key_index: params.sender_key_index, - recipient_key_index: params.recipient_key_index, - account_reference: params.account_reference, - account_label, - auto_accept_proof, - }; - - // Create ECDH provider and call SDK based on mode - let result = match params.ecdh_mode { - DashSDKEcdhMode::ClientSide => { - // Client provides shared secret - if params.shared_secret.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Shared secret is null for ClientSide ECDH mode".to_string(), - )); - } - - let shared_secret_bytes = std::slice::from_raw_parts(params.shared_secret, 32); - let mut shared_secret = [0u8; 32]; - shared_secret.copy_from_slice(shared_secret_bytes); - - wrapper.runtime.block_on(async { - create_contact_request_with_shared_secret( - sdk, - input, - shared_secret, - extended_public_key, - ) - .await - .map_err(FFIError::from) - }) - } - DashSDKEcdhMode::SdkSide => { - // SDK performs ECDH with private key - if params.sender_private_key.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Sender private key is null for SdkSide ECDH mode".to_string(), - )); - } - - let private_key_bytes = std::slice::from_raw_parts(params.sender_private_key, 32); - let private_key = match SecretKey::from_slice(private_key_bytes) { - Ok(key) => key, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid private key: {}", e), - )); - } - }; - - wrapper.runtime.block_on(async { - create_contact_request_with_private_key( - sdk, - input, - private_key, - extended_public_key, - ) - .await - .map_err(FFIError::from) - }) - } - }; - - match result { - Ok(contact_request_result) => { - // Convert document ID to hex string - let document_id_hex = contact_request_result - .id - .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); - let document_id_cstring = match utils::c_string_from(document_id_hex) { - Ok(s) => s, - Err(e) => return DashSDKResult::error(e), - }; - - // Convert owner ID to hex string - let owner_id_hex = contact_request_result - .owner_id - .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); - let owner_id_cstring = match utils::c_string_from(owner_id_hex) { - Ok(s) => s, - Err(e) => { - // Clean up document ID string - let _ = std::ffi::CString::from_raw(document_id_cstring); - return DashSDKResult::error(e); - } - }; - - // Convert properties to JSON - let properties_json = match serde_json::to_string(&contact_request_result.properties) { - Ok(json) => json, - Err(e) => { - // Clean up previous strings - let _ = std::ffi::CString::from_raw(document_id_cstring); - let _ = std::ffi::CString::from_raw(owner_id_cstring); - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::SerializationError, - format!("Failed to serialize properties: {}", e), - )); - } - }; - - let properties_cstring = match utils::c_string_from(properties_json) { - Ok(s) => s, - Err(e) => { - // Clean up previous strings - let _ = std::ffi::CString::from_raw(document_id_cstring); - let _ = std::ffi::CString::from_raw(owner_id_cstring); - return DashSDKResult::error(e); - } - }; - - // Create result structure - let result = Box::new(DashSDKContactRequestResult { - document_id: document_id_cstring, - owner_id: owner_id_cstring, - properties_json: properties_cstring, - }); - - DashSDKResult::success(Box::into_raw(result) as *mut std::os::raw::c_void) - } - Err(e) => DashSDKResult::error(e.into()), - } -} - -/// Send a contact request to the platform -/// -/// This creates a contact request document and submits it to the platform. -/// -/// # Safety -/// - All parameters must be valid -/// - Signer must be valid and not previously freed -/// -/// # Returns -/// Returns a DashSDKSendContactRequestResult on success -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_dashpay_send_contact_request( - handle: *const SDKHandle, - params: *const DashSDKContactRequestParams, - identity_public_key: *const std::os::raw::c_void, - signer: *const std::os::raw::c_void, -) -> DashSDKResult { - if handle.is_null() || params.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle or parameters are null".to_string(), - )); - } - - if identity_public_key.is_null() || signer.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Identity public key or signer is null".to_string(), - )); - } - - let params = &*params; - let wrapper = &*(handle as *const SDKWrapper); - let sdk = &wrapper.sdk; - - // Validate required parameters (same as create_contact_request) - if params.sender_identity.is_null() || params.recipient_id.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Sender identity or recipient ID is null".to_string(), - )); - } - - if params.extended_public_key.is_null() || params.extended_public_key_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Extended public key is null or empty".to_string(), - )); - } - - // Get sender identity from handle - let sender_identity_arc = Arc::from_raw(params.sender_identity as *const Identity); - let sender_identity = (*sender_identity_arc).clone(); - std::mem::forget(sender_identity_arc); - - // Parse recipient ID - let recipient_id_bytes = std::slice::from_raw_parts(params.recipient_id, 32); - let recipient_id = match dash_sdk::dpp::prelude::Identifier::from_bytes(recipient_id_bytes) { - Ok(id) => id, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid recipient ID: {}", e), - )); - } - }; - - // Determine recipient (fetch or use provided) - let recipient = if params.fetch_recipient { - RecipientIdentity::Identifier(recipient_id) - } else { - if params.recipient_identity.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Recipient identity is null but fetch_recipient is false".to_string(), - )); - } - let recipient_identity_arc = Arc::from_raw(params.recipient_identity as *const Identity); - let recipient_identity = (*recipient_identity_arc).clone(); - std::mem::forget(recipient_identity_arc); - RecipientIdentity::Identity(recipient_identity) - }; - - // Parse account label if provided - let account_label = if !params.account_label.is_null() { - match CStr::from_ptr(params.account_label).to_str() { - Ok(s) => Some(s.to_string()), - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid UTF-8 in account label: {}", e), - )); - } - } - } else { - None - }; - - // Parse auto-accept proof if provided - let auto_accept_proof = - if !params.auto_accept_proof.is_null() && params.auto_accept_proof_len > 0 { - Some( - std::slice::from_raw_parts(params.auto_accept_proof, params.auto_accept_proof_len) - .to_vec(), - ) - } else { - None - }; - - // Get extended public key - let extended_public_key = - std::slice::from_raw_parts(params.extended_public_key, params.extended_public_key_len) - .to_vec(); - - // Get identity public key from handle - let key_arc = Arc::from_raw(identity_public_key as *const IdentityPublicKey); - let key_clone = (*key_arc).clone(); - std::mem::forget(key_arc); - - // Get signer from handle (non-owning reference — the handle remains - // owned by the caller and is freed via `dash_sdk_signer_destroy`). - let signer_ref = VTableSignerRef(&*(signer as *const VTableSigner)); - - // Create contact request input - let contact_request_input = ContactRequestInput { - sender_identity, - recipient, - sender_key_index: params.sender_key_index, - recipient_key_index: params.recipient_key_index, - account_reference: params.account_reference, - account_label, - auto_accept_proof, - }; - - // Create send input - let send_input = SendContactRequestInput { - contact_request: contact_request_input, - identity_public_key: key_clone, - signer: signer_ref, - }; - - // Send contact request based on ECDH mode - let result = match params.ecdh_mode { - DashSDKEcdhMode::ClientSide => { - if params.shared_secret.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Shared secret is null for ClientSide ECDH mode".to_string(), - )); - } - - let shared_secret_bytes = std::slice::from_raw_parts(params.shared_secret, 32); - let mut shared_secret = [0u8; 32]; - shared_secret.copy_from_slice(shared_secret_bytes); - - wrapper.runtime.block_on(async { - send_contact_request_with_shared_secret( - sdk, - send_input, - shared_secret, - extended_public_key, - ) - .await - .map_err(FFIError::from) - }) - } - DashSDKEcdhMode::SdkSide => { - if params.sender_private_key.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Sender private key is null for SdkSide ECDH mode".to_string(), - )); - } - - let private_key_bytes = std::slice::from_raw_parts(params.sender_private_key, 32); - let private_key = match SecretKey::from_slice(private_key_bytes) { - Ok(key) => key, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid private key: {}", e), - )); - } - }; - - wrapper.runtime.block_on(async { - send_contact_request_with_private_key( - sdk, - send_input, - private_key, - extended_public_key, - ) - .await - .map_err(FFIError::from) - }) - } - }; - - match result { - Ok(send_result) => { - // Serialize document to JSON - let document_json = match serde_json::to_string(&send_result.document) { - Ok(json) => json, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::SerializationError, - format!("Failed to serialize document: {}", e), - )); - } - }; - - let document_cstring = match utils::c_string_from(document_json) { - Ok(s) => s, - Err(e) => return DashSDKResult::error(e), - }; - - // Convert recipient ID to hex string - let recipient_id_hex = send_result - .recipient_id - .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); - let recipient_id_cstring = match utils::c_string_from(recipient_id_hex) { - Ok(s) => s, - Err(e) => { - // Clean up document string - let _ = std::ffi::CString::from_raw(document_cstring); - return DashSDKResult::error(e); - } - }; - - // Create result structure - let result = Box::new(DashSDKSendContactRequestResult { - document_json: document_cstring, - recipient_id: recipient_id_cstring, - account_reference: send_result.account_reference, - }); - - DashSDKResult::success(Box::into_raw(result) as *mut std::os::raw::c_void) - } - Err(e) => DashSDKResult::error(e.into()), - } -} - -/// Free a contact request result -/// -/// # Safety -/// - `result` must be a valid DashSDKContactRequestResult pointer -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_dashpay_contact_request_result_free( - result: *mut DashSDKContactRequestResult, -) { - if !result.is_null() { - let result = Box::from_raw(result); - - if !result.document_id.is_null() { - let _ = std::ffi::CString::from_raw(result.document_id); - } - if !result.owner_id.is_null() { - let _ = std::ffi::CString::from_raw(result.owner_id); - } - if !result.properties_json.is_null() { - let _ = std::ffi::CString::from_raw(result.properties_json); - } - } -} - -/// Free a send contact request result -/// -/// # Safety -/// - `result` must be a valid DashSDKSendContactRequestResult pointer -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_dashpay_send_contact_request_result_free( - result: *mut DashSDKSendContactRequestResult, -) { - if !result.is_null() { - let result = Box::from_raw(result); - - if !result.document_json.is_null() { - let _ = std::ffi::CString::from_raw(result.document_json); - } - if !result.recipient_id.is_null() { - let _ = std::ffi::CString::from_raw(result.recipient_id); - } - } -} diff --git a/packages/rs-sdk-ffi/src/dashpay/mod.rs b/packages/rs-sdk-ffi/src/dashpay/mod.rs deleted file mode 100644 index b18a8b6c7f4..00000000000 --- a/packages/rs-sdk-ffi/src/dashpay/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! DashPay operations - -mod contact_request; - -pub use contact_request::*; diff --git a/packages/rs-sdk-ffi/src/lib.rs b/packages/rs-sdk-ffi/src/lib.rs index 927f1da3311..8b7ac150cd1 100644 --- a/packages/rs-sdk-ffi/src/lib.rs +++ b/packages/rs-sdk-ffi/src/lib.rs @@ -11,7 +11,6 @@ mod contested_resource; mod context_callbacks; pub mod context_provider; mod crypto; -mod dashpay; mod data_contract; mod document; mod dpns; @@ -44,7 +43,6 @@ pub use contested_resource::*; pub use context_callbacks::*; pub use context_provider::*; pub use crypto::*; -pub use dashpay::*; pub use data_contract::*; pub use document::*; pub use dpns::*; diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index ade11828594..56f29c25009 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -45,17 +45,20 @@ //! - **`Zeroizing` wrappers** scrub on `Drop` for the byte-buffer //! intermediates: the resolver mnemonic buffer, the BIP-39 seed, //! and the final derived 32-byte scalar. -//! - **Explicit `non_secure_erase` calls** scrub the +//! - **The `WipingXprv` RAII guard** scrubs the //! [`secp256k1::SecretKey`] scalars inside the two intermediate -//! [`ExtendedPrivKey`] values (master + derived). `ExtendedPrivKey` -//! has no `Drop` / `Zeroize` impl in `key-wallet`, so falling out -//! of scope alone would leave those scalars resident; the explicit -//! wipe at the bottom of `derive_priv` closes the gap. Same -//! defense is applied at the sign-site for the `SecretKey` copy -//! `from_slice` creates. A proper fix is a `Zeroize` / -//! `ZeroizeOnDrop` impl in `dashpay/rust-dashcore`'s -//! `key-wallet/src/bip32.rs`; until that ships, the local wipes -//! keep the no-residue invariant true. +//! [`ExtendedPrivKey`] values (master + derived) on `Drop`. +//! `ExtendedPrivKey` has no `Drop` / `Zeroize` impl in `key-wallet`, +//! so falling out of scope alone would leave those scalars resident; +//! wrapping them in the guard wipes on **every** exit path — normal +//! return, `?` error propagation, and panic unwind — so the shared +//! `resolve_derived_xprv` helper and every consumer (`derive_priv`, +//! `extended_public_key`, `ecdh_shared_secret`) inherit the wipe +//! without hand-placed calls. Same defense is applied at the sign-site +//! for the `SecretKey` copy `from_slice` creates. A proper fix is a +//! `Zeroize` / `ZeroizeOnDrop` impl in `dashpay/rust-dashcore`'s +//! `key-wallet/src/bip32.rs`; until that ships, the guard keeps the +//! no-residue invariant true. //! //! Combined, no private key bytes survive past the trait-method //! boundary. @@ -64,7 +67,7 @@ use std::ffi::c_void; use std::os::raw::c_char; use async_trait::async_trait; -use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; +use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::secp256k1::{self, Secp256k1}; use key_wallet::signer::{Signer, SignerMethod}; use key_wallet::Network; @@ -222,18 +225,29 @@ impl MnemonicResolverCoreSigner { } } - /// Resolve the mnemonic from the Swift-side callback, then - /// derive the secp256k1 private key at `path`. Returns the raw - /// 32-byte scalar in a `Zeroizing` wrapper so the caller's last - /// drop point zeros it. + /// Resolve the mnemonic from the Swift-side callback and derive the + /// BIP-32 [`ExtendedPrivKey`] at `path`, returning `(master, derived)`. /// - /// All other intermediate buffers (mnemonic, seed) are dropped - /// (and zeroed) before this method returns — only the final - /// derived scalar leaks out, and even that is `Zeroizing`-wrapped. - fn derive_priv( + /// Single source of truth for the resolve → parse → seed → master → + /// `derive_priv` chain shared by [`Self::derive_priv`] (which reads the + /// derived scalar) and the [`Signer::extended_public_key`] method (which + /// computes the public xpub). All intermediate byte buffers (mnemonic, + /// seed) are dropped — and zeroed via `Zeroizing` — before this method + /// returns. + /// + /// # Zeroization contract + /// + /// The returned `master` / `derived` are each wrapped in [`WipingXprv`], + /// whose `Drop` scrubs the inner `secp256k1::SecretKey` scalar (which + /// [`ExtendedPrivKey`] does not wipe on its own). So both scalars are wiped + /// on **every** exit path of the caller — normal return, `?` error + /// propagation, and panic unwind — with no hand-placed `non_secure_erase` + /// required. `master` is wrapped the instant it exists, so it is scrubbed + /// even if the path derivation below fails. + fn resolve_derived_xprv( &self, path: &DerivationPath, - ) -> Result, MnemonicResolverSignerError> { + ) -> Result<(WipingXprv, WipingXprv), MnemonicResolverSignerError> { if self.resolver_addr == 0 { return Err(MnemonicResolverSignerError::NullHandle); } @@ -291,31 +305,265 @@ impl MnemonicResolverCoreSigner { drop(mnemonic); let secp = Secp256k1::new(); - let mut master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) - .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")))?; - let mut derived = master - .derive_priv(&secp, path) - .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; - - // `secret_bytes()` returns a plain `[u8; 32]`; wrap in - // `Zeroizing` so the caller (and any panic-unwind path) - // wipes it on drop. - let bytes = Zeroizing::new(derived.private_key.secret_bytes()); - - // TODO(upstream): `key_wallet::bip32::ExtendedPrivKey` has no - // `Drop` / `Zeroize` impl — the inner `secp256k1::SecretKey` - // scalars on `master` and `derived` would otherwise drop - // un-wiped. Mirrors the SecretKey-copy hole CodeRabbit R7 - // flagged at the sign-site. Proper fix is a `Zeroize` / - // `ZeroizeOnDrop` impl in `dashpay/rust-dashcore`'s - // `key-wallet/src/bip32.rs`; until that lands, wipe the two - // SecretKey fields explicitly here. Mirrored in the sibling - // FFI at `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`. - master.private_key.non_secure_erase(); - derived.private_key.non_secure_erase(); + // Wrap `master` in its wiping guard the instant it exists, so its scalar + // is scrubbed even if the path derivation below returns `Err`. + let master = WipingXprv( + ExtendedPrivKey::new_master(self.network, seed.as_ref()).map_err(|e| { + MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")) + })?, + ); + let derived = + WipingXprv(master.key().derive_priv(&secp, path).map_err(|e| { + MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")) + })?); + + Ok((master, derived)) + } + + /// Resolve the mnemonic from the Swift-side callback, then + /// derive the secp256k1 private key at `path`. Returns the raw + /// 32-byte scalar in a `Zeroizing` wrapper so the caller's last + /// drop point zeros it. + /// + /// All other intermediate buffers (mnemonic, seed) are dropped + /// (and zeroed) before this method returns — only the final + /// derived scalar leaks out, and even that is `Zeroizing`-wrapped. + fn derive_priv( + &self, + path: &DerivationPath, + ) -> Result, MnemonicResolverSignerError> { + let (_master, derived) = self.resolve_derived_xprv(path)?; + + // `secret_bytes()` returns a plain `[u8; 32]`; wrap in `Zeroizing` so + // the caller (and any panic-unwind path) wipes it on drop. The + // `WipingXprv` guards scrub `master`/`derived`'s scalars when they drop + // at the end of this scope, on every exit path. + let bytes = Zeroizing::new(derived.key().private_key.secret_bytes()); Ok(bytes) } + + /// Export the raw auto-accept private scalar at `path` (DIP-15 QR + /// auto-accept) — the **one deliberate raw-key export** from this signer + /// (every other method returns only a derived product, never the scalar). + /// The auto-accept key is a shareable, expiry-bounded bearer credential the + /// owner embeds in a QR (`dapk`), so it must leave the signer. + /// + /// Scoped by defense-in-depth: `path` MUST be an auto-accept path + /// (`m/9'/coin_type'/16'/expiry'`, 4 components with `9'` purpose + `16'` + /// feature) — otherwise this errors, so it cannot be repurposed to + /// exfiltrate a signing or identity key. Returns the 32-byte scalar + /// `Zeroizing`-wrapped (the QR encoder copies it; the wrapper wipes the + /// temporary on drop). + pub fn export_auto_accept_private_key( + &self, + path: &DerivationPath, + ) -> Result, MnemonicResolverSignerError> { + let purpose9 = ChildNumber::from_hardened_idx(9) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; + let feature16 = ChildNumber::from_hardened_idx(16) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(e.to_string()))?; + let comps: &[ChildNumber] = path.as_ref(); + if comps.len() != 4 || comps[0] != purpose9 || comps[2] != feature16 { + return Err(MnemonicResolverSignerError::DerivationFailed( + "export_auto_accept_private_key: path is not an auto-accept path".to_string(), + )); + } + self.derive_priv(path) + } + + /// Compute the DIP-15 ECDH shared secret between our identity-encryption + /// key (derived at `path`) and the contact's `peer_pubkey`, entirely + /// in-process. The derived private scalar never leaves this function — + /// only the ECDH *product* is returned (safe to use as the symmetric key + /// for the caller's AES step; it is not the raw scalar). + /// + /// Reuses [`platform_encryption::derive_shared_key_ecdh`] — the single + /// ECDH source (`SHA256((y&1|2) ‖ x)`) — so the result is byte-identical + /// to the resident-seed path it replaces (pinned by a parity test). The + /// scalar is scrubbed by the [`WipingXprv`] guard before returning. + /// + /// Sync (the derivation is CPU-bound + the resolver call is synchronous); + /// the [`EcdhProvider::ClientSide`] closure that consumes it wraps it in a + /// future at the FFI seam. + pub fn ecdh_shared_secret( + &self, + path: &DerivationPath, + peer_pubkey: &secp256k1::PublicKey, + ) -> Result, MnemonicResolverSignerError> { + let (_master, derived) = self.resolve_derived_xprv(path)?; + // Read the scalar by reference; the `WipingXprv` guards scrub both + // scalars on drop. + let shared = + platform_encryption::derive_shared_key_ecdh(&derived.key().private_key, peer_pubkey); + Ok(Zeroizing::new(shared)) + } + + /// DIP-15 `accountReference` for a contact-request send, computed entirely + /// in-process. Derive the sender's ECDH private scalar at `path` and feed it + /// (as the HMAC key) to [`platform_encryption::calculate_account_reference`] + /// over the 69-byte compact xpub. This is the same scalar + /// [`Self::ecdh_shared_secret`] uses; it never leaves the signer (the stack + /// copy is `Zeroizing`-scrubbed and the derived key by the `WipingXprv` + /// guard), so the masked reference is produced without the resident seed. + pub fn account_reference( + &self, + path: &DerivationPath, + compact_xpub: &[u8], + account_index: u32, + version: u32, + ) -> Result { + let (_master, derived) = self.resolve_derived_xprv(path)?; + let secret = Zeroizing::new(derived.key().private_key.secret_bytes()); + Ok(platform_encryption::calculate_account_reference( + &secret, + compact_xpub, + account_index, + version, + )) + } + + /// Inverse of [`Self::account_reference`]: recover `(version, account_index)` + /// from a masked reference using the same in-process scalar. Used on re-send + /// to read the previous rotation version without the resident seed. + pub fn unmask_account_reference( + &self, + path: &DerivationPath, + compact_xpub: &[u8], + account_reference: u32, + ) -> Result<(u32, u32), MnemonicResolverSignerError> { + let (_master, derived) = self.resolve_derived_xprv(path)?; + let secret = Zeroizing::new(derived.key().private_key.secret_bytes()); + Ok(platform_encryption::unmask_account_reference( + account_reference, + &secret, + compact_xpub, + )) + } + + /// Derive the 32-byte AES key for one DIP-15 contactInfo feature + /// (`encToUserId` = 65536, `privateData` = 65537) at + /// `root_path / feature' / derivation_index'`. The scalar is wiped by the + /// `WipingXprv` guard; the returned key bytes are `Zeroizing`-wrapped. + fn derive_contact_info_aes_key( + &self, + root_path: &DerivationPath, + feature: u32, + derivation_index: u32, + ) -> Result, MnemonicResolverSignerError> { + let path = root_path.clone().extend([ + ChildNumber::from_hardened_idx(feature).map_err(|e| { + MnemonicResolverSignerError::DerivationFailed(format!("contactInfo feature: {e}")) + })?, + ChildNumber::from_hardened_idx(derivation_index).map_err(|e| { + MnemonicResolverSignerError::DerivationFailed(format!("contactInfo index: {e}")) + })?, + ]); + let (_master, derived) = self.resolve_derived_xprv(&path)?; + Ok(Zeroizing::new(derived.key().private_key.secret_bytes())) + } + + /// DIP-15 contactInfo **seal**: encrypt `contact_id` (`encToUserId`, + /// AES-256-ECB) and `private_data_plaintext` (`privateData`, AES-256-CBC + /// with `private_data_iv`) under the two hardened-child keys at `root_path`, + /// entirely in-process. Reuses `platform_encryption` (the single AES + /// source); the DIP-15 wire codec (length prefixes etc.) stays in the + /// caller — this handles only the key derivation + AES. + pub fn contact_info_seal( + &self, + root_path: &DerivationPath, + derivation_index: u32, + contact_id: &[u8; 32], + private_data_plaintext: &[u8], + private_data_iv: &[u8; 16], + ) -> Result { + let enc_key = self.derive_contact_info_aes_key(root_path, 65536, derivation_index)?; + let priv_key = self.derive_contact_info_aes_key(root_path, 65537, derivation_index)?; + Ok(ContactInfoSealed { + enc_to_user_id: platform_encryption::encrypt_enc_to_user_id(&enc_key, contact_id), + private_data: platform_encryption::encrypt_private_data( + &priv_key, + private_data_iv, + private_data_plaintext, + ), + }) + } + + /// DIP-15 contactInfo **open**: inverse of [`Self::contact_info_seal`] — + /// recover the contact id + private-data plaintext. + pub fn contact_info_open( + &self, + root_path: &DerivationPath, + derivation_index: u32, + enc_to_user_id: &[u8; 32], + private_data_blob: &[u8], + ) -> Result { + let enc_key = self.derive_contact_info_aes_key(root_path, 65536, derivation_index)?; + let priv_key = self.derive_contact_info_aes_key(root_path, 65537, derivation_index)?; + let private_data = platform_encryption::decrypt_private_data(&priv_key, private_data_blob) + .map_err(|e| { + MnemonicResolverSignerError::DerivationFailed(format!("contactInfo decrypt: {e}")) + })?; + Ok(ContactInfoOpened { + contact_id: platform_encryption::decrypt_enc_to_user_id(&enc_key, enc_to_user_id), + private_data, + }) + } + +} + +/// Result of [`MnemonicResolverCoreSigner::contact_info_seal`]. +pub struct ContactInfoSealed { + /// `encToUserId` ciphertext (AES-256-ECB of the 32-byte contact id). + pub enc_to_user_id: [u8; 32], + /// `privateData` ciphertext (`iv ‖ AES-256-CBC`). + pub private_data: Vec, +} + +/// Result of [`MnemonicResolverCoreSigner::contact_info_open`]. +pub struct ContactInfoOpened { + /// The recovered 32-byte contact id. + pub contact_id: [u8; 32], + /// The recovered private-data plaintext. + pub private_data: Vec, +} + +/// RAII guard that scrubs an [`ExtendedPrivKey`]'s secret scalar on drop. +/// +/// `key_wallet::bip32::ExtendedPrivKey` has no `Drop` / `Zeroize` impl, so its +/// inner `secp256k1::SecretKey` would otherwise survive in memory on *every* +/// exit path — normal return, early `?` error propagation, and panic unwind. +/// Moving the wipe into `Drop` makes it run on all of them, instead of only +/// where a `non_secure_erase()` was hand-placed. (`non_secure_erase` is +/// secp256k1's best-effort scrub — the strongest tool until `ExtendedPrivKey` +/// gains a `ZeroizeOnDrop` upstream.) The sibling FFI at +/// `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs` has the same +/// un-wiped-on-error gap and wants the same guard. +struct WipingXprv(ExtendedPrivKey); + +impl WipingXprv { + #[inline] + fn key(&self) -> &ExtendedPrivKey { + &self.0 + } +} + +impl Drop for WipingXprv { + fn drop(&mut self) { + self.0.private_key.non_secure_erase(); + } +} + +/// RAII guard that scrubs a `secp256k1::SecretKey`'s scalar on drop. `from_slice` +/// allocates a 32-byte scalar copy distinct from the `Zeroizing` source bytes, +/// and `SecretKey` has no `Drop` wipe of its own — so without this the copy would +/// survive a panic between construction and signing. `WipingXprv` for the leaf key. +struct WipingSecretKey(secp256k1::SecretKey); + +impl Drop for WipingSecretKey { + fn drop(&mut self) { + self.0.non_secure_erase(); + } } #[async_trait] @@ -336,32 +584,46 @@ impl Signer for MnemonicResolverCoreSigner { ) -> Result<(secp256k1::ecdsa::Signature, secp256k1::PublicKey), Self::Error> { let secret_bytes = self.derive_priv(path)?; let secp = Secp256k1::new(); - // `SecretKey::from_slice` validates the 32-byte scalar is a - // legitimate field element. - let mut secret = secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) - .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?; + // `SecretKey::from_slice` validates the 32-byte scalar is a legitimate + // field element. The `WipingSecretKey` guard scrubs the SecretKey-owned + // copy on every exit path (incl. panic) — `Zeroizing<[u8;32]>` only + // covers `secret_bytes`, not the separate copy `from_slice` allocates. + let secret = WipingSecretKey( + secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) + .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?, + ); let msg = secp256k1::Message::from_digest(sighash); - let signature = secp.sign_ecdsa(&msg, &secret); - let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret); - // Wipe the SecretKey-owned scalar before it drops. `Zeroizing<[u8;32]>` - // covers `secret_bytes`; `SecretKey::from_slice` allocated a separate - // 32-byte copy that needs its own wipe. - secret.non_secure_erase(); + let signature = secp.sign_ecdsa(&msg, &secret.0); + let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret.0); Ok((signature, pubkey)) } async fn public_key(&self, path: &DerivationPath) -> Result { let secret_bytes = self.derive_priv(path)?; let secp = Secp256k1::new(); - let mut secret = secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) - .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?; - let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret); - // Wipe the SecretKey-owned scalar before it drops. `Zeroizing<[u8;32]>` - // covers `secret_bytes`; `SecretKey::from_slice` allocated a separate - // 32-byte copy that needs its own wipe. - secret.non_secure_erase(); + // `WipingSecretKey` scrubs the SecretKey-owned scalar copy on every exit + // path including panic (see `sign_ecdsa`). + let secret = WipingSecretKey( + secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) + .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?, + ); + let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &secret.0); Ok(pubkey) } + + async fn extended_public_key( + &self, + path: &DerivationPath, + ) -> Result { + let (_master, derived) = self.resolve_derived_xprv(path)?; + let secp = Secp256k1::new(); + // The extended public key (point + chain code) carries no secret; it is + // safe to return. The `WipingXprv` guards scrub the private halves when + // they drop at the end of this scope, on every exit path. + let xpub = ExtendedPubKey::from_priv(&secp, derived.key()); + + Ok(xpub) + } } #[cfg(test)] @@ -456,6 +718,276 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + #[tokio::test] + async fn extended_public_key_leaf_matches_public_key() { + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let path = test_path(); + let xpub = signer + .extended_public_key(&path) + .await + .expect("extended_public_key succeeds"); + let pk_only = signer.public_key(&path).await.expect("public_key succeeds"); + + // The xpub's leaf point must be the same key `public_key()` derives + // at the same path — they take different routes (xpub vs raw scalar) + // to the same secp256k1 point. + assert_eq!( + xpub.public_key, pk_only, + "extended_public_key().public_key must equal public_key() at the same path" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// Interop guard: the signer-based DashPay xpub route must be + /// byte-identical to the resident-seed + /// `Wallet::derive_extended_public_key` it replaces. + /// + /// The signer derives the contact-relationship extended public key at + /// the DIP-15 receiving path `m/9'/coin'/15'/0'//` + /// from the Keychain mnemonic; a `Wallet` built from the SAME mnemonic + /// derives it the old way. If they ever diverge, every contact xpub the + /// signer path produces would be unrecognizable to the resident-seed + /// path (and to the reference clients), so this pins them equal. + #[tokio::test] + async fn extended_public_key_matches_wallet_derivation_for_dashpay_path() { + use key_wallet::account::AccountType; + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + + // Two arbitrary 32-byte identity ids for the friendship path. + let sender_id = [0x11u8; 32]; + let recipient_id = [0x22u8; 32]; + + let path = AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: sender_id, + friend_identity_id: recipient_id, + } + .derivation_path(Network::Testnet) + .expect("DashPay receiving path"); + + // Old route: resident-seed wallet from the same mnemonic. + let mnemonic = + Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = + Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) + .expect("seeded wallet"); + let expected = wallet + .derive_extended_public_key(&path) + .expect("wallet derives DashPay xpub"); + + // New route: signer fed the same mnemonic via the resolver. + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + let via_signer = signer + .extended_public_key(&path) + .await + .expect("signer derives DashPay xpub"); + + assert_eq!( + via_signer, expected, + "signer-based DashPay xpub must equal Wallet::derive_extended_public_key \ + for the same mnemonic and path" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// Interop guard: the signer-based ECDH shared secret must be + /// byte-identical to the resident-seed route it replaces. + /// + /// The signer derives our scalar at `path` from the Keychain mnemonic and + /// ECDHs with a peer pubkey; a `Wallet` built from the SAME mnemonic + /// derives the scalar the old way and ECDHs through the SAME single crypto + /// source. If they diverged, every contact-request encrypt/decrypt the + /// signer path produces would be unreadable by the reference clients, so + /// this pins them equal. + #[tokio::test] + async fn ecdh_shared_secret_matches_wallet_derivation() { + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + + let path = test_path(); + + // A fixed peer keypair (the contact's encryption key). + let secp = Secp256k1::new(); + let peer_sk = secp256k1::SecretKey::from_slice(&[0x42u8; 32]).expect("peer secret key"); + let peer_pk = secp256k1::PublicKey::from_secret_key(&secp, &peer_sk); + + // Old route: resident-seed wallet from the same mnemonic → derive the + // scalar at `path` → ECDH through the single crypto source. + let mnemonic = + Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = + Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) + .expect("seeded wallet"); + let xprv = wallet + .derive_extended_private_key(&path) + .expect("wallet derives the private key at path"); + let expected = platform_encryption::derive_shared_key_ecdh(&xprv.private_key, &peer_pk); + + // New route: resolver-backed signer fed the same mnemonic. + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + let actual = signer + .ecdh_shared_secret(&path, &peer_pk) + .expect("signer computes the ECDH shared secret"); + + // Deref to a concrete `[u8; 32]` on both sides — `Zeroizing::as_ref` + // is ambiguous here (dashcore adds an `AsRef` for `[u8; 32]`). + let actual_bytes: [u8; 32] = *actual; + assert_eq!( + actual_bytes, expected, + "signer-based ECDH must equal the resident-seed ECDH for the same mnemonic and path" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// Interop guard for the seedless send path: the signer-computed + /// `accountReference` must equal the resident-seed route, and round-trip + /// back through the signer's unmask. + /// + /// The send flow masks `(version, account_index)` into the reference keyed + /// by the sender's ECDH private scalar. If the signer derived a different + /// scalar than the resident seed, a same-seed cross-wallet recovery would + /// unmask to the wrong account (silent — there's no on-chain oracle), so + /// this pins the signer route equal to `Wallet`'s and confirms the inverse. + #[tokio::test] + async fn account_reference_matches_wallet_derivation_and_round_trips() { + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + + let path = test_path(); + // A stand-in 69-byte compact xpub; the HMAC only consumes the bytes. + let compact_xpub: [u8; 69] = std::array::from_fn(|i| i as u8); + let account_index = 5u32; + let version = 3u32; + + // Old route: resident-seed wallet from the same mnemonic → derive the + // scalar at `path` → mask through the single accountReference source. + let mnemonic = + Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = + Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) + .expect("seeded wallet"); + let secret = wallet + .derive_extended_private_key(&path) + .expect("wallet derives the private key at path") + .private_key + .secret_bytes(); + let expected = platform_encryption::calculate_account_reference( + &secret, + &compact_xpub, + account_index, + version, + ); + + // New route: resolver-backed signer fed the same mnemonic. + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + let actual = signer + .account_reference(&path, &compact_xpub, account_index, version) + .expect("signer computes the account reference"); + assert_eq!( + actual, expected, + "signer accountReference must equal the resident-seed mask for the same mnemonic+path" + ); + + // And the signer's own inverse recovers the inputs. + let (got_version, got_account) = signer + .unmask_account_reference(&path, &compact_xpub, actual) + .expect("signer unmasks the account reference"); + assert_eq!(got_version, version, "version round-trips through the signer"); + assert_eq!( + got_account, account_index, + "account index round-trips through the signer" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// contactInfo seal/open round-trips, AND the signer's AES keys are + /// byte-identical to a resident wallet's derivation at the same DIP-15 + /// contactInfo paths (`root / 65536' / idx'` and `root / 65537' / idx'`) — + /// so contactInfo the signer seals is readable by the reference clients. + #[tokio::test] + async fn contact_info_seal_open_round_trips_and_matches_wallet_derivation() { + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let root_path = test_path(); + let derivation_index = 0u32; + let contact_id = [0x33u8; 32]; + let plaintext = b"hello private data".to_vec(); + let iv = [0x11u8; 16]; + + // Seal, then open — must recover the inputs. + let sealed = signer + .contact_info_seal(&root_path, derivation_index, &contact_id, &plaintext, &iv) + .expect("seal"); + let opened = signer + .contact_info_open( + &root_path, + derivation_index, + &sealed.enc_to_user_id, + &sealed.private_data, + ) + .expect("open"); + assert_eq!( + opened.contact_id, contact_id, + "open recovers the contact id" + ); + assert_eq!( + opened.private_data, plaintext, + "open recovers the private data" + ); + + // Parity: encToUserId equals a resident wallet's derive+encrypt. + let mnemonic = + Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = + Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) + .expect("seeded wallet"); + let enc_key: [u8; 32] = { + let path = root_path.clone().extend([ + ChildNumber::from_hardened_idx(65536).unwrap(), + ChildNumber::from_hardened_idx(derivation_index).unwrap(), + ]); + wallet + .derive_extended_private_key(&path) + .expect("derive encToUserId key") + .private_key + .secret_bytes() + }; + let expected_enc = platform_encryption::encrypt_enc_to_user_id(&enc_key, &contact_id); + assert_eq!( + sealed.enc_to_user_id, expected_enc, + "signer encToUserId must equal the resident-seed encryption at the same path" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + #[tokio::test] async fn missing_resolver_surfaces_not_found_error() { let resolver = make_resolver(missing_resolve); diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index 9c131977023..19e6503349a 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -19,7 +19,7 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::{Bytes32, Value}; use dpp::prelude::Identifier; use platform_encryption::{ - derive_shared_key_ecdh, encrypt_account_label, encrypt_extended_public_key, + derive_shared_key_ecdh, encrypt_account_label, encrypt_extended_public_key, COMPACT_XPUB_LEN, }; use std::collections::BTreeMap; @@ -111,6 +111,13 @@ pub struct ContactRequestResult { pub owner_id: Identifier, /// The document properties pub properties: BTreeMap, + /// The entropy used to derive `id`. + /// + /// This must be reused when broadcasting the document so that the + /// document id computed at creation matches the id platform consensus + /// recomputes from the entropy (otherwise the create transition is + /// rejected with `InvalidDocumentTransitionIdError`). + pub entropy: Bytes32, } /// Input for sending a contact request to the platform @@ -134,6 +141,21 @@ pub struct SendContactRequestResult { pub account_reference: u32, } +/// Whether `purpose` is acceptable for the `senderKeyIndex` key of a contact +/// request. The sender always references its own ENCRYPTION key. +fn sender_key_purpose_is_valid(purpose: Purpose) -> bool { + purpose == Purpose::ENCRYPTION +} + +/// Whether `purpose` is acceptable for the `recipientKeyIndex` key of a +/// contact request. The newest cohort references the recipient's +/// DECRYPTION key (our original convention); the dominant mobile cohort has no +/// DECRYPTION key and references its ENCRYPTION key. Accept either; reject +/// AUTHENTICATION/MASTER/TRANSFER. +fn recipient_key_purpose_is_valid(purpose: Purpose) -> bool { + matches!(purpose, Purpose::DECRYPTION | Purpose::ENCRYPTION) +} + impl Sdk { /// Create a contact request document /// @@ -147,7 +169,10 @@ impl Sdk { /// * `ecdh_provider` - Provider for ECDH key exchange (client-side or SDK-side) /// * `get_extended_public_key` - Async function to retrieve the extended public key to share with recipient /// - Parameters: `(account_reference: u32)` - /// - Returns: The unencrypted extended public key bytes (typically 78 bytes) + /// - Returns: The unencrypted extended public key bytes — the **69-byte + /// DIP-15 compact form** (`parentFingerprint(4) ‖ chainCode(32) ‖ + /// pubKey(33)`), NOT a 78/107-byte BIP32/DIP-14 serialization. A + /// non-69-byte return is rejected before encryption. /// /// # Returns /// @@ -208,27 +233,33 @@ impl Sdk { )) })?; - if sender_key.purpose() != Purpose::ENCRYPTION { + // Sender always references its own ENCRYPTION key (the live + // convention of both on-chain cohorts). + if !sender_key_purpose_is_valid(sender_key.purpose()) { return Err(Error::Generic(format!( "Sender key at index {} is not an encryption key", input.sender_key_index ))); } - // Verify recipient has the encryption key at the specified index + // Verify recipient has the referenced key at the specified index. let recipient_key = recipient_identity .public_keys() .get(&input.recipient_key_index) .ok_or_else(|| { Error::Generic(format!( - "Recipient identity does not have encryption key at index {}", + "Recipient identity does not have a key at index {}", input.recipient_key_index )) })?; - if recipient_key.purpose() != Purpose::DECRYPTION { + // Accept either a DECRYPTION key (newest cohort / our original + // convention) OR an ENCRYPTION key (the dominant mobile cohort, whose + // identities carry no DECRYPTION key and reference their ENCRYPTION + // key for recipientKeyIndex). + if !recipient_key_purpose_is_valid(recipient_key.purpose()) { return Err(Error::Generic(format!( - "Recipient key at index {} is not a decryption key", + "Recipient key at index {} is not a decryption or encryption key", input.recipient_key_index ))); } @@ -252,8 +283,20 @@ impl Sdk { } }; - // Get the extended public key to encrypt + // Get the extended public key to encrypt. Per DIP-15 the callback must + // return the 69-byte COMPACT form (parentFingerprint ‖ chainCode ‖ + // pubKey) — NOT a 78/107-byte BIP32/DIP-14 serialization. Validate the + // length up front so a malformed producer fails with a precise error + // instead of the downstream "96-byte" assertion (which a 78-byte input + // would silently pass while remaining undecryptable by mobile clients). let extended_public_key = get_extended_public_key(input.account_reference).await?; + if extended_public_key.len() != COMPACT_XPUB_LEN { + return Err(Error::Generic(format!( + "Extended public key must be the {COMPACT_XPUB_LEN}-byte DIP-15 compact form \ + (parentFingerprint ‖ chainCode ‖ pubKey), got {} bytes", + extended_public_key.len() + ))); + } // Generate random IVs for encryption let mut rng = StdRng::from_entropy(); @@ -345,11 +388,13 @@ impl Sdk { properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); } - // Return the essential fields for the contact request + // Return the essential fields for the contact request, including the + // entropy that derived `document_id` so the broadcast path can reuse it. Ok(ContactRequestResult { id: document_id, owner_id: sender_id, properties, + entropy, }) } @@ -364,7 +409,9 @@ impl Sdk { /// * `ecdh_provider` - Provider for ECDH key exchange (client-side or SDK-side) /// * `get_extended_public_key` - Async function to retrieve the extended public key to share with recipient /// - Parameters: `(account_reference: u32)` - /// - Returns: The unencrypted extended public key bytes (typically 78 bytes) + /// - Returns: The unencrypted extended public key bytes — the **69-byte + /// DIP-15 compact form** (`parentFingerprint(4) ‖ chainCode(32) ‖ + /// pubKey(33)`), NOT a 78/107-byte BIP32/DIP-14 serialization. /// /// # Returns /// @@ -410,6 +457,12 @@ impl Sdk { Error::Generic("DashPay contactRequest document type not found".to_string()) })?; + // Reuse the entropy that derived result.id during creation. Platform + // consensus recomputes the document id from this entropy and rejects the + // create transition unless it matches result.id, so a freshly generated + // entropy here would always be rejected (InvalidDocumentTransitionIdError). + let entropy = result.entropy; + // Create the document from the result let document = Document::V0(DocumentV0 { id: result.id, @@ -428,12 +481,6 @@ impl Sdk { creator_id: None, }); - // Extract entropy from document ID for state transition - // Note: In a real implementation, we'd need to store the entropy used during creation - // For now, we'll generate new entropy (this is a simplification) - let mut rng = StdRng::from_entropy(); - let entropy = Bytes32::random_with_rng(&mut rng); - // Submit the document to the platform let platform_document = document .put_to_platform_and_wait_for_response( @@ -478,8 +525,11 @@ mod tests { rand::thread_rng().fill_bytes(&mut xpub_iv); rand::thread_rng().fill_bytes(&mut label_iv); - // Test extended public key encryption (78 bytes -> 96 bytes with IV + PKCS7 padding) - let xpub_data = vec![0x04; 78]; + // Test extended public key encryption: the DIP-15 compact plaintext is + // 69 bytes (parentFingerprint ‖ chainCode ‖ pubKey) → 96 bytes with IV + // + PKCS7 padding. (A 78-byte BIP32 xpub would also pad to 96, but the + // contract + reference clients require exactly the 69-byte compact.) + let xpub_data = vec![0x04; COMPACT_XPUB_LEN]; let encrypted_xpub = encrypt_extended_public_key(&shared_key, &xpub_iv, &xpub_data); assert_eq!( encrypted_xpub.len(), @@ -522,6 +572,87 @@ mod tests { } } + #[test] + fn contact_request_result_entropy_derives_returned_id() { + // Regression for G2 entropy mismatch: the document id returned by + // create_contact_request must be derivable from the entropy carried in + // ContactRequestResult. send_contact_request reuses ContactRequestResult::entropy + // when broadcasting, and platform consensus rejects the create transition + // (InvalidDocumentTransitionIdError) unless + // generate_document_id_v0(contract, owner, "contactRequest", entropy) == base.id. + // + // Without the `entropy` field on ContactRequestResult, + // send_contact_request would generate fresh entropy E2 != E1 and this + // invariant could not even be expressed. This test pins it. + let mut rng = StdRng::seed_from_u64(0x6732_4732); // deterministic, no network + let entropy = Bytes32::random_with_rng(&mut rng); + + let contract_id = Identifier::from([1u8; 32]); + let owner_id = Identifier::from([2u8; 32]); + + let id = Document::generate_document_id_v0( + &contract_id, + &owner_id, + "contactRequest", + entropy.as_slice(), + ); + + let result = ContactRequestResult { + id, + owner_id, + properties: BTreeMap::new(), + entropy, + }; + + // The entropy that send_contact_request will broadcast must regenerate the + // exact id that was returned at creation time. + let regenerated = Document::generate_document_id_v0( + &contract_id, + &result.owner_id, + "contactRequest", + result.entropy.as_slice(), + ); + assert_eq!( + regenerated, result.id, + "entropy carried in ContactRequestResult must derive the returned document id" + ); + } + + #[test] + fn recipient_key_purpose_accepts_decryption_and_encryption() { + // G15: the recipient-key assertion must accept DECRYPTION (our + // original convention / newest cohort) OR ENCRYPTION (the dominant + // mobile cohort, whose identities have no DECRYPTION key and reference + // their ENCRYPTION key for recipientKeyIndex). Accepting only + // DECRYPTION would make sending to a mobile recipient error with + // "Recipient key ... is not a decryption key". + assert!( + recipient_key_purpose_is_valid(Purpose::DECRYPTION), + "DECRYPTION recipient key must remain valid" + ); + assert!( + recipient_key_purpose_is_valid(Purpose::ENCRYPTION), + "ENCRYPTION recipient key (mobile cohort) must be accepted" + ); + } + + #[test] + fn recipient_key_purpose_rejects_authentication() { + // No AUTHENTICATION fallback — reusing signing keys for ECDH is poor + // key separation and no live population needs it. + assert!(!recipient_key_purpose_is_valid(Purpose::AUTHENTICATION)); + assert!(!recipient_key_purpose_is_valid(Purpose::TRANSFER)); + } + + #[test] + fn sender_key_purpose_is_unchanged_encryption_only() { + // Sender side stays strict: only ENCRYPTION (per the task, the + // sender-side assertion is unchanged). + assert!(sender_key_purpose_is_valid(Purpose::ENCRYPTION)); + assert!(!sender_key_purpose_is_valid(Purpose::DECRYPTION)); + assert!(!sender_key_purpose_is_valid(Purpose::AUTHENTICATION)); + } + #[test] fn test_ecdh_shared_secret_symmetry() { // Test that both parties derive the same shared secret diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs b/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs index 2670ac96772..0aa743dfffd 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs @@ -1,131 +1,149 @@ //! Contact request query helpers //! -//! This module provides helper functions for querying contact requests from the platform +//! This module provides helper functions for querying contact requests from the platform. +//! +//! The fetch is **incremental and fully paginated**: an optional `after_created_at` +//! lower bound restricts the query to documents newer than the caller's +//! high-water mark, and the helper drains *all* pages via a `StartAfter` +//! document-id cursor so a flood of requests can never bury (truncate) the +//! newest ones. Returning `Ok` means pagination ran to exhaustion without +//! error — the caller may then advance its high-water cursor; any page error +//! propagates as `Err`, leaving the caller's cursor untouched. use crate::platform::documents::document_query::DocumentQuery; use crate::platform::FetchMany; use crate::{Error, Sdk}; +use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; use dpp::document::Document; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; use dpp::platform_value::platform_value; use dpp::prelude::Identifier; -use drive::query::{WhereClause, WhereOperator}; +use drive::query::{OrderClause, WhereClause, WhereOperator}; use drive_proof_verifier::types::Documents; /// Result of a contact request query containing the parsed documents pub type ContactRequestDocuments = Documents; +/// Page size for the paginated contact-request fetch. The fetch drains every +/// page (retrieve-all); this only bounds how many documents move per round +/// trip. 100 is the platform document-query maximum. +const CONTACT_REQUEST_PAGE_SIZE: u32 = 100; + impl Sdk { - /// Fetch all contact requests sent by a specific identity - /// - /// This queries the DashPay contract for contactRequest documents where - /// the given identity is the owner (sender). - /// - /// # Arguments + /// Drain every `contactRequest` document matching `filter_field == + /// identity_id` (and, if `after_created_at` is set, `$createdAt > + /// after_created_at`), paginating with a `StartAfter` document-id cursor + /// until a short/empty page proves exhaustion. /// - /// * `identity_id` - The identity ID of the sender - /// * `limit` - Maximum number of contact requests to fetch (default: 100) - /// - /// # Returns - /// - /// Returns a map of document IDs to optional contact request documents - pub async fn fetch_sent_contact_requests( + /// `Ok` ⇒ all pages fetched (the caller may advance its high-water mark); + /// any page error short-circuits as `Err` so the caller does not advance. + async fn fetch_contact_requests_paginated( &self, + filter_field: &str, identity_id: Identifier, - limit: Option, + after_created_at: Option, ) -> Result { - // Fetch the DashPay contract let dashpay_contract = self.fetch_dashpay_contract().await?; - // Query for sent contact requests (where this identity is the owner) - // Note: We need to filter by $ownerId to get only this identity's sent requests - let query = DocumentQuery { - select: drive::query::SelectProjection::documents(), - data_contract: dashpay_contract, - document_type_name: "contactRequest".to_string(), - where_clauses: vec![WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::Equal, - value: platform_value!(identity_id), - }], - group_by: vec![], - having: vec![], - order_by_clauses: vec![], - limit: limit.unwrap_or(100), - start: None, - }; + let mut where_clauses = vec![WhereClause { + field: filter_field.to_string(), + operator: WhereOperator::Equal, + value: platform_value!(identity_id), + }]; + if let Some(after) = after_created_at { + where_clauses.push(WhereClause { + field: "$createdAt".to_string(), + operator: WhereOperator::GreaterThan, + value: platform_value!(after), + }); + } + + let mut all: ContactRequestDocuments = Default::default(); + let mut start: Option = None; - // Fetch the documents - Document::fetch_many(self, query).await + loop { + let query = DocumentQuery { + select: drive::query::SelectProjection::documents(), + data_contract: dashpay_contract.clone(), + document_type_name: "contactRequest".to_string(), + where_clauses: where_clauses.clone(), + group_by: vec![], + having: vec![], + // Load-bearing: a bare secondary-index equality with no + // order-by is silently proven ABSENT by drive (observed + // against drive 4.0.0-rc.2: `toUserId ==` returned a verified + // empty result for an existing document). The clause also + // pins the query to the contract's `(field, $createdAt)` + // index, giving the deterministic order pagination relies on. + order_by_clauses: vec![OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }], + limit: CONTACT_REQUEST_PAGE_SIZE, + start: start.clone(), + }; + + let page = Document::fetch_many(self, query).await?; + let page_len = page.len(); + // The last document id in query order seeds the next page's + // cursor (distinct from the `$createdAt` high-water the caller + // tracks — this id cursor is ephemeral, per-loop). Relies on + // `Documents` being insertion-ordered (`IndexMap`) so `keys().last()` + // is the `$createdAt`-ascending last doc; a `BTreeMap` here would + // silently reorder by doc id and break pagination. + let last_id = page.keys().last().copied(); + for (id, doc) in page { + all.insert(id, doc); + } + + // A short page proves exhaustion (a full page may have more). + if page_len < CONTACT_REQUEST_PAGE_SIZE as usize { + break; + } + match last_id { + Some(id) => start = Some(Start::StartAfter(id.to_buffer().to_vec())), + None => break, + } + } + + Ok(all) } - /// Fetch all contact requests received by a specific identity - /// - /// This queries the DashPay contract for contactRequest documents where - /// the given identity is the recipient (toUserId field). - /// - /// # Arguments - /// - /// * `identity_id` - The identity ID of the recipient - /// * `limit` - Maximum number of contact requests to fetch (default: 100) - /// - /// # Returns - /// - /// Returns a map of document IDs to optional contact request documents - pub async fn fetch_received_contact_requests( + /// Fetch contact requests **sent** by `identity_id` (`$ownerId ==`), + /// newer than `after_created_at` if given, fully paginated. + pub async fn fetch_sent_contact_requests( &self, identity_id: Identifier, - limit: Option, + after_created_at: Option, ) -> Result { - // Fetch the DashPay contract - let dashpay_contract = self.fetch_dashpay_contract().await?; - - // Query for received contact requests (where this identity is toUserId) - let query = DocumentQuery { - select: drive::query::SelectProjection::documents(), - data_contract: dashpay_contract, - document_type_name: "contactRequest".to_string(), - where_clauses: vec![WhereClause { - field: "toUserId".to_string(), - operator: WhereOperator::Equal, - value: platform_value!(identity_id), - }], - group_by: vec![], - having: vec![], - order_by_clauses: vec![], - limit: limit.unwrap_or(100), - start: None, - }; + self.fetch_contact_requests_paginated("$ownerId", identity_id, after_created_at) + .await + } - // Fetch the documents - Document::fetch_many(self, query).await + /// Fetch contact requests **received** by `identity_id` (`toUserId ==`), + /// newer than `after_created_at` if given, fully paginated. + pub async fn fetch_received_contact_requests( + &self, + identity_id: Identifier, + after_created_at: Option, + ) -> Result { + self.fetch_contact_requests_paginated("toUserId", identity_id, after_created_at) + .await } - /// Fetch all contact requests for a specific identity (both sent and received) - /// - /// This is a convenience method that fetches both sent and received contact requests - /// for a given identity. - /// - /// # Arguments - /// - /// * `identity` - The identity to fetch contact requests for - /// * `limit` - Maximum number of contact requests to fetch per query (default: 100) - /// - /// # Returns - /// - /// Returns a tuple of (sent_requests, received_requests) + /// Fetch both sent and received contact requests for an identity, each + /// newer than `after_created_at` if given. pub async fn fetch_all_contact_requests_for_identity( &self, identity: &Identity, - limit: Option, + after_created_at: Option, ) -> Result<(ContactRequestDocuments, ContactRequestDocuments), Error> { let identity_id = identity.id(); - // Fetch both sent and received contact requests in parallel let (sent_result, received_result) = tokio::join!( - self.fetch_sent_contact_requests(identity_id, limit), - self.fetch_received_contact_requests(identity_id, limit) + self.fetch_sent_contact_requests(identity_id, after_created_at), + self.fetch_received_contact_requests(identity_id, after_created_at) ); Ok((sent_result?, received_result?)) diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 9a73096838e..7f05df820f7 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -30,7 +30,10 @@ impl Sdk { #[cfg(not(feature = "dashpay-contract"))] let dashpay_contract_id = { - const DASHPAY_CONTRACT_ID: &str = "GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec"; + // The deployed DashPay v1 contract id. This fallback + // previously held the DPNS id — a latent foot-gun for + // builds without the `dashpay-contract` feature. + const DASHPAY_CONTRACT_ID: &str = "Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7"; Identifier::from_string( DASHPAY_CONTRACT_ID, dpp::platform_value::string_encoding::Encoding::Base58, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift index a7a6d34ed4c..e93be6f61cb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift @@ -637,24 +637,6 @@ public final class KeychainSigner: Signer, @unchecked Sendable { // MARK: - Signer protocol conformance (legacy) - /// Legacy `Signer` protocol path — exposed so views that still hold - /// a `Signer` (rather than a `KeychainSigner.handle`) keep - /// compiling during the FFI migration. Always treats the input - /// as an identity-key request (legacy callers never produced - /// platform-address requests) and routes through the same - /// SwiftData → Keychain identity-key lookup the trampoline uses. - public func sign(identityPublicKey: Data, data: Data) -> Data? { - switch lookupIdentityPrivateKey(publicKey: identityPublicKey) { - case .failure: - return nil - case .success(let priv): - switch ffiSign(privateKey: priv, data: data) { - case .success(let sig): return sig - case .failure: return nil - } - } - } - public func canSign(identityPublicKey: Data) -> Bool { canSign(publicKey: identityPublicKey, keyType: KeyType.ecdsaSecp256k1.rawValue) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/Signer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/Signer.swift index 4b0406c35d3..4bf682e1fc6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/Signer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/Signer.swift @@ -6,22 +6,14 @@ import Foundation /// /// `KeychainSigner` is the production implementation — it is also /// what every FFI `_with_signer` entry point expects via its -/// `.handle` property. The protocol itself is kept (rather than -/// deleted) so callers that hold a generic `any Signer` reference -/// can keep compiling while migration to `KeychainSigner.handle` -/// proceeds. +/// `.handle` property. Signing itself happens entirely through that +/// handle (the FFI signing path); the protocol only exposes the +/// can-sign capability check. /// /// New code should depend on `KeychainSigner` directly and pass /// `signer.handle` to FFI; this protocol does not (and cannot) /// participate in the FFI signing path. public protocol Signer: Sendable { - /// Sign data using the private key corresponding to the given public key. - /// - Parameters: - /// - identityPublicKey: The public key data identifying which private key to use. - /// - data: The data to sign. - /// - Returns: The signature data, or nil if signing failed. - func sign(identityPublicKey: Data, data: Data) -> Data? - /// Check if this signer can sign for the given public key. /// - Parameter identityPublicKey: The public key data to check. /// - Returns: true if the signer has the corresponding private key. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index c135e1c6a57..ec4f97ff99b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -9,7 +9,10 @@ public enum DashModelContainer { PersistentIdentity.self, PersistentDPNSName.self, PersistentDashpayProfile.self, + PersistentDashpayContactProfile.self, PersistentDashpayContactRequest.self, + PersistentDashpayPayment.self, + PersistentDashpayIgnoredSender.self, PersistentDocument.self, PersistentDataContract.self, PersistentPublicKey.self, @@ -156,6 +159,38 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// `(network, owner, contact, isOutgoing)` quad. Existing dev /// stores predate the row collection and rebuild on next /// DashPay contact sync. +/// - `PersistentDashpayContactRequest` gained the additive +/// `paymentChannelBroken` column (defaulted `false`) so the G1c +/// broken-channel flag projected by the persister survives +/// restarts. Additive-with-default ⇒ lightweight migration. +/// - `PersistentDashpayPayment` was added (cascade-owned by +/// `PersistentIdentity` via the new `dashpayPayments` +/// collection). Mirrors the per-identity `dashpay_payments` map +/// read through `managed_identity_get_dashpay_payments`; rows are +/// refreshed by `PlatformWalletManager.refreshDashPayPayments` +/// (the persister doesn't project payment history). Additive +/// model + additive relationship ⇒ lightweight migration. +/// - `PersistentDashpayIgnoredSender` was added (cascade-owned by +/// `PersistentIdentity` via the new `dashpayIgnoredSenders` +/// collection). Persists per-sender ignores (local-only mute, = +/// block, reversible) the persister projects in the `ignored` +/// changeset array so the Rust `ignored_senders` set can be restored +/// at load — without it an ignored sender resurfaces on relaunch. +/// Keyed per-sender (no `accountReference`), so an ignored sender's +/// rotated requests are suppressed too. Additive model + additive +/// relationship ⇒ lightweight migration. (Replaces the earlier +/// per-`(sender, accountReference)` `PersistentDashpayRejectedRequest` +/// — the model decision collapsed reject into ignore.) +/// - `PersistentDashpayContactProfile` was added (cascade-owned by +/// `PersistentIdentity` via the new `contactProfiles` collection). +/// Mirrors one entry of the per-identity `contact_profiles` map +/// (cached contacts' public profiles, keyed by the contact's +/// identity id) projected by the persister as +/// `IdentityEntryFFI.contact_profiles` rows, and read back at load to +/// rebuild the Rust cache so contacts don't refetch on every +/// relaunch. Distinct from `PersistentDashpayProfile` (the owner's +/// own profile). Additive model + additive relationship ⇒ +/// lightweight migration. /// - `PersistentAccount` gained `#Unique<…>([\.wallet, \.accountType, /// \.accountIndex, \.userIdentityId, \.friendIdentityId])` plus /// `@Attribute(.unique)` on `accountExtendedPubKeyBytes`. The diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift new file mode 100644 index 00000000000..e49297b2775 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift @@ -0,0 +1,175 @@ +import Foundation +import SwiftData + +/// SwiftData row for one cached DashPay **contact** profile — a mirror +/// of one entry in the Rust-side `contact_profiles` map on a +/// `ManagedIdentity` (keyed by the contact's identity id). +/// +/// Distinct from `PersistentDashpayProfile`, which is the owner's *own* +/// profile (one per identity): this row is a *contact's* public profile, +/// cached so the requests / contacts UI can show a display name + avatar +/// without re-fetching on every launch. The cache is +/// relationship-independent — it serves established contacts, pending +/// incoming-request senders, and (later) ignored senders from one table, +/// matching the Rust map. It holds **only the five public profile +/// fields** parsed from the on-chain `profile` document; it must never +/// receive anything derived from the encrypted `contactInfo` path. +/// +/// One row per `(network, owner, contact)` — the Rust map is keyed by +/// the contact's identity id per owner, scoped by network so two +/// networks don't collide in a shared local store. +/// +/// Populated by the platform-wallet persister callback whenever an +/// `IdentityEntry.contact_profiles` entry rides on the FFI changeset +/// (one `ContactProfileRowFFI` per **present** profile — confirmed-absent +/// negative-cache entries are not projected). Read back at load to +/// rebuild the Rust `contact_profiles` map so the cache survives +/// relaunch instead of refetching every contact on the first sweep. +/// +/// Cascade-deleted from `PersistentIdentity.contactProfiles` — losing +/// the owner identity drops its cached contact profiles. +@Model +public final class PersistentDashpayContactProfile { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// contactIdentityId)`. Mirrors the per-owner, per-contact keying of + /// the Rust `contact_profiles` map. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + public var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + public var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the `owner` join. Always equal to `owner.identityId` — + /// kept in sync by the persister. + public var ownerIdentityId: Data + + /// The contact's 32-byte identity id — the `contact_profiles` map + /// key. Part of the compound unique key above. + public var contactIdentityId: Data + + // MARK: - Profile fields + // + // All optional — every `dashpay.profile` document field is optional + // in the contract schema except the implicit `$ownerId`. We mirror + // that so partial profiles (only an `avatarUrl` set, only a + // `displayName` set, etc.) round-trip without forcing placeholders. + + /// `displayName` field on the contact's DashPay `profile` document. + public var displayName: String? + + /// `publicMessage` field on the contact's `profile` document. + public var publicMessage: String? + + /// `bio` field. Carried for forwards-compat with future contract + /// revisions; reserved here so adding it later doesn't trigger a + /// destructive schema change. + public var bio: String? + + /// `avatarUrl` field — URL the consumer fetches + caches locally. + /// The binary asset itself is never persisted. Treated as untrusted + /// (attacker-controlled public data): the Rust side caches and + /// restores it only when it is a bounded `https://` URL. + public var avatarUrl: String? + + /// `avatarHash` field — 32-byte hash of the avatar binary, so + /// consumers can verify a fetched asset matches what the contact + /// published. `nil` when the underlying `avatar_hash` was absent. + public var avatarHash: Data? + + /// `avatarFingerprint` field — 8-byte perceptual hash for quick + /// equality checks on cached avatars. `nil` when absent. + public var avatarFingerprint: Data? + + /// Wall-clock ms of the last fetch attempt on the Rust side + /// (`ContactProfileEntry.checked_at_ms`) — drives the self-heal + /// backoff. Round-tripped verbatim so the restored cache keeps the + /// same re-query schedule it had before relaunch. Stored as the + /// scalar so the predicate engine compares it directly. + public var checkedAtMs: UInt64 + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity whose cached + /// contact profiles this row belongs to. Non-optional: every contact + /// profile exists *because of* an owner identity. Cascade-deleted + /// from `PersistentIdentity.contactProfiles`. + public var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping) + + public var createdAt: Date + public var lastUpdated: Date + + // MARK: - Initialization + + public init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } +} + +// MARK: - Queries + +extension PersistentDashpayContactProfile { + /// Predicate filtering all cached contact-profile rows that belong + /// to a specific owner identity. Filters on the denormalized + /// `ownerIdentityId` scalar so SwiftData's predicate engine doesn't + /// traverse the `owner` relationship — same shape as the + /// contact-request / payment predicates. + public static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + /// Contact-scoped variant of [`predicate(ownerIdentityId:)`] — fetch + /// the one cached profile for a single contact of an owner. + public static func predicate( + ownerIdentityId: Data, + contactIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let contact = contactIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.contactIdentityId == contact + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift index 11196c7d02c..91146312b3a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift @@ -94,6 +94,34 @@ public final class PersistentDashpayContactRequest { /// request document was created. public var createdAtMillis: UInt64 + /// Whether the established relationship this row belongs to has a + /// **permanently broken** payment channel. Mirrors + /// `ContactRequestFFI::payment_channel_broken`: only meaningful + /// for rows projected from the `established` map — both + /// directions of an established pair carry the same flag (it's a + /// property of the relationship, not of one direction). Always + /// `false` for pending rows. The UI reads it to disable "Send + /// Dash" and surface "payment channel broken — ask the contact to + /// send a new request". + /// + /// Defaulted so existing rows ride SwiftData's lightweight + /// migration (additive column, non-destructive). + public var paymentChannelBroken: Bool = false + + /// Owner-private alias for the contact — `contactInfo`-backed, + /// synced across devices via Platform. Mirrors + /// `ContactRequestFFI::alias`; established rows only, replicated + /// onto both directions like `paymentChannelBroken`. Optional so + /// existing rows ride the lightweight migration. + public var contactAlias: String? + + /// Owner-private note — same conventions as `contactAlias`. + public var contactNote: String? + + /// `contactInfo.displayHidden` — whether the owner hid this + /// contact from the list. Defaulted for lightweight migration. + public var contactHidden: Bool = false + // MARK: - Relationships /// Owning identity — the wallet-managed identity this row's @@ -120,7 +148,8 @@ public final class PersistentDashpayContactRequest { encryptedAccountLabel: Data? = nil, autoAcceptProof: Data? = nil, coreHeightCreatedAt: UInt32, - createdAtMillis: UInt64 + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false ) { self.owner = owner self.networkRaw = owner.networkRaw @@ -135,6 +164,7 @@ public final class PersistentDashpayContactRequest { self.autoAcceptProof = autoAcceptProof self.coreHeightCreatedAt = coreHeightCreatedAt self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken self.createdAt = Date() self.lastUpdated = Date() } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayIgnoredSender.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayIgnoredSender.swift new file mode 100644 index 00000000000..eb4291667a1 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayIgnoredSender.swift @@ -0,0 +1,124 @@ +import Foundation +import SwiftData + +/// SwiftData row for one DashPay **ignored sender** — a mirror of one +/// entry in the Rust-side `ManagedIdentity.ignored_senders` set, keyed by +/// the ignored sender's identity id. +/// +/// ## Why this row exists +/// +/// Ignore is a per-sender mute (= block, reversible, **local-only**): there +/// is no on-chain artifact (syncing it would leak who you ignored via the +/// public contact-request indices). `contactRequest` documents are +/// immutable and never deleted on-chain, so an ignored sender's requests +/// keep returning on every sync sweep. The Rust side suppresses re-ingest +/// via `is_sender_ignored`, but that set is in-memory: on relaunch it +/// starts empty, and without a persisted row to restore it from, the +/// ignored sender's requests **re-ingest and the sender resurfaces**. This +/// row is that persisted state — the load path rehydrates `ignored_senders` +/// from it (see the `ignored_senders` array on `IdentityRestoreEntryFFI`). +/// +/// It is the SwiftData analog of the Rust-side ignored-senders store; the +/// example app uses the SwiftData persister, so it needs its own durable +/// ignore store. +/// +/// ## Keying +/// +/// Compound-unique on `(networkRaw, ownerIdentityId, ignoredSenderId)`. +/// Suppression is **per-sender** — bare sender id, no `accountReference`: +/// an ignored sender's requests are ALL suppressed (including rotated, +/// bumped-`accountReference` ones), matching the Rust set exactly. (This is +/// the deliberate difference from the old per-`(sender, accountReference)` +/// reject this replaces.) +/// +/// Cascade-deleted from `PersistentIdentity.dashpayIgnoredSenders` — +/// losing the owner identity drops its ignored senders. +@Model +public final class PersistentDashpayIgnoredSender { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// ignoredSenderId)` — the Rust per-sender suppression key, scoped by + /// network so two networks don't collide in a shared store. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue`, kept + /// in sync with `owner.networkRaw` by the init. + public var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` if + /// the stored raw value drifts. + public var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id — the recipient that + /// ignored the sender. Denormalized so `#Predicate` filters match + /// without a relationship traversal. Always equal to + /// `owner.identityId`. + public var ownerIdentityId: Data + + /// The 32-byte id of the ignored sender. The per-sender suppression + /// key — no `accountReference`, so ALL of this sender's requests are + /// suppressed. + public var ignoredSenderId: Data + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity that ignored the + /// sender. Non-optional: an ignore exists *because of* an owner + /// identity. Cascade-deleted from + /// `PersistentIdentity.dashpayIgnoredSenders`. + public var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping) + + public var ignoredAt: Date + + // MARK: - Initialization + + public init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } +} + +// MARK: - Queries + +extension PersistentDashpayIgnoredSender { + /// Predicate filtering all ignored-sender rows that belong to a + /// specific owner identity. Filters on the denormalized + /// `ownerIdentityId` scalar so SwiftData's predicate engine doesn't + /// traverse the `owner` relationship — same shape as the + /// contact-request and contact-profile predicates. Drives the + /// "Ignored" screen's `@Query`. + public static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + /// Sender-scoped variant — fetch the one row for a single ignored + /// sender of an owner (used by the persister's upsert/delete path). + public static func predicate( + ownerIdentityId: Data, + ignoredSenderId: Data + ) -> Predicate { + let target = ownerIdentityId + let sender = ignoredSenderId + return #Predicate { row in + row.ownerIdentityId == target + && row.ignoredSenderId == sender + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swift new file mode 100644 index 00000000000..965574ff7e5 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swift @@ -0,0 +1,161 @@ +import Foundation +import SwiftData + +/// SwiftData row for one DashPay payment-history entry — a mirror of +/// the Rust-side `PaymentEntry` map on a `ManagedIdentity` +/// (`dashpay_payments`, keyed by txid). +/// +/// Unlike the contact-request rows this model is **not** populated by +/// the persister callback. The Rust persister doesn't project payment +/// history; rows are refreshed on demand from the +/// `managed_identity_get_dashpay_payments` FFI getter via +/// `PlatformWalletManager.refreshDashPayPayments(walletId:identityId:)`, +/// which upserts here so the UI can `@Query` payments reactively. +/// +/// One row per `(network, owner, txid)` — the Rust map is keyed by +/// txid per identity, scoped by network so two networks don't collide +/// in a shared local store. +/// +/// The source `PaymentEntry` carries no timestamp field (the model +/// keys history by txid and records no wall-clock time), so none is +/// persisted — `createdAt` / `lastUpdated` below are local row +/// bookkeeping, not payment dates. +/// +/// Cascade-deleted from `PersistentIdentity.dashpayPayments` — losing +/// the owner identity drops its payment history. +@Model +public final class PersistentDashpayPayment { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, txid)`. + /// Mirrors the per-identity txid keying of the Rust + /// `dashpay_payments` map. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + public var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + public var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the `owner` join. Always equal to `owner.identityId` — + /// kept in sync by the refresh path. + public var ownerIdentityId: Data + + /// The other identity in this payment + /// (`DashpayPaymentFFI::counterparty_id`). Whether they are the + /// sender or the receiver is encoded in `directionRaw`. + public var counterpartyIdentityId: Data + + /// Amount in duffs. Always positive; `directionRaw` carries the + /// sign. + public var amountDuffs: UInt64 + + /// Raw `DashPayPaymentDirection` value. Stored as the scalar so + /// the predicate engine compares it directly. + public var directionRaw: UInt8 + + /// Type-safe accessor over `directionRaw`. Falls back to `.sent` + /// if the stored raw value drifts. + public var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + /// Raw `DashPayPaymentStatus` value. + public var statusRaw: UInt8 + + /// Type-safe accessor over `statusRaw`. Falls back to `.pending` + /// if the stored raw value drifts. + public var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + /// Transaction id (hex), the Rust `dashpay_payments` map key. + /// Part of the compound unique key above. + public var txid: String + + /// Sender memo, when present. `nil` mirrors the source `Option` + /// being `None`. + public var memo: String? + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity whose payment + /// history this row belongs to. Non-optional: every payment row + /// exists *because of* an owner identity. Cascade-deleted from + /// `PersistentIdentity.dashpayPayments`. + public var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping, not payment dates) + + public var createdAt: Date + public var lastUpdated: Date + + // MARK: - Initialization + + public init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } +} + +// MARK: - Queries + +extension PersistentDashpayPayment { + /// Predicate filtering all payment rows that belong to a specific + /// owner identity. Filters on the denormalized `ownerIdentityId` + /// scalar so SwiftData's predicate engine doesn't have to traverse + /// the `owner` relationship — same shape as the contact-request + /// predicate. + public static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + /// Counterparty-scoped variant of [`predicate(ownerIdentityId:)`] + /// — the payment list on a `ContactDetailView` shows only the + /// history with that one contact. + public static func predicate( + ownerIdentityId: Data, + counterpartyIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let counterparty = counterpartyIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.counterpartyIdentityId == counterparty + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift index dc89ac85ed5..9ace0361b53 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift @@ -118,6 +118,37 @@ public final class PersistentIdentity { @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) public var contactRequests: [PersistentDashpayContactRequest] = [] + /// DashPay payment-history rows owned by this identity. + /// Cascade-deleted from the parent. Same + /// query-by-denormalized-id pattern as `contactRequests`: filters + /// use `PersistentDashpayPayment.predicate(ownerIdentityId:)` + /// rather than walking this collection from a SwiftUI view. + /// Populated by `PlatformWalletManager.refreshDashPayPayments` + /// (FFI getter → upsert), not by the persister callback. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + public var dashpayPayments: [PersistentDashpayPayment] = [] + + /// DashPay ignored senders (per-sender mute, = block, reversible, + /// local-only) owned by this identity. Cascade-deleted from the parent. + /// Persisted from the `ignored` changeset array by `persistContacts` + /// and read back at load to rebuild the Rust `ignored_senders` set — + /// without them an ignored sender resurfaces on relaunch. Filters use + /// `PersistentDashpayIgnoredSender.predicate(ownerIdentityId:)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + public var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + + /// Cached DashPay **contact** profiles owned by this identity (one + /// per contact whose public profile has been fetched). Cascade-deleted + /// from the parent. Same query-by-denormalized-id pattern as + /// `contactRequests`: filters use + /// `PersistentDashpayContactProfile.predicate(ownerIdentityId:)` rather + /// than walking this collection from a SwiftUI view. Populated by the + /// persister callback (`IdentityEntryFFI.contact_profiles` rows) and + /// read back at load to rebuild the Rust `contact_profiles` map. + /// Distinct from the owner's own `dashpayProfile`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + public var contactProfiles: [PersistentDashpayContactProfile] = [] + // Contracts in the local store that name this identity as their // owner. `.nullify` so deleting the identity leaves the contract // rows alive (with `ownerIdentity` nulled) — matches the user's @@ -163,6 +194,9 @@ public final class PersistentIdentity { self.dpnsNames = [] self.dashpayProfile = nil self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] self.ownedDataContracts = [] self.createdAt = Date() self.lastUpdated = Date() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayPayment.swift new file mode 100644 index 00000000000..4896d858aa7 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayPayment.swift @@ -0,0 +1,97 @@ +import Foundation +import DashSDKFFI + +/// Direction of a DashPay payment from the owner's perspective. +/// Mirrors the FFI `DashpayPaymentDirectionFFI` discriminants. +public enum DashPayPaymentDirection: UInt8, Sendable, Equatable { + /// The owner sent this payment to the counterparty. + case sent = 0 + /// The owner received this payment from the counterparty. + case received = 1 +} + +/// Core-chain status of a DashPay payment. Mirrors the FFI +/// `DashpayPaymentStatusFFI` discriminants. +public enum DashPayPaymentStatus: UInt8, Sendable, Equatable { + /// Broadcast but not yet confirmed. + case pending = 0 + /// Confirmed on Core chain. + case confirmed = 1 + /// Broadcast failed or the transaction was dropped. + case failed = 2 +} + +/// One DashPay payment-history entry — a Swift-owned copy of the +/// Rust-side `PaymentEntry` row on a `ManagedIdentity` +/// (`dashpay_payments`, keyed by txid). +/// +/// The source `PaymentEntry` carries **no timestamp field** (the +/// underlying model keys history by txid and does not record a +/// wall-clock time), so none is surfaced here — ordering is by txid / +/// arrival, matching the Rust map. +/// +/// Read via `ManagedIdentity.getDashPayPayments()` / +/// `ManagedPlatformWallet.getDashPayPayments(identityId:)`, persisted +/// into `PersistentDashpayPayment` rows by +/// `PlatformWalletManager.refreshDashPayPayments(walletId:identityId:)`. +public struct DashPayPayment: Sendable, Equatable { + /// The other identity in this payment. Whether they are the + /// sender or the receiver is encoded in `direction`. + public let counterpartyId: Identifier + /// Amount in duffs. Always positive; `direction` carries the sign. + public let amountDuffs: UInt64 + /// Payment direction from the owner's perspective. + public let direction: DashPayPaymentDirection + /// Core-chain status. + public let status: DashPayPaymentStatus + /// Transaction id (hex), the Rust `dashpay_payments` map key. + public let txid: String + /// Sender memo, when present. `nil` mirrors the source `Option` + /// being `None`. + public let memo: String? + + public init( + counterpartyId: Identifier, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.counterpartyId = counterpartyId + self.amountDuffs = amountDuffs + self.direction = direction + self.status = status + self.txid = txid + self.memo = memo + } + + /// Copy a `DashpayPaymentFFI` row into a Swift-owned value. The + /// caller retains ownership of the FFI struct and is responsible + /// for freeing the array afterward with + /// `dashpay_payment_array_free` — this initializer only *reads* + /// the pointers. + /// + /// Unknown direction / status discriminants fall back to `.sent` / + /// `.pending` rather than failing — a newer Rust enum case must + /// not make the whole history unreadable. + init(ffi: DashpayPaymentFFI) { + var counterparty = ffi.counterparty_id + self.counterpartyId = Swift.withUnsafeBytes(of: &counterparty) { Data($0) } + self.amountDuffs = ffi.amount_duffs + self.direction = DashPayPaymentDirection(rawValue: ffi.direction) ?? .sent + self.status = DashPayPaymentStatus(rawValue: ffi.status) ?? .pending + if let txidPtr = ffi.txid { + self.txid = String(cString: txidPtr) + } else { + // `txid` is documented always non-null; degrade to an + // empty string defensively rather than trapping. + self.txid = "" + } + if let memoPtr = ffi.memo { + self.memo = String(cString: memoPtr) + } else { + self.memo = nil + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift index 6e37c9ba826..367ab32b20a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift @@ -377,10 +377,12 @@ public final class ManagedIdentity: @unchecked Sendable { try managed_identity_accept_contact_request(handle, request.handle).check() } - /// Reject a contact request from another identity - public func rejectContactRequest(senderId: Identifier) throws { + /// Ignore a contact sender (per-sender mute, = block, reversible). + /// Local in-memory path on this handle (no persister) — the durable + /// path is `ManagedPlatformWallet.ignoreContactSender`. + public func ignoreContactSender(senderId: Identifier) throws { try senderId.withFFIBytes { idPtr in - try managed_identity_reject_contact_request(handle, idPtr).check() + try managed_identity_ignore_contact_sender(handle, idPtr).check() } } @@ -455,4 +457,29 @@ public final class ManagedIdentity: @unchecked Sendable { guard hasProfile else { return nil } return DashPayProfile(ffi: ffiProfile) } + + // MARK: - DashPay payment history + + /// Read this identity's DashPay payment history — the + /// `dashpay_payments` map (keyed by txid) maintained by the Rust + /// wallet — as Swift-owned values. + /// + /// Sync, lock-free read of the in-memory cache. The source + /// `PaymentEntry` carries no timestamp, so entries are unordered + /// beyond the map's txid keying. Empty array when no payments + /// have been recorded. + public func getDashPayPayments() throws -> [DashPayPayment] { + var array = DashpayPaymentArray() + try managed_identity_get_dashpay_payments(handle, &array).check() + defer { dashpay_payment_array_free(&array) } + guard let items = array.items, array.count > 0 else { + return [] + } + var payments: [DashPayPayment] = [] + payments.reserveCapacity(Int(array.count)) + for i in 0.. ContactRequest { let handle = self.handle let signerHandle = signer.handle + // Resolver-backed core signer: the contact-request crypto (friendship + // xpub, ECDH shared secret, DIP-15 accountReference) is derived through + // the Keychain mnemonic resolver Rust-side, so no resident seed is + // needed and watch-only / external-signable wallets work. + let coreSigner = MnemonicResolver() let senderBytes: [UInt8] = senderIdentityId.withFFIBytes { ptr in Array(UnsafeBufferPointer(start: ptr, count: 32)) } @@ -1646,45 +1651,120 @@ extension ManagedPlatformWallet { let requestHandle: Handle = try await Task.detached(priority: .userInitiated) { () -> Handle in - _ = signer var outHandle: Handle = NULL_HANDLE - let result: PlatformWalletFFIResult = senderBytes.withUnsafeBufferPointer { - senderBp -> PlatformWalletFFIResult in - recipientBytes.withUnsafeBufferPointer { recipientBp -> PlatformWalletFFIResult in - let callWithLabel: (UnsafePointer?) -> PlatformWalletFFIResult = { - labelPtr in - if let autoAcceptProof, !autoAcceptProof.isEmpty { - return autoAcceptProof.withUnsafeBytes { rawBuf in - let bytesPtr = rawBuf.baseAddress? - .assumingMemoryBound(to: UInt8.self) + // `withExtendedLifetime` keeps both the document signer and the + // resolver alive across the synchronous FFI call — the optimizer + // can otherwise drop them mid-call and a vtable callback would + // use-after-free. + let result: PlatformWalletFFIResult = withExtendedLifetime((signer, coreSigner)) { + senderBytes.withUnsafeBufferPointer { + senderBp -> PlatformWalletFFIResult in + recipientBytes.withUnsafeBufferPointer { recipientBp -> PlatformWalletFFIResult in + let callWithLabel: (UnsafePointer?) -> PlatformWalletFFIResult = { + labelPtr in + if let autoAcceptProof, !autoAcceptProof.isEmpty { + return autoAcceptProof.withUnsafeBytes { rawBuf in + let bytesPtr = rawBuf.baseAddress? + .assumingMemoryBound(to: UInt8.self) + return platform_wallet_send_contact_request_with_signer( + handle, + senderBp.baseAddress!, + recipientBp.baseAddress!, + labelPtr, + bytesPtr, + UInt(autoAcceptProof.count), + signerHandle, + coreSigner.handle, + &outHandle + ) + } + } else { return platform_wallet_send_contact_request_with_signer( handle, senderBp.baseAddress!, recipientBp.baseAddress!, labelPtr, - bytesPtr, - UInt(autoAcceptProof.count), + nil, + 0, signerHandle, + coreSigner.handle, &outHandle ) } + } + if let accountLabel { + return accountLabel.withCString { callWithLabel($0) } } else { - return platform_wallet_send_contact_request_with_signer( - handle, - senderBp.baseAddress!, - recipientBp.baseAddress!, - labelPtr, - nil, - 0, - signerHandle, - &outHandle - ) + return callWithLabel(nil) } } - if let accountLabel { - return accountLabel.withCString { callWithLabel($0) } - } else { - return callWithLabel(nil) + } + } + try result.check() + return outHandle + }.value + + return ContactRequest(handle: requestHandle) + } + + /// Build a DIP-15 auto-accept QR URI (`dash:?du=&dapk=`) + /// for this wallet, valid for 1 hour. `username` is the owner's DPNS name. The + /// returned URI is rendered as a QR; a scanner sends a contact request the + /// owner auto-accepts. All derivation/encoding happens Rust-side. + public func buildAutoAcceptQR(username: String) async throws -> String { + let handle = self.handle + let coreSigner = MnemonicResolver() + return try await Task.detached(priority: .userInitiated) { () -> String in + var outURI: UnsafeMutablePointer? + let result: PlatformWalletFFIResult = withExtendedLifetime(coreSigner) { + username.withCString { uPtr in + platform_wallet_build_auto_accept_qr( + handle, + uPtr, + coreSigner.handle, + &outURI + ) + } + } + try result.check() + guard let outURI else { + throw PlatformWalletError.nullPointer("auto-accept QR returned a null URI") + } + let uri = String(cString: outURI) + platform_wallet_string_free(outURI) + return uri + }.value + } + + /// Send a contact request from a scanned DIP-15 auto-accept QR + /// (`dash:?du=&dapk=`): resolve the username, decode the + /// handed key, sign the proof, and broadcast — so the owner auto-accepts it. + public func sendContactRequestFromQR( + senderIdentityId: Identifier, + uri: String, + signer: KeychainSigner + ) async throws -> ContactRequest { + let handle = self.handle + let signerHandle = signer.handle + let coreSigner = MnemonicResolver() + let senderBytes: [UInt8] = senderIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + + let requestHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + let result: PlatformWalletFFIResult = withExtendedLifetime((signer, coreSigner)) { + senderBytes.withUnsafeBufferPointer { senderBp -> PlatformWalletFFIResult in + uri.withCString { uriPtr in + platform_wallet_send_contact_request_from_qr( + handle, + senderBp.baseAddress!, + uriPtr, + signerHandle, + coreSigner.handle, + &outHandle + ) } } } @@ -1705,29 +1785,39 @@ extension ManagedPlatformWallet { let walletHandle = self.handle let requestHandle = request.handle let signerHandle = signer.handle + // Resolver-backed core signer: the reciprocal request's contact crypto + // (ECDH + external-account registration) is derived through the Keychain + // mnemonic resolver Rust-side, so no resident seed is needed. + let coreSigner = MnemonicResolver() let establishedHandle: Handle = try await Task.detached( priority: .userInitiated ) { () -> Handle in - _ = signer var outHandle: Handle = NULL_HANDLE - let result = platform_wallet_accept_contact_request_with_signer( - walletHandle, - requestHandle, - signerHandle, - &outHandle - ) + // Keep both signers alive across the FFI call (vtable callbacks fire + // during it); a bare `_ = ...` lets the optimizer drop them. + let result = withExtendedLifetime((signer, coreSigner)) { + platform_wallet_accept_contact_request_with_signer( + walletHandle, + requestHandle, + signerHandle, + coreSigner.handle, + &outHandle + ) + } try result.check() return outHandle }.value return EstablishedContact(handle: establishedHandle) } - /// Reject an incoming contact request. Today the effect is - /// local — drops it from `ManagedIdentity.incoming_contact_requests`. - /// A future follow-up (TODO in the Rust `reject_contact_request`) - /// will also write a `display_hidden` contactInfo document so - /// the rejection persists across devices. - public func rejectContactRequest( + /// Ignore a contact sender (per-sender mute, = block, reversible). + /// + /// Drops the sender's pending incoming request and suppresses ALL of + /// their requests (including rotated ones) from the main pending list + /// on every future sync sweep. Ignore is **local-only** — no on-chain + /// artifact; it's persisted through the changeset → SwiftData pipeline + /// so it survives a relaunch. Reverse with `unignoreContactSender`. + public func ignoreContactSender( ourIdentityId: Identifier, contactIdentityId: Identifier ) async throws { @@ -1742,7 +1832,39 @@ extension ManagedPlatformWallet { let result = ourBytes.withUnsafeBufferPointer { ourBp -> PlatformWalletFFIResult in contactBytes.withUnsafeBufferPointer { contactBp in - platform_wallet_reject_contact_request( + platform_wallet_ignore_contact_sender( + handle, + ourBp.baseAddress!, + contactBp.baseAddress! + ) + } + } + try result.check() + }.value + } + + /// Un-ignore a contact sender (reverse `ignoreContactSender`). + /// + /// Removes the sender from the ignore set and rewinds the received + /// sync cursor so the next sweep re-fetches their on-chain requests + /// (otherwise the cursor has already passed them and they'd never + /// reappear). A no-op when the sender wasn't ignored. + public func unignoreContactSender( + ourIdentityId: Identifier, + contactIdentityId: Identifier + ) async throws { + let handle = self.handle + let ourBytes: [UInt8] = ourIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let contactBytes: [UInt8] = contactIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + try await Task.detached(priority: .userInitiated) { + let result = ourBytes.withUnsafeBufferPointer { + ourBp -> PlatformWalletFFIResult in + contactBytes.withUnsafeBufferPointer { contactBp in + platform_wallet_unignore_contact_sender( handle, ourBp.baseAddress!, contactBp.baseAddress! @@ -1807,6 +1929,12 @@ extension ManagedPlatformWallet { Array(UnsafeBufferPointer(start: ptr, count: 32)) } let memoCopy = memo + // Resolver-backed core signer owns mnemonic access for the lifetime + // of this call. Each funding-input ECDSA signature happens atomically + // inside the resolver vtable (mnemonic fetched from Keychain, key + // derived, digest signed, buffers zeroed) — the seed never becomes + // resident and no private key leaves Swift. + let coreSigner = MnemonicResolver() return try await Task.detached(priority: .userInitiated) { () -> Data in var txidTuple: ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, @@ -1817,23 +1945,29 @@ extension ManagedPlatformWallet { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ) - let result: PlatformWalletFFIResult = fromBytes.withUnsafeBufferPointer { - fromBp -> PlatformWalletFFIResult in - toBytes.withUnsafeBufferPointer { toBp -> PlatformWalletFFIResult in - let call: (UnsafePointer?) -> PlatformWalletFFIResult = { memoPtr in - platform_wallet_send_dashpay_payment( - handle, - fromBp.baseAddress!, - toBp.baseAddress!, - amountDuffs, - memoPtr, - &txidTuple - ) - } - if let memoCopy { - return memoCopy.withCString { call($0) } - } else { - return call(nil) + // `withExtendedLifetime` (not a bare `_ = coreSigner`) keeps the + // resolver alive across the synchronous FFI call — the optimizer + // can otherwise drop it mid-call and the vtable callback would + // use-after-free. + let result: PlatformWalletFFIResult = withExtendedLifetime(coreSigner) { + fromBytes.withUnsafeBufferPointer { fromBp -> PlatformWalletFFIResult in + toBytes.withUnsafeBufferPointer { toBp -> PlatformWalletFFIResult in + let call: (UnsafePointer?) -> PlatformWalletFFIResult = { memoPtr in + platform_wallet_send_dashpay_payment( + handle, + fromBp.baseAddress!, + toBp.baseAddress!, + amountDuffs, + memoPtr, + coreSigner.handle, + &txidTuple + ) + } + if let memoCopy { + return memoCopy.withCString { call($0) } + } else { + return call(nil) + } } } } @@ -1875,6 +2009,59 @@ extension ManagedPlatformWallet { return DashPayProfile(ffi: ffiProfile) } + /// Read the cached profile of a **contact** (by contact identity id) + /// under `ownerIdentityId`, from this wallet's live state. + /// + /// Returns `nil` when the owner has no cached entry for that contact, or + /// the contact published no profile on Platform. The cache is populated by + /// the background contact-profile sync and covers established contacts and + /// pending senders. For a contact that is itself one of the wallet's own + /// identities, use `getDashPayProfile(identityId:)` (its own profile is + /// authoritative) — the contact cache intentionally skips such ids. + /// + /// Sync, lock-free read of the in-memory cache. + public func getContactProfile( + ownerIdentityId: Identifier, + contactIdentityId: Identifier + ) throws -> DashPayProfile? { + var ffiProfile = DashPayProfileFFI() + var hasProfile: Bool = false + + let result = ownerIdentityId.withFFIBytes { ownerPtr in + contactIdentityId.withFFIBytes { contactPtr in + platform_wallet_get_contact_profile( + handle, + ownerPtr, + contactPtr, + &ffiProfile, + &hasProfile + ) + } + } + defer { dashpay_profile_ffi_free(&ffiProfile) } + + try result.check() + guard hasProfile else { return nil } + return DashPayProfile(ffi: ffiProfile) + } + + /// Read the DashPay payment history for `identityId` directly + /// from this wallet's live state. + /// + /// Convenient for UI layers that track identities by ID and don't + /// hold a live `ManagedIdentity` handle. Throws + /// `.identityNotFound` when the wallet doesn't know this + /// identity; returns an empty array when no payments have been + /// recorded. + /// + /// Sync, lock-free read of the in-memory cache. To land the rows + /// in SwiftData for `@Query` consumption, go through + /// `PlatformWalletManager.refreshDashPayPayments(walletId:identityId:)` + /// instead. + public func getDashPayPayments(identityId: Identifier) throws -> [DashPayPayment] { + try managedIdentity(identityId: identityId).getDashPayPayments() + } + /// Refresh every managed identity's DashPay profile cache from /// Platform. /// @@ -2007,6 +2194,85 @@ extension ManagedPlatformWallet { }.value } + /// Set the owner-private alias / note / hidden flag for an + /// established contact and publish the self-encrypted + /// `contactInfo` document. Local state (and hence + /// the SwiftData contact rows) updates immediately; the network + /// write is deferred by the Rust side under DIP-15's + /// ≥2-established-contacts privacy rule. + /// Outcome of `setDashPayContactInfo`: local state is always updated, + /// but the cross-device document publish may be deferred or skipped. + /// Mirrors the Rust `ContactInfoPublishOutcome` / the FFI + /// `CONTACT_INFO_*` discriminants. + public enum ContactInfoPublishOutcome: Sendable { + /// Published on Platform — synced cross-device. + case published + /// Saved locally; publish deferred by DIP-15 until the identity + /// has at least two established contacts. + case deferredUntilTwoContacts + /// Saved locally; publish not possible for a watch-only identity. + case skippedWatchOnly + } + + @discardableResult + public func setDashPayContactInfo( + identityId: Identifier, + contactId: Identifier, + alias: String?, + note: String?, + hidden: Bool, + signer: KeychainSigner + ) async throws -> ContactInfoPublishOutcome { + let handle = self.handle + let signerHandle = signer.handle + // Resolver-backed core signer: the contactInfo seal/find-existing crypto + // is derived through the Keychain mnemonic resolver Rust-side, so no + // resident seed is needed. + let coreSigner = MnemonicResolver() + let idBytes: [UInt8] = identityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let contactBytes: [UInt8] = contactId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + + let outcomeRaw: UInt8 = try await Task.detached(priority: .userInitiated) { + var outcomeRaw: UInt8 = 0 + // Keep both signers alive across the FFI call (vtable callbacks fire + // during it); a bare `_ = ...` lets the optimizer drop them. + let result: PlatformWalletFFIResult = withExtendedLifetime((signer, coreSigner)) { + idBytes.withUnsafeBufferPointer { idBp in + contactBytes.withUnsafeBufferPointer { contactBp in + invokeWithOptionalCStrings(alias, note, nil) { aliasPtr, notePtr, _ in + platform_wallet_set_dashpay_contact_info_with_signer( + handle, + idBp.baseAddress!, + contactBp.baseAddress!, + aliasPtr, + notePtr, + hidden, + signerHandle, + coreSigner.handle, + &outcomeRaw + ) + } + } + } + } + try result.check() + return outcomeRaw + }.value + + switch outcomeRaw { + case UInt8(CONTACT_INFO_DEFERRED_UNTIL_TWO_CONTACTS): + return .deferredUntilTwoContacts + case UInt8(CONTACT_INFO_SKIPPED_WATCH_ONLY): + return .skippedWatchOnly + default: + return .published + } + } + } // MARK: - In-memory state (Wallet Memory Explorer) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 0e433d368ef..686d3cfe9a1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -16,6 +16,34 @@ final class ShieldedSyncGenerationCounter: @unchecked Sendable { @discardableResult func bump() -> UInt64 { lock.withLock { value &+= 1; return value } } } +/// Per-wallet DashPay "needs unlock / verify failed" status, surfaced for the +/// UI. One coherent snapshot per wallet (not parallel dictionaries) so a banner +/// is a pure function of one `Equatable` value. +public struct DashPayUnlockStatus: Equatable { + /// Count of deferred account-build contact-crypto ops waiting for a signer + /// unlock to finish payment-account setup. A wallet-scoped upper bound + /// (aggregates the wallet's identities; may include ops that resolve to + /// channel-broken on the next drain) — phrase it as "waiting," not "will + /// succeed." Polled from `platform_wallet_pending_contact_crypto_count`. + public var pendingAccountBuilds: UInt32 = 0 + /// The Keychain-resolved seed does not bind to this wallet (Rust + /// `SeedMismatch`): DashPay signing is disabled until the mapping is fixed. + /// Set from the verify FFI result at unlock. + public var seedMismatch: Bool = false + /// A deferred-crypto drain is in flight. Drives a "finishing…" state and + /// disables a second Unlock tap so the banner can't kick a concurrent drain. + public var draining: Bool = false + + public init(pendingAccountBuilds: UInt32 = 0, seedMismatch: Bool = false, draining: Bool = false) { + self.pendingAccountBuilds = pendingAccountBuilds + self.seedMismatch = seedMismatch + self.draining = draining + } + + /// Whether anything is worth showing the user (a banner host can early-out). + public var hasSignal: Bool { seedMismatch || draining || pendingAccountBuilds > 0 } +} + /// The one thing SwiftUI needs for all wallet operations. /// /// Owns the Rust-side `PlatformWalletManager` handle which drives: @@ -58,6 +86,18 @@ public class PlatformWalletManager: ObservableObject { /// a pass in flight. @Published public private(set) var shieldedSyncIsSyncing: Bool = false + /// Whether the Rust-owned DashPay sync coordinator currently has + /// a pass in flight. The single sync-in-progress signal: all + /// three DashPay sync callers (`.task`, pull-to-refresh, the + /// background loop) observe this one flag, and a pull-to-refresh + /// during an in-flight sync attaches to it instead of + /// double-firing. Updated by the polling task started in + /// [`configure`]. Named after the `shieldedSyncIsSyncing` / + /// `platformAddressSyncIsSyncing` mirrors (the natural + /// `isDashPaySyncing` would collide with the wrapper method of + /// that name). + @Published public private(set) var dashPaySyncIsSyncing: Bool = false + /// Last completed shielded sync event emitted by Rust. @Published public internal(set) var lastShieldedSyncEvent: ShieldedSyncEvent? @@ -119,6 +159,14 @@ public class PlatformWalletManager: ObservableObject { /// assuming a single "active" wallet. @Published public private(set) var wallets: [Data: ManagedPlatformWallet] = [:] + /// Per-wallet DashPay needs-unlock / verify-failed status, keyed by the + /// 32-byte wallet id. `pendingAccountBuilds` is refreshed by the progress + /// poller; `seedMismatch` and `draining` are set at the unlock / drain call + /// sites. Keys are pruned when a wallet leaves [`wallets`] (and explicitly + /// on [`deleteWallet`]) so a re-created wallet with the same deterministic + /// id can't inherit a stale banner. + @Published public private(set) var dashPayUnlockStatus: [Data: DashPayUnlockStatus] = [:] + /// Last error from a wallet operation, if any. Cleared on successful op. @Published public private(set) var lastError: Error? @@ -131,6 +179,13 @@ public class PlatformWalletManager: ObservableObject { /// context pointer remains valid. private var persistenceHandler: PlatformWalletPersistenceHandler? + /// SwiftData container + network captured at `configure`, used to build a + /// `KeychainSigner` (the identity document signer) for the unlock-time + /// auto-accept drain. Nil when configured without persistence (no Keychain + /// signing possible → the drain runs provider-only). + private var modelContainer: ModelContainer? + private var signerNetwork: Network? + /// Retained for the lifetime of the FFI handle so the event-handler /// context pointer remains valid. private var eventHandler: PlatformWalletEventHandler? @@ -155,6 +210,7 @@ public class PlatformWalletManager: ObservableObject { if handle != NULL_HANDLE { platform_wallet_manager_platform_address_sync_stop(handle).discard() platform_wallet_manager_shielded_sync_stop(handle).discard() + platform_wallet_manager_dashpay_sync_stop(handle).discard() platform_wallet_manager_destroy(handle).discard() } } @@ -223,6 +279,8 @@ public class PlatformWalletManager: ObservableObject { self.handle = handle self.persistenceHandler = handler self.eventHandler = eventHandler + self.modelContainer = modelContainer + self.signerNetwork = network self.isConfigured = true startProgressPolling() @@ -323,15 +381,20 @@ public class PlatformWalletManager: ObservableObject { /// /// Calls `platform_wallet_manager_load_from_persistor` which fires /// the Swift-side `on_load_wallet_list_fn` callback. For each - /// persisted wallet, Rust reconstructs a **watch-only** `Wallet` - /// plus the wallet's persisted platform-address sync snapshot. - /// After the FFI returns, we call `platform_wallet_manager_get_wallet` - /// for each restored id so Swift gets a `ManagedPlatformWallet` - /// handle. + /// persisted wallet, Rust reconstructs an **external-signable** + /// (watch-only, no key material) `Wallet` plus the wallet's + /// persisted platform-address sync snapshot. After the FFI returns, + /// we call `platform_wallet_manager_get_wallet` for each restored id + /// so Swift gets a `ManagedPlatformWallet` handle. /// - /// Signing operations will fail until a future unlock flow - /// upgrades a watch-only wallet to a signing wallet via the - /// mnemonic stored in Keychain. + /// Each restored wallet then runs the seedless unlock via + /// [`unlockWalletFromKeychain`](Self/unlockWalletFromKeychain(_:)): it + /// verifies the Keychain-resolved seed binds to the wallet (refusing a + /// mis-mapped slot) and drains any contact-crypto deferred while the + /// wallet was seedless — the seed never becomes resident; signing runs + /// through the resolver. Wallets with no stored mnemonic (genuine + /// watch-only) stay watch-only — the unlock is best-effort per + /// wallet and never fails the restore. /// /// Idempotent: if there's no persisted state, does nothing and /// leaves `self.wallets` untouched. Safe to call before any @@ -375,6 +438,44 @@ public class PlatformWalletManager: ObservableObject { let managedWallet = ManagedPlatformWallet(handle: walletHandle, walletId: walletId) restored.append(managedWallet) self.wallets[walletId] = managedWallet + + // Seedless unlock of the just-restored external-signable + // (watch-only) wallet: verify the Keychain-resolved seed binds + // to this wallet and drain any deferred contact-crypto. + // Best-effort, per wallet: a wallet with no stored mnemonic + // (genuine watch-only) stays watch-only, and any unlock error + // (e.g. a mis-mapped Keychain slot) is logged-and-continued so + // one wallet can't fail the whole restore. + do { + let unlocked = try unlockWalletFromKeychain(managedWallet) + print( + "🔓 wallet unlock \(walletId.toHexString().prefix(8)): " + + (unlocked ? "seed verified — drain scheduled" : "no mnemonic — stays watch-only") + ) + } catch let error as PlatformWalletError { + // Distinguish a wrong-seed binding (Rust `SeedMismatch` → + // `ErrorInvalidParameter` → `.invalidParameter`) from a + // transient failure. The verify FFI is the only `.check()` on + // this path and `walletId` is already 32 bytes here, so + // `.invalidParameter` ≡ the seed-binding rejection — a + // security-relevant Keychain slot mis-mapping, not a hiccup. + // Either way the wallet stays external-signable (cannot sign), + // so no wrong-seed signing can occur. + if case .invalidParameter = error { + print( + "🚫 wallet unlock REJECTED \(walletId.toHexString().prefix(8)): " + + "seed does not bind (mis-mapped Keychain slot?) — stays watch-only" + ) + } else { + // Transient (resolver/Keychain unavailable, …) — not + // retried this pass; a later signer-present action re-tries. + print("⚠️ wallet unlock failed \(walletId.toHexString().prefix(8)) (transient): \(error)") + } + self.lastError = error + } catch { + print("❌ wallet unlock failed \(walletId.toHexString().prefix(8)): \(error)") + self.lastError = error + } } catch { // Log and skip — one wallet failing doesn't fail the // whole restore. Usually means wallet_id / xpub @@ -399,6 +500,149 @@ public class PlatformWalletManager: ObservableObject { return restored } + // MARK: - Keychain seed unlock + + /// Seedless unlock of a restored external-signable wallet. + /// + /// The persisted-restore path (`loadFromPersistor`) rehydrates every + /// wallet **external-signable** — per-account xpubs only, no key + /// material. Rather than grafting a resident seed back on, signing runs + /// through the Keychain-backed resolver per-operation. This unlock does + /// two things, both through a resolver (the seed never becomes resident): + /// + /// 1. **Verify** the resolved seed binds to this wallet — + /// `platform_wallet_verify_seed_binds_to_wallet` derives the BIP44 + /// account-0 xpub through the resolver and compares it to the + /// persisted one. A mis-mapped Keychain slot derives a different xpub + /// and the call throws, so a wrong seed never signs for this wallet. + /// 2. **Drain** (in the background) any contact-crypto deferred while + /// the wallet was seedless — `platform_wallet_drain_pending_contact_crypto`. + /// The drain re-fetches + decrypts over the network, so it runs in a + /// detached task off the caller's thread. + /// + /// Per the Swift-SDK FFI boundary rules, the mnemonic → seed conversion + /// happens entirely inside the resolver vtable in Rust; Swift only + /// checks the Keychain entry's existence (`hasMnemonic`) and never pulls + /// the plaintext across. + /// + /// - Parameter wallet: the restored `ManagedPlatformWallet`. + /// - Returns: `true` if the wallet's seed verified (drain scheduled); + /// `false` if no mnemonic is stored for this wallet (a genuine + /// watch-only wallet), without throwing. + /// - Throws: `PlatformWalletError` if the verify FFI fails (e.g. the + /// resolved seed does not bind — a mis-mapped Keychain slot). + @discardableResult + public func unlockWalletFromKeychain(_ wallet: ManagedPlatformWallet) throws -> Bool { + try ensureConfigured() + let walletId = wallet.walletId + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be 32 bytes, got \(walletId.count)" + ) + } + + // A genuine watch-only wallet (imported by xpub, never holding a + // seed) has no Keychain mnemonic — stays watch-only. Existence-only + // check; the plaintext is never materialized in Swift. + guard WalletStorage().hasMnemonic(for: walletId) else { + return false + } + + let walletHandle = wallet.handle + // Resolver-backed signer: the mnemonic is fetched from the Keychain + // inside the resolver vtable Rust-side; no resident seed. + let coreSigner = MnemonicResolver() + + // Wrong-seed / wrong-wallet gate. `withExtendedLifetime` keeps the + // resolver alive across the synchronous FFI call (its vtable callback + // fires during it). Throws if the resolved seed derives a different + // BIP44 account-0 xpub than the wallet's persisted one. + // + // Publish the per-wallet `seedMismatch` from the verify result itself, + // scoped to JUST this call: the verify FFI maps Rust `SeedMismatch` → + // `.invalidParameter`, and scoping the catch here keeps the earlier + // 32-byte `walletId` precondition (also `.invalidParameter`) from being + // mistaken for a seed mismatch. Rethrow so the existing caller handling + // (loadFromPersistor's log-and-continue) is unchanged. + do { + try withExtendedLifetime(coreSigner) { + try platform_wallet_verify_seed_binds_to_wallet( + walletHandle, + coreSigner.handle + ).check() + } + setDashPaySeedMismatch(walletId, false) + } catch let error as PlatformWalletError { + if case .invalidParameter = error { + setDashPaySeedMismatch(walletId, true) + } + throw error + } + + // Don't stack a second drain on an in-flight one: a banner Unlock tap + // (or a second unlock) while a drain runs would duplicate the network + // re-fetch + ECDH work and race the channel-broken writes. The banner + // also disables Unlock while `draining`, but guard here too. + if dashPayUnlockStatus[walletId]?.draining == true { + return true + } + setDashPayDraining(walletId, true) + + // Identity document signer for the DIP-15 auto-accept pass (which sends + // the reciprocal contact request). Nil when no SwiftData container is + // configured → the drain runs provider-only (account build / contactInfo) + // and skips auto-accept. `KeychainSigner` is `@unchecked Sendable`; + // captured (with the resolver) in the detached task below. + let identitySigner: KeychainSigner? = self.modelContainer.map { + KeychainSigner(modelContainer: $0, network: self.signerNetwork ?? .testnet) + } + + // Drain deferred contact-crypto in the background — it re-fetches and + // decrypts over the network, so it must not block the caller. The + // detached task retains `coreSigner` (+ the identity signer), keeping + // them alive for the drain's vtable callbacks. It captures the raw + // `walletHandle` (a `UInt64`), not the `ManagedPlatformWallet`: if the + // wallet is destroyed before the drain runs, `with_item` Rust-side simply + // misses the handle and the drain no-ops (NotFound) — no use-after-free. + // Fire-and-forget: a failure here is not fatal (the next signer-present + // DashPay action re-attempts the drain via its own provider), but it is + // no longer swallowed silently — the failure lands on `lastError` and + // the `draining` flag is cleared, both on the main actor. + Task.detached(priority: .utility) { [weak self] in + var drained: UInt32 = 0 + let result = withExtendedLifetime((coreSigner, identitySigner)) { + platform_wallet_drain_pending_contact_crypto( + walletHandle, + identitySigner?.handle, + coreSigner.handle, + &drained + ) + } + let drainError: Error? = { + do { try result.check(); return nil } catch { return error } + }() + await MainActor.run { + self?.setDashPayDraining(walletId, false) + if let drainError { + self?.lastError = drainError + print( + "⚠️ contact-crypto drain failed for " + + "\(walletId.toHexString().prefix(8)): \(drainError)" + ) + } else if drained > 0 { + // `drained` counts cleared queue entries — both completed + // and permanently-failed (channel-broken) ops — so report + // it neutrally rather than implying all succeeded. + print( + "🔑 processed \(drained) deferred contact-crypto op(s) for " + + "\(walletId.toHexString().prefix(8))" + ) + } + } + } + return true + } + /// For every persisted asset lock at `statusRaw < 2` (Built / /// Broadcast), kick off a background `Task` that drives /// `asset_lock_manager_catch_up_blocking` to completion or @@ -618,6 +862,10 @@ public class PlatformWalletManager: ObservableObject { } wallets.removeValue(forKey: walletId) + // Drop the needs-unlock banner state immediately so a re-created wallet + // with the same deterministic id doesn't inherit a stale banner (the + // poller would also prune it, but not until the next tick). + dashPayUnlockStatus.removeValue(forKey: walletId) try persistenceHandler.deleteWalletData(walletId: walletId) @@ -664,6 +912,39 @@ public class PlatformWalletManager: ObservableObject { return key.flatMap { wallets[$0] } } + // MARK: - DashPay needs-unlock signal + + /// Count of deferred **account-build** contact-crypto ops queued for the + /// wallet (the contacts waiting for a signer unlock to finish payment-account + /// setup). Thin bridge over `platform_wallet_pending_contact_crypto_count`; + /// the Rust side decides what counts (account-build ops only). Signerless — + /// safe to poll. + public func pendingAccountBuildCount(for walletId: Data) throws -> UInt32 { + guard let wallet = wallets[walletId] else { + throw PlatformWalletError.invalidParameter("unknown wallet") + } + var count: UInt32 = 0 + try platform_wallet_pending_contact_crypto_count(wallet.handle, &count).check() + return count + } + + /// Update `seedMismatch` for a wallet, gated on change to avoid needless + /// `@Published` churn. + private func setDashPaySeedMismatch(_ walletId: Data, _ value: Bool) { + var status = dashPayUnlockStatus[walletId] ?? .init() + guard status.seedMismatch != value else { return } + status.seedMismatch = value + dashPayUnlockStatus[walletId] = status + } + + /// Update `draining` for a wallet, gated on change. + private func setDashPayDraining(_ walletId: Data, _ value: Bool) { + var status = dashPayUnlockStatus[walletId] ?? .init() + guard status.draining != value else { return } + status.draining = value + dashPayUnlockStatus[walletId] = status + } + // MARK: - Xpub rendering /// Render a bincode-encoded per-account `ExtendedPubKey` (as @@ -824,10 +1105,30 @@ public class PlatformWalletManager: ObservableObject { isSyncing != self.shieldedSyncIsSyncing { self.shieldedSyncIsSyncing = isSyncing } + if let isSyncing = try? self.isDashPaySyncing(), + isSyncing != self.dashPaySyncIsSyncing { + self.dashPaySyncIsSyncing = isSyncing + } let tip = (try? self.currentSpvTipBlockTime()) ?? nil if tip != self.spvTipBlockTime { self.spvTipBlockTime = tip } + // Refresh the per-wallet needs-unlock count (account-build ops). + // Per-wallet, so O(wallets)/tick; gated on change per key. + for walletId in self.wallets.keys { + if let n = try? self.pendingAccountBuildCount(for: walletId), + n != self.dashPayUnlockStatus[walletId]?.pendingAccountBuilds { + var status = self.dashPayUnlockStatus[walletId] ?? .init() + status.pendingAccountBuilds = n + self.dashPayUnlockStatus[walletId] = status + } + } + // Prune status for wallets no longer loaded (e.g. removed by a + // wipe) so a re-created wallet with the same id starts clean. + let stale = self.dashPayUnlockStatus.keys.filter { self.wallets[$0] == nil } + for walletId in stale { + self.dashPayUnlockStatus.removeValue(forKey: walletId) + } try? await Task.sleep(for: .seconds(1)) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDashPaySync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDashPaySync.swift new file mode 100644 index 00000000000..7731050b185 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDashPaySync.swift @@ -0,0 +1,181 @@ +import Foundation +import DashSDKFFI + +/// Per-pass summary returned by +/// `PlatformWalletManager.dashPaySyncNow()`. Mirrors the FFI +/// out-params of `platform_wallet_manager_dashpay_sync_sync_now`. +/// +/// `success == 0 && errors == 0 && syncUnixSeconds == 0` is the +/// "no pass ran" sentinel — a pass was already in flight (e.g. fired +/// by the background loop) and the manager skipped. Check +/// `isDashPaySyncing()` to distinguish "skipped" from "swept zero +/// wallets". +public struct DashPaySyncSummary: Sendable, Equatable { + /// Wallets whose `dashpay_sync()` succeeded in this pass. + public let success: Int + /// Wallets whose `dashpay_sync()` failed (logged Rust-side, + /// non-fatal to the rest of the pass). + public let errors: Int + /// Unix seconds the pass completed, or 0 if no pass ran. + public let syncUnixSeconds: UInt64 + + public init(success: Int, errors: Int, syncUnixSeconds: UInt64) { + self.success = success + self.errors = errors + self.syncUnixSeconds = syncUnixSeconds + } +} + +extension PlatformWalletManager { + // MARK: - DashPay sync lifecycle + // + // Mirrors the identity-token / shielded coordinators: NOT + // auto-started. The host lifecycle calls `startDashPaySync()` + // once the wallets are registered and the SDK is connected + // (same place it starts the address / shielded loops); the + // on-demand `dashPaySyncNow()` entry point backs pull-to-refresh. + // Unlike the identity-token coordinator the DashPay sweep is + // wallet-driven — every registered wallet is swept on every pass, + // so there is no per-identity registry surface here. + + /// Start the recurring DashPay (contact-request + profile) sync + /// background loop. Idempotent — calling while already running is + /// a no-op. + public func startDashPaySync(intervalSeconds: UInt64? = nil) throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + if let intervalSeconds { + try setDashPaySyncInterval(seconds: intervalSeconds) + } + try platform_wallet_manager_dashpay_sync_start(handle).check() + } + + /// Stop the recurring DashPay sync loop if it is running. + /// Cancel-only: a pass already in flight keeps running to + /// completion. + public func stopDashPaySync() throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + try platform_wallet_manager_dashpay_sync_stop(handle).check() + } + + /// Whether the DashPay sync background loop is running. + public func isDashPaySyncRunning() throws -> Bool { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + var running = false + try platform_wallet_manager_dashpay_sync_is_running(handle, &running).check() + return running + } + + /// Whether a DashPay sync pass is currently in flight. + /// + /// Prefer observing the published mirror + /// [`PlatformWalletManager.dashPaySyncIsSyncing`] from SwiftUI; + /// this is the direct FFI read backing it. + public func isDashPaySyncing() throws -> Bool { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + var syncing = false + try platform_wallet_manager_dashpay_sync_is_syncing(handle, &syncing).check() + return syncing + } + + /// Unix seconds of the last completed DashPay sync pass, or 0 if + /// no pass has ever completed. The watermark is global (one + /// last-sync per manager, not per-identity) — the sweep is + /// wallet-driven. + public func dashPayLastSyncUnixSeconds() throws -> UInt64 { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + var lastSync: UInt64 = 0 + try platform_wallet_manager_dashpay_sync_last_sync_unix_seconds(handle, &lastSync).check() + return lastSync + } + + /// Set the background DashPay sync interval (clamped to >= 1 + /// second on the Rust side). The running loop picks the new + /// interval up on its next sleep. + public func setDashPaySyncInterval(seconds: UInt64) throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + try platform_wallet_manager_dashpay_sync_set_interval(handle, seconds).check() + } + + /// Run one DashPay sync pass across every registered wallet. + /// Synchronous from the FFI side — runs on a detached worker + /// `Task`. If a pass is already in flight, the Rust manager skips + /// and the returned summary is the all-zero "no pass ran" + /// sentinel (see [`DashPaySyncSummary`]). + /// + /// This is the pull-to-refresh entry point: a refresh during + /// an in-flight sync attaches to it (skip + sentinel) instead of + /// double-firing. + @discardableResult + public func dashPaySyncNow() async throws -> DashPaySyncSummary { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> DashPaySyncSummary in + var successCount: UInt = 0 + var errorCount: UInt = 0 + var syncUnixSeconds: UInt64 = 0 + try platform_wallet_manager_dashpay_sync_sync_now( + handle, + &successCount, + &errorCount, + &syncUnixSeconds + ).check() + return DashPaySyncSummary( + success: Int(successCount), + errors: Int(errorCount), + syncUnixSeconds: syncUnixSeconds + ) + }.value + } + + // MARK: - DashPay payment history refresh + + /// Refresh the persisted DashPay payment history for one + /// identity: one FFI read + /// (`managed_identity_get_dashpay_payments`) + one persistence + /// pass upserting `PersistentDashpayPayment` rows, so the UI can + /// `@Query` them. + /// + /// Requires the manager to have been configured with a + /// `ModelContainer` (no-persistence mode has nowhere to land the + /// rows). Throws `.identityNotFound` when no loaded wallet knows + /// this identity. + @discardableResult + public func refreshDashPayPayments( + walletId: Data, + identityId: Identifier + ) throws -> [DashPayPayment] { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + guard let wallet = wallets[walletId] else { + throw PlatformWalletError.invalidParameter( + "no loaded wallet with id \(walletId.toHexString())" + ) + } + guard let persistenceHandler = persistence else { + throw PlatformWalletError.invalidHandle( + "refreshDashPayPayments requires a persistence handler — configure the manager with a ModelContainer" + ) + } + let payments = try wallet.getDashPayPayments(identityId: identityId) + persistenceHandler.persistDashpayPayments( + ownerIdentityId: identityId, + payments: payments + ) + return payments + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 8464500da53..e13af05e590 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1208,6 +1208,22 @@ public class PlatformWalletPersistenceHandler { upsertDashpayProfile(identityRow: row, profile: profile) } + // Upsert the cached contact-profile rows for this identity. + // + // One row per contact (keyed by `(owner, contact)`), distinct + // from the own-profile upsert above. Rust projects only + // present profiles (the negative cache is skipped), so a + // contact missing from this flush does NOT mean its profile + // was removed — we mirror the own-profile + DPNS policy: + // insert/refresh, never cascade-prune. An empty array leaves + // any existing rows intact. + if !entry.contactProfiles.isEmpty { + upsertDashpayContactProfiles( + identityRow: row, + profiles: entry.contactProfiles + ) + } + // Attach the identity to its owning `PersistentWallet` // via the relationship. This is the sole wallet-side // association on the row — there is no denormalized @@ -1383,6 +1399,69 @@ public class PlatformWalletPersistenceHandler { } } + /// Upsert one `PersistentDashpayContactProfile` row per cached + /// **contact** profile snapshot — keyed by `(networkRaw, + /// ownerIdentityId, contactIdentityId)`. Idempotent on repeated + /// flushes: an existing row is refreshed in place so SwiftUI views + /// observing it via `@Query` see field-level updates rather than + /// row-replacement churn. + /// + /// Full-REPLACE per contact, mirroring the Rust cache-write + /// semantics (§4.7): each fetched profile is the authoritative + /// *complete* state for that contact, so every column is overwritten + /// — a contact who *removes* their `avatarUrl` must not keep showing + /// a stale avatar. This is the same field-level overwrite the + /// own-profile `upsertDashpayProfile` does, just per contact. + /// + /// Append/refresh only: contacts absent from this flush keep their + /// existing rows (Rust projects only present profiles, so absence is + /// "no update", not "delete"). The cache cannot grow duplicate rows + /// for the same contact because of the `#Unique` compound key. + /// + /// Runs on `serialQueue` — only called from inside + /// `persistIdentities`'s `onQueue` body. + private func upsertDashpayContactProfiles( + identityRow: PersistentIdentity, + profiles: [ContactProfileSnapshot] + ) { + let ownerIdentityId = identityRow.identityId + for profile in profiles { + let contactIdentityId = profile.contactIdentityId + let descriptor = FetchDescriptor( + predicate: PersistentDashpayContactProfile.predicate( + ownerIdentityId: ownerIdentityId, + contactIdentityId: contactIdentityId + ) + ) + if let existing = try? backgroundContext.fetch(descriptor).first { + existing.displayName = profile.displayName + existing.bio = profile.bio + existing.publicMessage = profile.publicMessage + existing.avatarUrl = profile.avatarUrl + existing.avatarHash = profile.avatarHash + existing.avatarFingerprint = profile.avatarFingerprint + existing.checkedAtMs = profile.checkedAtMs + existing.lastUpdated = Date() + } else { + let row = PersistentDashpayContactProfile( + owner: identityRow, + contactIdentityId: contactIdentityId, + checkedAtMs: profile.checkedAtMs, + displayName: profile.displayName, + publicMessage: profile.publicMessage, + bio: profile.bio, + avatarUrl: profile.avatarUrl, + avatarHash: profile.avatarHash, + avatarFingerprint: profile.avatarFingerprint + ) + backgroundContext.insert(row) + // SwiftData populates the inverse `owner.contactProfiles` + // collection from the `inverse:` declaration on + // `PersistentIdentity.contactProfiles`. + } + } + } + // MARK: - Identity keys persistence /// Upsert / remove rows from `PersistentPublicKey` in response to @@ -1393,21 +1472,25 @@ public class PlatformWalletPersistenceHandler { /// same composite the Rust side uses for `BTreeMap` uniqueness. /// - Each `removed` pair deletes the matching row. /// - /// `PrivateKeyKindFFI` encoding: - /// - `None` (0): clear any stored `privateKeyKeychainIdentifier`. - /// - `Clear` (1): store raw 32-byte key material to the Keychain - /// via `KeychainManager`, record the resulting identifier. - /// - `AtWalletDerivationPath` (2): no Keychain write — the seed - /// is stored at wallet level, and `derivationPath` tells the - /// signing path to re-derive. Stored as the identifier so - /// `hasPrivateKey` still reflects presence, but with a - /// `derived:` prefix so consumers can distinguish stored-bytes - /// vs. derived-on-demand. + /// Private-key handling: + /// - When the entry carries a verified scalar (`privateKey != nil`), + /// it is stored directly to the Keychain via `storeCarriedIdentityKey` + /// and the resulting account string is recorded on + /// `privateKeyKeychainIdentifier`. + /// - When the entry carries no scalar, the existing + /// `privateKeyKeychainIdentifier` is PRESERVED — a materialized key + /// stays materialized, and a genuinely watch-only key never had one. func persistIdentityKeys( walletId: Data, upserts: [IdentityKeyEntrySnapshot], removed: [(identityId: Data, keyId: UInt32)] ) { + // Read-only over `upserts`: this function never mutates the array, so + // the caller (`persistIdentityKeysCallback`) stays the sole owner of + // each snapshot's `privateKey` buffer and can scrub it in place once + // this returns. Scrubbing here instead would hit a copy-on-write fork + // (the caller's array still references the originals) and leave the + // carried secret intact in the buffer handed to the Keychain. onQueue { for entry in upserts { // PersistentPublicKey is keyed on (identity, keyId) via @@ -1490,31 +1573,54 @@ public class PlatformWalletPersistenceHandler { // Private-key handling. // - // No bytes cross the FFI — when the entry carries - // derivation indices, Swift re-derives the 32-byte - // ECDSA scalar from the owning wallet's mnemonic and - // stores it in the keychain under the serialized - // derivation path. Wallet id resolves the same way as - // for the identity row itself: prefer per-entry - // `entry.walletId` (lets Rust route a key to a - // foreign wallet in some future cross-wallet-scan - // flow), fall back to the scope `walletId` that - // parameterised this callback. Keys without - // derivation indices are watch-only and clear any - // prior stored identifier. - if let indices = entry.derivationIndices { + // Rust discovery already derived and verified the 32-byte ECDSA + // scalar and carries it here in `entry.privateKey` — Swift + // stores those bytes directly, no mnemonic read and no + // re-derivation (the old re-derive pipeline depended on a + // readable keychain mnemonic that is absent during import). Wallet + // id resolves the same way as for the identity row itself: prefer + // per-entry `entry.walletId` (lets Rust route a key to a foreign + // wallet in some future cross-wallet-scan flow), fall back to the + // scope `walletId` that parameterised this callback. + // + // No-secret entries (watch-only keys, and the watch-only snapshot + // a flow emits for an already-materialized key) PRESERVE any + // existing `privateKeyKeychainIdentifier`: a materialized key + // stays materialized (its keychain item is durable), and a + // genuinely watch-only key never had an id to preserve. This + // makes materialization independent of the order Rust emits the + // watch-only snapshot vs. the secret-bearing upsert. + if let scalar = entry.privateKey, let indices = entry.derivationIndices { let resolvedWalletId = entry.walletId ?? walletId - let keychainId = deriveAndStoreIdentityKey( + let keychainId = storeCarriedIdentityKey( entry: entry, + scalar: scalar, walletId: resolvedWalletId, indices: indices, - publicKeyHex: entry.publicKeyData.toHexString(), - publicKeyHashHex: entry.publicKeyHash.toHexString(), identityIdBase58: identityHex ) - row.privateKeyKeychainIdentifier = keychainId - } else { - row.privateKeyKeychainIdentifier = nil + // Only overwrite the column when the store succeeded. A + // failed store (verify mismatch / keychain write failure) + // leaves the key watch-only without clobbering a previously + // materialized id; the next persister upsert retries. + if let keychainId = keychainId { + row.privateKeyKeychainIdentifier = keychainId + } + } else if entry.derivationIndices != nil, + row.privateKeyKeychainIdentifier == nil + { + // Breadcrumb present but no carried scalar: the key is + // wallet-derivable yet its private bytes were materialized by + // another path (e.g. identity registration writes its keychain + // items directly). Adopt the existing keychain account by a + // public-key-hex lookup — no derivation, no secret loaded — so + // the fast-path signer lookup and the `hasPrivateKey` marker + // work. A genuinely watch-only key finds nothing and stays so. + if let account = KeychainManager.shared.identityPrivateKeyAccount( + publicKeyHex: entry.publicKeyData.toHexString() + ) { + row.privateKeyKeychainIdentifier = account + } } row.lastAccessed = Date() @@ -1533,10 +1639,9 @@ public class PlatformWalletPersistenceHandler { } } - // `walletId` is now consumed as the scope fallback in the - // derivation branch above, so it's no longer a dead - // parameter. No save() — bracketed by - // changesetBegin/End. + // `walletId` is consumed as the scope fallback when resolving the + // owning wallet for a carried key, so it's not a dead parameter. + // No save() — bracketed by changesetBegin/End. } // onQueue } @@ -1681,6 +1786,15 @@ public class PlatformWalletPersistenceHandler { /// stamped per row), so the upsert path is direction-agnostic. /// - Each `removedSent` row drops the matching outgoing row. /// - Each `removedIncoming` row drops the matching incoming row. + /// - Each `ignored` entry (`isIgnored == true`) drops **every** + /// incoming row from that sender — ignore is per-sender, so a + /// rotated (bumped-`accountReference`) request is suppressed too + /// (unlike the old per-`accountReference` reject) — and upserts + /// the `PersistentDashpayIgnoredSender` row. An `unignored` entry + /// (`isIgnored == false`) deletes that ignored-sender row. The + /// Rust side owns ignore suppression across re-syncs (an ignored + /// sender never re-enters `upserts`); SwiftData only stops showing + /// them and persists the ignored set for the Ignored screen. /// /// The owner identity is required to exist in SwiftData before /// the row is inserted — the relationship is non-optional and @@ -1698,7 +1812,8 @@ public class PlatformWalletPersistenceHandler { walletId: Data, upserts: [ContactRequestSnapshot], removedSent: [ContactRequestRemovalSnapshot], - removedIncoming: [ContactRequestRemovalSnapshot] + removedIncoming: [ContactRequestRemovalSnapshot], + ignored: [ContactIgnoredSenderSnapshot] ) { onQueue { for entry in upserts { @@ -1714,8 +1829,13 @@ public class PlatformWalletPersistenceHandler { // managed by any wallet locally — there's no // identity row to hang it off, and the contract's // `ownerId` invariant means the row would be - // orphaned anyway. Skip silently; the next sync - // round will replay it once the owner row exists. + // orphaned anyway. The recurring sweep replays it + // once the owner row exists; log so a contact that + // is somehow dropped permanently (e.g. an + // out-of-wallet owner with no PersistentIdentity) + // is at least observable rather than vanishing + // silently. + print("⚠️ persistContacts: skipped contact upsert — no PersistentIdentity for owner \(entry.ownerIdentityId.prefix(8).toHexString())…; will retry next sync round") continue } @@ -1748,6 +1868,10 @@ public class PlatformWalletPersistenceHandler { existing.autoAcceptProof = entry.autoAcceptProof existing.coreHeightCreatedAt = entry.coreHeightCreatedAt existing.createdAtMillis = entry.createdAtMillis + existing.paymentChannelBroken = entry.paymentChannelBroken + existing.contactAlias = entry.contactAlias + existing.contactNote = entry.contactNote + existing.contactHidden = entry.contactHidden if existing.owner !== owner { existing.owner = owner } @@ -1764,8 +1888,12 @@ public class PlatformWalletPersistenceHandler { encryptedAccountLabel: entry.encryptedAccountLabel, autoAcceptProof: entry.autoAcceptProof, coreHeightCreatedAt: entry.coreHeightCreatedAt, - createdAtMillis: entry.createdAtMillis + createdAtMillis: entry.createdAtMillis, + paymentChannelBroken: entry.paymentChannelBroken ) + row.contactAlias = entry.contactAlias + row.contactNote = entry.contactNote + row.contactHidden = entry.contactHidden backgroundContext.insert(row) } } @@ -1784,6 +1912,30 @@ public class PlatformWalletPersistenceHandler { isOutgoing: false ) } + for row in ignored { + if row.isIgnored { + // Ignore: (1) drop the sender's incoming row so the + // request stops showing in the pending UI, and (2) + // persist a durable ignored-sender row so the Rust + // `ignored_senders` set can be restored at load — + // without (2) the ignored sender resurfaces on the + // next post-relaunch sweep. Per-sender (no + // accountReference): ALL the sender's incoming rows go. + deleteIgnoredSenderIncomingRows( + ownerId: row.ownerIdentityId, + senderId: row.senderIdentityId + ) + upsertIgnoredSender(row) + } else { + // Un-ignore: delete the ignored-sender row so the + // sender's requests resurface on the next sweep (the + // Rust side rewinds the cursor to re-fetch them). + deleteIgnoredSender( + ownerId: row.ownerIdentityId, + senderId: row.senderIdentityId + ) + } + } // No save() — bracketed by changesetBegin/End from the // Rust store() round. _ = walletId // reserved for future wallet-scope batching @@ -1813,6 +1965,88 @@ public class PlatformWalletPersistenceHandler { } } + /// Drop every incoming-request row from an ignored sender so their + /// requests stop lingering in the UI store. Per-sender (no + /// `accountReference` gate): unlike the old reject, ignore suppresses + /// ALL of the sender's requests, including rotated ones. Silent on + /// miss: an already-removed row is the success state. + /// + /// Assumes it's already running on `serialQueue`. + private func deleteIgnoredSenderIncomingRows(ownerId: Data, senderId: Data) { + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.ownerIdentityId == ownerId + && $0.contactIdentityId == senderId + && $0.isOutgoing == false + } + ) + if let rows = try? backgroundContext.fetch(descriptor) { + for row in rows { + backgroundContext.delete(row) + } + } + } + + /// Persist one ignored sender as a durable + /// `PersistentDashpayIgnoredSender` row so the Rust `ignored_senders` + /// set can be rebuilt at load. Without this the in-memory set starts + /// empty after relaunch and the still-on-platform immutable + /// `contactRequest`s re-ingest on the next sweep, resurfacing the + /// ignored sender. + /// + /// Upsert keyed `(networkRaw, ownerIdentityId, ignoredSenderId)` — the + /// Rust per-sender suppression key. Idempotent: a replay of the same + /// ignore is a no-op. Requires the owner `PersistentIdentity` to exist + /// (the row hangs off it); skipped + logged if it hasn't landed yet — + /// the next sync round replays it. + /// + /// Assumes it's already running on `serialQueue`. + private func upsertIgnoredSender(_ row: ContactIgnoredSenderSnapshot) { + let ownerId = row.ownerIdentityId + let ownerDescriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == ownerId } + ) + guard let owner = try? backgroundContext.fetch(ownerDescriptor).first else { + print("⚠️ persistContacts: skipped ignored-sender — no PersistentIdentity for owner \(row.ownerIdentityId.prefix(8).toHexString())…; will retry next sync round") + return + } + + let networkRaw = owner.networkRaw + let senderId = row.senderIdentityId + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw + && $0.ownerIdentityId == ownerId + && $0.ignoredSenderId == senderId + } + ) + if (try? backgroundContext.fetch(descriptor).first) == nil { + backgroundContext.insert( + PersistentDashpayIgnoredSender( + owner: owner, + ignoredSenderId: row.senderIdentityId + ) + ) + } + } + + /// Delete the ignored-sender row matching `(ownerId, senderId)` — the + /// un-ignore path. Silent on miss: an already-removed row is the + /// success state. + /// + /// Assumes it's already running on `serialQueue`. + private func deleteIgnoredSender(ownerId: Data, senderId: Data) { + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.ownerIdentityId == ownerId + && $0.ignoredSenderId == senderId + } + ) + if let existing = try? backgroundContext.fetch(descriptor).first { + backgroundContext.delete(existing) + } + } + /// Owned snapshot of a `ContactRequestFFI` row. Decouples the /// lifetime of the encrypted-key buffers from the Rust-side /// allocation: the callback copies them into Swift `Data` before @@ -1829,6 +2063,14 @@ public class PlatformWalletPersistenceHandler { let autoAcceptProof: Data? let coreHeightCreatedAt: UInt32 let createdAtMillis: UInt64 + let paymentChannelBroken: Bool + /// Owner-private alias (contactInfo-backed, M3). Established + /// rows only — nil for pending rows. + let contactAlias: String? + /// Owner-private note — same conventions as `contactAlias`. + let contactNote: String? + /// `contactInfo.displayHidden`. + let contactHidden: Bool } /// Owned snapshot of a `ContactRequestRemovalFFI` row. Carries @@ -1840,74 +2082,173 @@ public class PlatformWalletPersistenceHandler { let contactIdentityId: Data } - // MARK: - Identity private-key derivation + /// Owned snapshot of a `ContactIgnoredSenderFFI` row. The per-sender + /// suppression key is `(owner, sender)` — no `accountReference`, so an + /// ignored sender's requests are ALL suppressed (rotations included). + /// `isIgnored` is the insert/remove bit: `true` ⇒ persist the + /// ignored-sender row (an ignore); `false` ⇒ delete it (an un-ignore). + struct ContactIgnoredSenderSnapshot { + let ownerIdentityId: Data + let senderIdentityId: Data + let isIgnored: Bool + } - /// Derive the 32-byte ECDSA scalar for an identity key from the - /// owning wallet's mnemonic and stash it in the keychain at the - /// serialized DIP-9 derivation path. Returns the keychain - /// account string on success (which `PersistentPublicKey.priv- - /// ateKeyKeychainIdentifier` stores) or `nil` if anything in the - /// pipeline fails — mnemonic missing, network unresolved, path - /// build error, FFI derivation error, or keychain write failure. + // MARK: - DashPay payment-history persistence + + /// Upsert DashPay payment-history rows for one owner identity. + /// + /// NOT a persister-callback path — the Rust persister doesn't + /// project payment history. Called by + /// `PlatformWalletManager.refreshDashPayPayments` after reading + /// the `managed_identity_get_dashpay_payments` getter, so the UI + /// can `@Query` `PersistentDashpayPayment` rows reactively. + /// + /// Upsert-only: the Rust `dashpay_payments` map is append-only + /// history (keyed by txid), so a refresh never has to delete + /// rows; cascade from the owner identity handles wallet wipes. + /// Rows are keyed `(networkRaw, ownerIdentityId, txid)`. Skips + /// silently when the owner identity row doesn't exist yet — + /// the next refresh after the identity flush replays it. + /// + /// Saves immediately when no changeset round is open — same + /// convention as the other app-facing writers (`setWalletName`): + /// mid-round calls leave the commit/rollback to `endChangeset`. + public func persistDashpayPayments( + ownerIdentityId: Data, + payments: [DashPayPayment] + ) { + onQueue { + let ownerId = ownerIdentityId + let ownerDescriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == ownerId } + ) + guard let owner = try? backgroundContext.fetch(ownerDescriptor).first else { + return + } + let networkRaw = owner.networkRaw + + for payment in payments { + guard !payment.txid.isEmpty else { continue } + let txid = payment.txid + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw + && $0.ownerIdentityId == ownerId + && $0.txid == txid + } + ) + if let existing = try? backgroundContext.fetch(descriptor).first { + // Refresh in place only when a field actually changed. + // The FFI snapshot is authoritative, and `status` is the + // field that moves (Pending → Confirmed / Failed). A + // no-op rewrite would still dirty the row and re-fire + // every `@Query` observer on each refresh pass — and the + // recurring DashPay-sync falling edge calls this even on + // a quiescent channel, so skipping unchanged rows keeps + // an open payment list from re-rendering every sync. + let changed = existing.counterpartyIdentityId != payment.counterpartyId + || existing.amountDuffs != payment.amountDuffs + || existing.directionRaw != payment.direction.rawValue + || existing.statusRaw != payment.status.rawValue + || existing.memo != payment.memo + || existing.owner !== owner + if changed { + existing.counterpartyIdentityId = payment.counterpartyId + existing.amountDuffs = payment.amountDuffs + existing.directionRaw = payment.direction.rawValue + existing.statusRaw = payment.status.rawValue + existing.memo = payment.memo + if existing.owner !== owner { + existing.owner = owner + } + existing.lastUpdated = Date() + } + } else { + let row = PersistentDashpayPayment( + owner: owner, + counterpartyIdentityId: payment.counterpartyId, + amountDuffs: payment.amountDuffs, + direction: payment.direction, + status: payment.status, + txid: payment.txid, + memo: payment.memo + ) + backgroundContext.insert(row) + } + } + // Same guard as the other app-facing writers + // (`setWalletName`, …): a refresh landing while a Rust + // persister round is open must ride that round's + // endChangeset commit/rollback instead of flushing the + // half-applied round early. + // + // Surface (don't swallow) a save failure: a dropped payment + // upsert silently loses Sent history + memos, the exact H1 + // symptom this path exists to prevent, so a failure must at + // least be observable rather than vanishing behind `try?`. + if !self.inChangeset { + do { + try backgroundContext.save() + } catch { + print("⚠️ persistDashpayPayments: SwiftData save failed — payment history may be incomplete: \(error)") + } + } + } + } + + // MARK: - Identity private-key materialization + + /// Store the already-verified 32-byte ECDSA scalar carried from Rust + /// discovery into the keychain at the serialized DIP-9 derivation path. + /// Returns the keychain account string on success (which + /// `PersistentPublicKey.privateKeyKeychainIdentifier` stores) or `nil` + /// if anything fails — network unresolved, path build error, verify + /// mismatch, or keychain write failure. + /// + /// No mnemonic is read and no key is re-derived: the scalar is the one + /// discovery already validated against the on-chain key. Swift only + /// formats the keychain account label (a pure string, not key + /// derivation) and persists the bytes — the sanctioned Keychain + /// exception in `swift-sdk/CLAUDE.md`. /// /// Idempotent per `(wallet, identity_index, key_index)` triple: - /// repeated persister callbacks for the same key overwrite - /// cleanly via `storeIdentityPrivateKey`'s delete-then-add. + /// repeated persister callbacks for the same key overwrite cleanly via + /// `storeIdentityPrivateKey`'s delete-then-add. /// - /// Runs off the main actor (this whole handler fires from the - /// Rust persister thread); every touched API is either - /// `nonisolated` or backed by thread-safe primitives. - private func deriveAndStoreIdentityKey( + /// Runs off the main actor (this whole handler fires from the Rust + /// persister thread); every touched API is either `nonisolated` or + /// backed by thread-safe primitives. + private func storeCarriedIdentityKey( entry: IdentityKeyEntrySnapshot, + scalar: Data, walletId: Data, indices: (identityIndex: UInt32, keyIndex: UInt32), - publicKeyHex: String, - publicKeyHashHex: String, identityIdBase58: String ) -> String? { - // 1. Resolve the wallet's network from SwiftData. We need it - // to feed `KeyDerivation.getIdentityAuthenticationPath` - // so the path chooses the right `coin_type` (mainnet vs + let publicKeyHashHex = entry.publicKeyHash.toHexString() + + // 1. Resolve the wallet's network from SwiftData. Needed to feed + // `KeyDerivation.getIdentityAuthenticationPath` so the keychain + // account label carries the right `coin_type` (mainnet vs // testnet). Scope to THIS handler's network via - // `walletRecordPredicate` — the same `walletId` can now have - // a row per network, and a bare walletId-only fetch could - // resolve to a sibling network's row and derive the key on - // the wrong chain (unusable on-chain). + // `walletRecordPredicate` — the same `walletId` can have a row + // per network, and a bare walletId-only fetch could resolve to a + // sibling network's row. let walletDescriptor = FetchDescriptor( predicate: walletRecordPredicate(walletId: walletId) ) guard let persistentWallet = try? backgroundContext.fetch(walletDescriptor).first else { - print("⚠️ deriveAndStoreIdentityKey: wallet row not found for \(walletId.prefix(4).toHexString())…") + print("⚠️ storeCarriedIdentityKey: wallet row not found for \(walletId.prefix(4).toHexString())…") return nil } let network: Network = persistentWallet.network ?? .testnet - // 2. Fetch the mnemonic UTF-8 bytes for this wallet from the - // keychain. Keep the call site off Swift `String` so the - // plaintext phrase does not live in higher-level heap - // objects longer than necessary. - let mnemonicUTF8Bytes: Data - do { - mnemonicUTF8Bytes = try WalletStorage().retrieveMnemonicUTF8Bytes(for: walletId) - } catch { - print("⚠️ deriveAndStoreIdentityKey: mnemonic missing for wallet \(walletId.prefix(4).toHexString())…: \(error.localizedDescription)") - return nil - } - - // 3. Mnemonic UTF-8 bytes → 64-byte BIP39 seed. - let seed: Data - do { - seed = try Mnemonic.toSeed(mnemonicUTF8Bytes: mnemonicUTF8Bytes) - } catch { - print("⚠️ deriveAndStoreIdentityKey: mnemonic-to-seed failed: \(error.localizedDescription)") - return nil - } - - // 4. Build the DIP-9 authentication path. The string form - // doubles as the keychain account suffix so the explorer - // can render it. + // 2. Build the DIP-9 authentication path string. This is a pure + // string format for the keychain account label (the FFI + // path-formatter takes only the network + indices, no mnemonic); + // it is not key derivation. let derivationPath: String do { derivationPath = try KeyDerivation.getIdentityAuthenticationPath( @@ -1916,36 +2257,41 @@ public class PlatformWalletPersistenceHandler { keyIndex: indices.keyIndex ) } catch { - print("⚠️ deriveAndStoreIdentityKey: path build failed: \(error.localizedDescription)") + print("⚠️ storeCarriedIdentityKey: path build failed: \(error.localizedDescription)") return nil } - // 5. Derive the 32-byte scalar via the FFI bridge. The - // bridge writes into a caller-provided buffer; we zero - // the scratch `Data` on the way out for hygiene (the - // keychain item is the real home for the bytes). - var privateKey = Data(count: 32) - let rc: Int32 = privateKey.withUnsafeMutableBytes { pkBytes -> Int32 in - guard let pkPtr = pkBytes.bindMemory(to: UInt8.self).baseAddress else { return -1 } - return seed.withUnsafeBytes { seedBytes -> Int32 in - guard let seedPtr = seedBytes.bindMemory(to: UInt8.self).baseAddress else { - return -1 - } - return derivationPath.withCString { pathCStr in - key_wallet_derive_private_key_from_seed(seedPtr, pathCStr, pkPtr) + // 3. Verify-before-store mirror-check (ECDSA_SECP256K1 only). + // For an ECDSA_SECP256K1 key the stored `publicKeyHash` is + // exactly hash160(compressed pubkey), so a single hash160 of the + // carried scalar's pubkey must reproduce it. A corrupted or + // mismatched transfer drops to watch-only rather than storing a + // wrong key. Other key types are NOT re-verified here: an + // ECDSA_HASH160 key's `publicKeyHash` is a double hash + // (hash160 of the already-hashed `data()`), so this single-hash + // check would never match — they rely on the type-correct Rust + // `validate_private_key_bytes` gate that already ran at discovery. + // A future non-ECDSA carry must grow a per-type branch here. + if entry.keyType == KeyType.ecdsaSecp256k1.rawValue { + var derivedHash = Data(count: 20) + let hashRc: Int32 = derivedHash.withUnsafeMutableBytes { outBytes -> Int32 in + guard let outPtr = outBytes.bindMemory(to: UInt8.self).baseAddress else { return -1 } + return scalar.withUnsafeBytes { pkBytes -> Int32 in + guard let pkPtr = pkBytes.bindMemory(to: UInt8.self).baseAddress else { + return -1 + } + return platform_wallet_pubkey_hash_from_private_key(pkPtr, outPtr) } } - } - guard rc == 0 else { - print("⚠️ deriveAndStoreIdentityKey: FFI derive failed (rc=\(rc))") - // Zero out any partial write before returning. - privateKey.resetBytes(in: 0..