diff --git a/.claude/skills/simulator-control/SKILL.md b/.claude/skills/simulator-control/SKILL.md index d29f8a48d4b..20b6f34e032 100644 --- a/.claude/skills/simulator-control/SKILL.md +++ b/.claude/skills/simulator-control/SKILL.md @@ -256,6 +256,60 @@ SELECT ZIDENTITYINDEXRAW AS slot, ZSTATUSRAW AS status Within-store contrast eliminates a class of "did I really install the new build?" doubts — if the histogram changed for the row you just created but not for the 20 pre-existing ones, the new code is provably running. +### K — Auto-fund a testnet wallet from the in-app faucet (no manual faucet visit) + +For e2e flows that need Core funds on **testnet** (identity registration, DashPay +payments), the Receive screen has a built-in faucet button — tap it instead of +visiting a web faucet by hand. It sends ~1 tDASH to the wallet's current Core +receive address via `faucet.thepasta.org` (`ReceiveAddressView.requestFromTestnetFaucet` +→ `TestnetFaucet().requestCoreDash`). + +Preconditions (the button is conditionally rendered): +- The app must be on the **Core tab** and the SDK on the **testnet** network + (`selectedTab == .core && currentNetwork == .testnet`). It's hidden on + mainnet / devnet / regtest. +- A valid receive address must be shown (a wallet exists + synced enough to derive one). + +Drive it by its **stable accessibility id** `receive.testnetFaucetButton` (don't +rely on the visible label — it doubles as a status toast: "Get 1 tDASH — Testnet +Faucet" → "Solving captcha…" → "Sent N tDASH! tx: …", or a web-fallback toast on +rate-limit/failure): + +```bash +UDID=$(xcrun simctl list devices booted | awk -F'[()]' '/Booted/ {print $2}') +idb ui describe-all --udid "$UDID" > /tmp/tree.json +# tap the element whose AXUniqueId == "receive.testnetFaucetButton" +LABEL_ID="receive.testnetFaucetButton" UDID="$UDID" python3 << 'PY' +import json, os, subprocess +items = json.load(open('/tmp/tree.json')) +m = next((it for it in items if it.get('AXUniqueId') == os.environ['LABEL_ID'] and it.get('enabled')), None) +if not m: raise SystemExit('faucet button not found — on Core tab + testnet? wallet synced?') +fr = m['frame']; x, y = int(fr['x']+fr['width']/2), int(fr['y']+fr['height']/2) +subprocess.run([os.path.expanduser('~/.local/bin/idb'),'ui','tap','--udid',os.environ['UDID'],str(x),str(y)], check=True) +print(f'tapped faucet at ({x},{y})') +PY +``` + +Then poll for arrival (the faucet tx must confirm/IS-lock before SPV credits it): +```bash +DATA=$(xcrun simctl get_app_container booted org.dashfoundation.DashDeveloperPro data) +STORE="$DATA/Library/Application Support/default.store" +for i in {1..40}; do + bal=$(sqlite3 "$STORE" "SELECT COALESCE(SUM(ZAMOUNT),0) FROM ZPERSISTENTTXO WHERE ZISSPENT=0;") + echo "[$i] unspent duffs=$bal"; [ "${bal:-0}" -gt 0 ] && break; sleep 6 +done +``` + +Notes: +- **Rate-limited / captcha failure** falls back to opening the *web* faucet in + Safari and copying the address to the clipboard — if the toast says "opened web + faucet", the in-app send did NOT happen; either wait out the rate limit and + re-tap, or fund the copied address manually. +- ~1 tDASH per call; for a two-party DashPay test (fund both wallets) tap it once + per wallet from each wallet's Receive screen. +- This is a Core (L1) funding tool. Platform credits (identity top-up) still come + from an asset-lock of these Core funds — fund first, then register/top-up. + ## Setup checklist Run before any session that needs UI control: @@ -273,20 +327,31 @@ If `idb connect` hangs, clear stale companion processes: `pkill -f idb_companion If `idb connect` succeeds but `idb ui describe-all` returns a single root element with empty bounds (`{{0, 0}, {0, 0}}`) — companion is connected but desynced from the simulator UI tree. Same fix as the hang case: `pkill -f idb_companion && idb connect $UDID`. A successful re-connection shows the real app frame (e.g. `{{0, 0}, {402, 874}}` for iPhone 17 Pro) as the root element. +### Build for the simulator (canonical command) + +**To build the app for the simulator, run `bash packages/swift-sdk/build_ios.sh --target sim`** (from the repo root) — or `bash build_ios.sh --target sim` from `packages/swift-sdk/`. This is THE sim build: it compiles the Rust → iOS-sim `DashSDKFFI.xcframework`, the `SwiftDashSDK` package, and the `SwiftExampleApp` app (warnings-as-errors), ending in `** BUILD SUCCEEDED **`. Don't hand-run `xcodebuild` / `swift build` for a sim build — `build_ios.sh --target sim` wires the xcframework + flags correctly. (`--target all` adds device + macOS slices; `--target mac` is macOS-only and leaves the xcframework WITHOUT a sim slice — never use it for sim work.) + +**Never run two builds against the same worktree's DerivedData at once** (e.g. a background build + the user's own `build_ios.sh`) — concurrent `xcodebuild` corrupts the build DB (`error: unable to attach DB` → `** BUILD FAILED **`). If the user may be building, build in an isolated `git worktree` (separate DerivedData + target dir). See [[feedback-parallel-agents-need-worktrees]]. + ### Install the latest build before driving the UI -The skill assumes the binary on the simulator is current. It's not, if you've built but forgotten to install. After every `./build_ios.sh --target sim` (or any code change), push the fresh artifact: +The skill assumes the binary on the simulator is current. It's not, if you've built but forgotten to install. After every `build_ios.sh --target sim` (or any code change), push the fresh artifact. With MULTIPLE simulators booted, install to each by **UDID** (`booted` is ambiguous with >1 sim): ```bash BUNDLE=org.dashfoundation.DashDeveloperPro # The .app on disk is named after PRODUCT_NAME (still "SwiftExampleApp"), which # differs from the bundle id — find by the product name, launch by the bundle id. APP=$(find ~/Library/Developer/Xcode/DerivedData -name "SwiftExampleApp.app" -path "*Debug-iphonesimulator*" -not -path "*Index.noindex*" 2>/dev/null | head -1) -xcrun simctl install booted "$APP" -xcrun simctl launch booted "$BUNDLE" # or terminate-then-launch to force a fresh process +for UDID in $(xcrun simctl list devices booted | grep -oiE '[0-9A-F-]{36}'); do + xcrun simctl install "$UDID" "$APP" + xcrun simctl terminate "$UDID" "$BUNDLE" 2>/dev/null + xcrun simctl launch "$UDID" "$BUNDLE" +done ``` -Without this step, idb taps still hit the OLD binary's UI and your verification is meaningless. Pair with a clean `git status` + `git log -1` check before running any post-fix manual test pass. +For a **two-party DashPay test**, boot two sims (`xcrun simctl boot `), install the same build on both, and use a distinct identity/wallet per sim. Drive each by passing `--udid ` to every `idb` command. + +Without this step, idb taps still hit the OLD binary's UI and your verification is meaningless. Pair with a clean `git status` + `git log -1` check before any post-fix manual test pass. ## Pitfalls diff --git a/Cargo.lock b/Cargo.lock index 6e400baaa77..6da1a432977 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5064,8 +5064,9 @@ version = "4.0.0" dependencies = [ "aes", "cbc", - "dashcore", - "hex", + "hmac", + "secp256k1", + "sha2", "thiserror 1.0.69", ] @@ -5175,6 +5176,7 @@ name = "platform-wallet-ffi" version = "4.0.0" 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/docs/dashpay/BLOCK_SPEC.md b/docs/dashpay/BLOCK_SPEC.md new file mode 100644 index 00000000000..9770ea01b13 --- /dev/null +++ b/docs/dashpay/BLOCK_SPEC.md @@ -0,0 +1,107 @@ +# DashPay ignore — cross-device design + DoS / social-graph-leak analysis + +Status: the single-device **"Block sender"** design this file originally carried was +**superseded** by the shipped local-only **Ignore** (per-sender, reversible; +`ignored_senders`, `ignore_sender`/`unignore_sender`, applied in `changeset/apply.rs`). +That feature is documented in `SPEC.md` (G5) and `SYNC_CORRECTNESS_SPEC.md`; the +single-device Block design — its state/persistence/UI/test plan and the review +resolutions specific to it — is no longer reproduced here. + +This file is retained **only** for the two forward-looking pieces Ignore does not yet +cover: + +- **(a) Cross-device ignore** — how to make ignore sync across a user's devices via a + single owner-scoped, self-encrypted blocklist, and the privacy reason it is **not** + carried via `contactInfo` (§1). +- **(b) DoS / social-graph-leak analysis** — the fetch-cost / flood analysis and the + countable-index social-graph leak that any query-level DoS filter must avoid (§2). + +Owner: platform-wallet / swift-sdk. +Relates to: `SPEC.md` (G5, the shipped Ignore), `SYNC_CORRECTNESS_SPEC.md`, +`CONTACTINFO_FORMAT_SPEC.md`. + +--- + +## 1. Cross-device ignore — a self-encrypted blocklist, NOT `contactInfo` + +Ignore is per-device local state today; you re-ignore a sender on each device. Making +it sync is a **future** item on the contract / governance track — not built. + +**Why not `contactInfo`.** The tempting reuse — carry an ignore flag in the +`contactInfo.privateData` blob we already sync — **breaks the DIP-15 ≥2-contacts +unlinkability gate** and is rejected. An ignore targets a *non-established* sender, so +carrying it would mean creating a `contactInfo` **about a non-contact**. That document's +public existence + `$createdAt` correlates with the inbound `contactRequest` (via the +public `userIdCreatedAt` index) to re-identify *who* you ignored: `encToUserId` is +encrypted so *who* is hidden, but the doc's *existence/count* is not. The +"`displayHidden` is precedent" argument is a false equivalence — `displayHidden` rides a +document that exists anyway (an established contact), whereas an ignore-of-a-non-contact +*creates* the leaking document. It is also mechanically blocked today: +`set_contact_info_with_external_signer` → `set_contact_metadata` hard-requires an +established contact, and the apply side drops non-established `contactInfo`. + +**The design if it ships.** A **single owner-scoped, self-encrypted blocklist +document** — one document the owner encrypts to themselves (same key family as +`contactInfo`'s `privateData`, but a single owner-private list, not per-contact and not +gated by the 2-contact rule). Every device reads and applies it, so ignore (and +optionally decline) apply everywhere. Costs: each edit is a document write (credits); +it reveals only *that* a blocklist exists plus an edit count — **not** one document per +ignored victim. Its update timing should be conflated with normal profile edits so it +does not leak the per-sender existence/count. This is a contract change on the later +governance track, and the metadata-leak analysis above must be settled before building +it. + +## 2. DoS / spam, and the social-graph leak a countable index would create + +### 2.1 Fetch model, and why an ignore can't cut fetch cost + +The received-request query is keyed by recipient: + +``` +where toUserId == me, order_by $createdAt, limit: 100 +``` + +An ignore is a **local read-filter applied after fetch**: the index has no +`sender NOT IN (…)` axis and Sybil senders are unpredictable, so an ignore cannot avoid +the fetch + GroveDB proof-verify cost of an incoming request — it only hides it once +fetched. + +**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. The +only built-in deterrent is **economic**: each `contactRequest` costs the sender +platform credits. Spam isn't free, but it isn't prevented. A naive `limit: 100, +start: None` re-fetch also lets a flood of ≥100 junk requests **bury** legitimate ones +past the first page. + +**Mitigation — incremental fetch (high-water).** Track the newest `$createdAt` seen per +identity and query `WHERE toUserId == me AND $createdAt > high_water`, paginating +forward: each request is fetched exactly once, pagination can't bury legit requests past +100, and ignore/decline become one-time-on-first-sight. This 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. The existing `userIdCreatedAt` index `[toUserId, $createdAt]` already serves this +range-after-equality query, so incremental fetch needs **no** contract change. *(This +high-water incremental fetch has since shipped — see `SYNC_CORRECTNESS_SPEC.md` and the +DIP-15 §8.8/§8.12 row in `DIP_CONFORMANCE_GAPS.md`.)* + +### 2.2 The trap: a countable `[toUserId, $ownerId]` index leaks the inbound social graph + +A natural-looking next step is a **countable** index on the recipient→sender axis so the +wallet can answer "how many pending requests do I have" / "is one sender flooding me" +from a count proof **without fetching documents**: + +``` +byRecipientSender = [{ toUserId: asc }, { $ownerId: asc }] // countable — DO NOT ship as drafted +``` + +(The `$ownerId` of a `contactRequest` *is* the sender.) This is a **social-graph leak** +and must not ship in that form. Platform count / group-by proofs are **public, not +recipient-private**, and return cleartext `{sender_id → count}`. A countable +`[toUserId, $ownerId]` therefore lets *anyone* scrape "who contacted recipient R, with +counts" in O(log n) — the inbound social graph, in the clear. + +**Resolution:** drop the per-sender `GROUP BY $ownerId` axis. At most keep an aggregate +`COUNT(*) WHERE toUserId == me` (a single number, for a pending-request badge), which +reveals only a total and not per-sender edges. Any real query-level DoS filter that +excludes ignored/rejected senders *before* fetching is a contract change (DIP / +maintainer coordination), and this graph-exposure analysis must be carried into that DIP. diff --git a/docs/dashpay/CONTACTINFO_FORMAT_SPEC.md b/docs/dashpay/CONTACTINFO_FORMAT_SPEC.md new file mode 100644 index 00000000000..0ea08e454cc --- /dev/null +++ b/docs/dashpay/CONTACTINFO_FORMAT_SPEC.md @@ -0,0 +1,306 @@ +# 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 the R1 item in the backlog, dashpay/platform#4020.) +Owner: platform-wallet / platform-encryption +Relates to: Spec 2 (Ignore, adds `relationshipState`), `BLOCK_SPEC.md`, +`CONTACTINFO_FORMAT_SPEC.md` Appendix A. + +## 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* + `CONTACTINFO_FORMAT_SPEC.md` Appendix A). 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.) + +--- + + + +## Appendix A — 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 the spec above. + +`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/docs/dashpay/DASHPAY_STATE_ENCAPSULATION_SPEC.md b/docs/dashpay/DASHPAY_STATE_ENCAPSULATION_SPEC.md new file mode 100644 index 00000000000..072b95d1024 --- /dev/null +++ b/docs/dashpay/DASHPAY_STATE_ENCAPSULATION_SPEC.md @@ -0,0 +1,463 @@ +# DashPay State Encapsulation — extract `ManagedIdentity`'s DashPay fields into `DashPayState` + +Status: draft, rev 2 (review must-fixes folded) +Scope: `packages/rs-platform-wallet` (+ mechanical re-paths in `rs-platform-wallet-ffi`; +test-helper construction in `rs-platform-wallet-storage`). No on-disk format change, no FFI +ABI change, no Swift change. Follow-up to PR #3841 — lands as its own PR after #3841 merges. + +Origin: review question on PR #3841 — "Having dashpay stuff right in identity wallet and +identity manager and managed identity is mixing too much different stuff… shall we have a +separate dashpaywallet and somehow encapsulate dashpay stuff from common identity things?" + +> **Review outcome (rev 2).** Four independent reviewers (feasibility, scope, adversarial +> failure-modes, Rust/domain-fit) audited rev 1 against the code. Scope verdict: +> right-sized; two-tier design and two-commit staging earn their keep. The load-bearing +> corrections folded here: **(MF-1)** rev 1 undercounted the FFI cold-load restore path — +> it raw-writes **six** DashPay fields including all three relationship maps +> (`ffi/persistence.rs:4064/4067/4070`), so the relationship-map `apply_*` methods must be +> `pub`, not `pub(crate)`, and the boundary claim is recalibrated from "sealed" to +> "raw writes impossible; invariant-bypassing writes are named `apply_*` and auditable". +> **(MF-2)** a `pub dashpay` field is bypassable by whole-value replacement +> (`managed.dashpay = Default::default()` / `mem::take` compiles anywhere and silently wipes +> the high-water cursors) — the field itself is now private with a `dashpay()` borrow +> getter and per-field Tier B `_mut` accessors, and **no** whole-struct `dashpay_mut()`. +> **(MF-3)** rev 1 answered only one of the three sites the origin comment names; §0 now +> maps the design to all three honestly, and an optional facade-level `DashPayView` +> (zero-cost borrowing namespace — materially different from the twice-reverted owned +> facade) is added as decision point Q1. Plus: getters live on `DashPayState` itself; +> `dashpay_` field-name stutter dropped; `pub(super)` replaces the long scoped-visibility +> path; the in-crate test-fixture raw writes are inventoried and budgeted (§5); D4's method +> doc keeps the caller-side half of the cursor contract explicit. + +--- + +## 0. What the origin comment names, and what this spec covers + +The PR comment names three sites. Honest mapping: + +1. **`ManagedIdentity` (state)** — the worst offender and **this spec's target**: 12 of 19 + fields are DashPay social state, all `pub`, invariants enforceable only by convention. +2. **`IdentityWallet` (network facade)** — the *largest* mixing by volume (~10.2k lines of + DashPay ops vs ~6.6k identity-core across the `network/` impl files), but already + file-split by concern with documented layering (`network/mod.rs`). Two owned-facade + splits were tried and deliberately reverted on this branch (§4.1). What this spec offers + there is the optional zero-cost `DashPayView` namespace (§3 D8, decision Q1) — call-site + visibility without a second handle. +3. **`IdentityManager` / manager layer** — already clean: the manager holds buckets + a + location index with no DashPay logic; DashPay sync orchestration is already its own + coordinator (`manager/dashpay_sync.rs`). No change proposed. + +## 1. Problem + +`ManagedIdentity` (`src/wallet/identity/state/managed_identity/mod.rs`) mixes two concerns +in one flat, fully-`pub` struct: + +- **Identity-core**: `identity`, `identity_index`, `wallet_id`, `status`, `dpns_names`, + `contested_dpns_names`, two sync block-times. +- **DashPay social state — 12 fields**: `established_contacts`, `sent_contact_requests`, + `incoming_contact_requests`, `ignored_senders`, `auto_accept_verify_failed`, + `dashpay_rescan_triggered`, `dashpay_profile`, `dashpay_payments`, `contact_profiles`, + `high_water_received_ms`, `high_water_sent_ms`, `pending_contact_crypto`. + +Three concrete costs today (all verified against the code): + +1. **No boundary.** Nothing in the type answers "what is DashPay vs identity-core"; the + distinction lives in field-comment prose. Any future extraction (or reasoning about one) + starts from zero. +2. **Invariants are bypassable — and one already lives outside the state layer.** All fields + are `pub`, so the auto-establish invariant (reciprocal request ⇒ established contact), the + `AUTO_ACCEPT_VERIFY_FAILED_CAP` eviction, and the ignore-emits-both changeset rule are + upheld only by the convention of calling the right method. Worse, **high-water cursor + monotonicity is enforced nowhere in the state layer**: the compare-and-advance rule + (`advance_if_unchanged`, `network/contact_requests.rs:822`) is a free function in network + code writing the fields raw (`:1363`, `:1370`). A miss reintroduces the lost-unignore bug + that function's doc-comment describes. +3. **The FFI crate reads 9 public fields directly** inside handle closures + (`ffi/src/dashpay_profile.rs:150` et al.) and **raw-writes six fields on the cold-load + restore path**: `ignored_senders` (`ffi/persistence.rs:3758`), `dashpay_payments` + (`:3806`), `contact_profiles` (`:3900`), and — via `apply_contact_rows` + (`:3961-4077`, production load path) — all three relationship maps (`:4064/:4067/:4070`). + The compiler offers no help distinguishing "legit restore write" from "invariant bypass". + +## 2. Current architecture (facts the design relies on) + +From a three-agent inventory of the state layer, all access sites, and the persistence +coupling, plus four review passes re-verifying the cites: + +- **The core↔DashPay coupling is narrow.** No method in + `state/managed_identity/{contacts,contact_requests,identity_ops,sync}.rs` mutates both an + identity-core field and a DashPay field in the same call. Coupling is exactly: (a) + `snapshot_changeset()` → `IdentityEntry::from_managed` (`changeset/changeset.rs:332-349`) + reads 4 DashPay scalar fields on every core-field persist; (b) the two constructors + initialize both groups; (c) `disable_keys` reads `wallet_id`/`identity_index` alongside a + snapshot. DashPay mutators read only the immutable `self.id()`. +- **Mutation methods are already the norm.** Network code calls state-mutation methods 53×; + direct production field writes outside the owner module: ~11 in `network/` (high-water ×2, + `dashpay_rescan_triggered` ×1, `established_contacts.get_mut` ×3, + `set_dashpay_profile`/`mark_auto_accept_verify_failed` pass-throughs, `contact_profiles` + ×1), 12 in `state/manager/apply.rs` (changeset replay), 9 in `wallet/apply.rs` (changeset + replay), and **9 in the FFI crate** (6 restore-path raw writes + 3 via methods). Test + fixtures add raw writes in `wallet/apply.rs:562-567/:1348-1351`, `network/` test modules, + `ffi/tests/test_data/mod.rs:283-286/:304`, and `contact_workflow_tests.rs:289` + (`established_contacts.get_mut`). +- **Persistence never serializes `ManagedIdentity` itself** — it derives `Debug, Clone` only. + Three persistence tiers cover the 12 fields: + - `IdentityEntry` scalar snapshot (serde-derived flat struct, `changeset.rs:270-323`): + `dashpay_profile` (merge: LWW), `dashpay_payments` (extend, LWW per txid), + `contact_profiles` (extend, LWW per contact), `ignored_senders` (union). + - `ContactChangeSet` (`changeset.rs:634-663`): the three relationship maps + the + `ignored`/`unignored` tombstone pair. + - `PlatformWalletChangeSet.pending_contact_crypto_{added,cleared}` (`changeset.rs:1189/1193`) + for the deferred-crypto queue. + - **In-memory only, never persisted**: `dashpay_rescan_triggered`, + `auto_accept_verify_failed`, `high_water_received_ms`, `high_water_sent_ms`. +- **The FFI ABI is keyed off the flat entry types** (`IdentityEntryFFI::from_entry`, + `ContactRequestFFI::from_*` — `#[repr(C)]` with pinned sizes), NOT off `ManagedIdentity`'s + layout. Regrouping `ManagedIdentity` cannot move a single FFI byte as long as the entry + types stay flat. +- **Two restore paths construct/populate `ManagedIdentity` outside its methods**: the + boot/load path (in-crate: pre-built identities arrive via `IdentityManagerStartState`, + consumed at `manager/load.rs:100` — no field-level construction; FFI loader: + `ManagedIdentity::new` + restore writes, `ffi/persistence.rs:3692-3700`) and the + changeset-replay path (`state/manager/apply.rs`, `wallet/apply.rs` contacts block, + `apply_established_contact`). +- **One external struct-literal construction** exists in `rs-platform-wallet-storage` + (`schema/identities.rs:206-237`) — test-gated (`#[cfg(any(test, feature="__test-helpers"))]`). + It defaults the three relationship maps (loaded separately from the contacts table) and + populates `ignored_senders` by wholesale clone. +- **The WalletPersister is a method parameter**, not a `ManagedIdentity` field. Non-persisting + mutators return a `ContactChangeSet` for the caller to store. A sub-struct inherits the same + two patterns unchanged. +- History: a separate DashPay surface was tried and deliberately reverted twice on this + branch — `914e244401` folded `wallet/dashpay/` under `identity/` (duplicate files, + bidirectional refs, "where does this live?"), `cdd0da880e` merged the `DashPayWallet` + facade into `IdentityWallet` (two FFI handles, two clones per op, straddling ops like + `accept_contact_request`). + +## 3. Design + +### D1 — `DashPayState` struct, privately owned by `ManagedIdentity` + +New file `state/managed_identity/dashpay.rs` (a child module of `managed_identity`, sibling +of the four impl files — verified module chain makes `pub(super)` fields visible to all of +them and to nothing outside `managed_identity/`): + +```rust +/// Per-identity DashPay social state: the DashPay-contract layer +/// (contacts, requests, profile, payments, deferred crypto) carried by +/// a `ManagedIdentity` on top of its identity-core fields. +#[derive(Debug, Clone, Default)] +pub struct DashPayState { + // -- Tier A: guarded (sibling-module fields, mutate via methods) -- + pub(super) established_contacts: BTreeMap, + pub(super) sent_contact_requests: BTreeMap, + pub(super) incoming_contact_requests: BTreeMap, + pub(super) ignored_senders: BTreeSet, + pub(super) auto_accept_verify_failed: BTreeSet<[u8; 32]>, + pub(super) high_water_received_ms: Option, + pub(super) high_water_sent_ms: Option, + + // -- Tier B: open (plain data / caches, no cross-field invariant) -- + pub profile: Option, + pub payments: BTreeMap, + pub contact_profiles: BTreeMap, + pub rescan_triggered: BTreeSet, + pub pending_contact_crypto: Vec, +} +``` + +On `ManagedIdentity`, the 12 flat fields are replaced by a **private** field +`dashpay: DashPayState` (private-to-`managed_identity`: visible in `mod.rs` and all child +impl files, invisible outside — this also closes the whole-value-replacement bypass, see +D3). The existing field doc-comments (several are load-bearing, e.g. the in-memory-only +rationale on `rescan_triggered` and `auto_accept_verify_failed`) move verbatim. The +`dashpay_` name prefix is dropped inside the struct (no stutter behind `dashpay()`). + +**Tier assignment rationale.** Tier A = every field with a cross-field or temporal invariant: +the three relationship maps (auto-establish; rotation-supersede; both-exist precheck), +`ignored_senders` (ignore must emit `removed_incoming` + `ignored` together), +`auto_accept_verify_failed` (CAP eviction — already method-only today), the two high-water +cursors (compare-and-advance; only writer besides the sweep is `unignore_sender`'s rewind). +Tier B = independent per-key caches where a raw insert cannot corrupt sibling state, and where +the replay/restore paths and profile/payment recorders already write directly today. +`pending_contact_crypto` stays Tier B: its dedup invariant lives in the free function +`upsert_pending_contact_crypto` shared with the changeset apply path, and its drain uses +owned snapshots — capturing that in methods is real scope with no bypass bug on record +(possible follow-up, out of scope here). + +### D2 — mutation methods stay on `ManagedIdentity`; signatures unchanged + +Every existing mutation/query method (`add_sent_contact_request`, +`add_incoming_contact_request`, `accept_incoming_request`, `ignore_sender` / +`unignore_sender`, `set_contact_metadata`, `apply_rotated_incoming_request`, +`mark_auto_accept_verify_failed`, `should_enqueue_auto_accept`, `set_dashpay_profile`, +`record_dashpay_payment`, …) keeps its receiver, name, signature, and persister-threading +pattern; bodies reach through `self.dashpay.*`. The 53 existing method call sites don't +change. This is deliberately NOT a `DashPayState`-methods design for mutations: they need +`self.id()` and snapshot access, and moving them would churn every call site for zero +invariant gain. + +### D3 — read access: one `dashpay()` borrow + getters on `DashPayState` + +`ManagedIdentity` gains exactly one read accessor: + +```rust +pub fn dashpay(&self) -> &DashPayState +``` + +Tier B fields are `pub`, so all reads flow `managed.dashpay().payments`, +`managed.dashpay().contact_profiles`, … Tier A fields get borrow getters **on +`DashPayState` itself** (next to the fields): `established_contacts()`, +`sent_contact_requests()`, `incoming_contact_requests()`, `ignored_senders()`, and by-value +`high_water_received_ms()` / `high_water_sent_ms()` (`Option` is `Copy`). +`auto_accept_verify_failed` gets NO getter — the two existing query methods on +`ManagedIdentity` (`is_auto_accept_verify_failed`, `should_enqueue_auto_accept`) cover every +reader. Existing query helpers (`is_sender_ignored`, `established_contact(&id)`, +`prior_sent_account_reference`, …) stay on `ManagedIdentity` unchanged. + +Read sites (~30 network, ~18 FFI, ~55 test assertions) re-path mechanically +(`managed.established_contacts` → `managed.dashpay().established_contacts()`). Verified: no +name collisions with existing `ManagedIdentity` methods; the same-named methods on +`IdentityWallet` are a different type. + +Tier B **writes** get per-field mut accessors on `ManagedIdentity`: `payments_mut()`, +`contact_profiles_mut()`, `rescan_triggered_mut()`, `pending_contact_crypto_mut()`, and +`set_profile_raw` is unnecessary (`set_dashpay_profile` already exists; the replay paths use +the mut accessors). There is deliberately **no whole-struct `dashpay_mut()`** and the +`dashpay` field is private: `managed.dashpay = DashPayState::default()`, `mem::take`, and +`mem::swap` — whole-value replacements that would silently wipe Tier A state including the +cursors — do not compile outside `managed_identity/`. + +`established_contact_mut(&id) -> Option<&mut EstablishedContact>` **stays, promoted to +`pub`** (documented escape hatch; `contact_workflow_tests.rs:289` — an external compilation +unit — needs it, as do three in-crate network sites that mutate contact sub-fields then +persist a hand-built `ContactChangeSet`). Sealing per-contact sub-field mutation is +follow-up scope; the boundary claim here is deliberately modest — see D5. + +### D4 — capture the high-water invariant (the one real behavior-adjacent move) + +`advance_if_unchanged` + `advance_high_water` (free fns, +`network/contact_requests.rs:814-832`) move onto `ManagedIdentity` as the ONLY write path +for the cursors: + +```rust +/// Compare-and-advance: advance the received-direction cursor to +/// `max_fetched` (never below its current value) ONLY if the cursor +/// still holds `snapshot` — the value read at sweep start. A mid-sweep +/// `unignore_sender` rewind (reset to None) must not be clobbered by a +/// stale sweep max, or the un-ignored sender stays invisible until a +/// cold restart. +/// +/// Caller contract (unchanged from the free fn): invoke only when the +/// paginate exhausted without error AND every ingest reached disk — +/// fetch/persist-success gating stays at the call site. +pub fn advance_high_water_received(&mut self, snapshot: Option, max_fetched: Option); +pub fn advance_high_water_sent(&mut self, snapshot: Option, max_fetched: Option); +``` + +The two raw network writes (`:1363`, `:1370`) become calls; `unignore_sender`'s rewind stays +internal to the state layer. The moved invariant is CAS + monotonicity **only**; the +fetch-succeeded/persist-succeeded gating remains caller-side convention, stated in the doc. +Semantics bit-identical: the snapshot stays a caller-supplied param, so the two-guard +interleaving with a concurrent un-ignore is unchanged. + +### D5 — replay/restore writes become named `apply_*` methods + +The replay and cold-load paths currently write Tier A fields raw. They get intent-named +methods that skip business invariants **by design** (establishment/ignore decisions were made +before persist; replay must reproduce state, not re-decide it). Visibility follows the +callers — the FFI crate restores relationship maps in production, so these are `pub`: + +- `pub fn apply_sent_contact_request(&mut self, ContactRequest)` / + `apply_incoming_contact_request` — for `wallet/apply.rs:197-222` and the FFI loader + (`ffi/persistence.rs:4067/:4070`) + FFI fixtures (`ffi/tests/test_data/mod.rs:304`). +- `apply_established_contact` — exists (`contact_requests.rs:621`), promoted + `pub(crate)` → `pub` (FFI loader `:4064`, fixtures `:283-286`). Parity note: its + remove-both-pending-sides is a provable no-op on the cold-load path — `apply_contact_rows`' + match arms emit exactly one of {established, sent, incoming} per contact into fresh maps. +- `pub fn apply_ignored_sender(&mut self, Identifier)` / `apply_unignored_sender` — for + `wallet/apply.rs:255/265`, the FFI restore write (`ffi/persistence.rs:3758`), and the + storage-crate test helper. **Implementer note:** `state/manager/apply.rs:71/:115/:143` and + the storage helper write the *whole set* (`.extend()` union / fresh-object assign) — loop + `apply_ignored_sender` per element. Equivalent because `:115/:143` sit on fresh-insert + branches where the set is constructor-empty (assign ≡ insert-loop) and `:71` is already a + union; pin this equivalence with a test. +- `pub(crate) fn apply_removed_sent(&mut self, &Identifier)` / `apply_removed_incoming` — + only `wallet/apply.rs:225/:230` removes. +- `state/manager/apply.rs`'s remaining writes touch Tier B only (`dashpay_profile` → + `profile`, `dashpay_payments` → `payments`, `contact_profiles`) — they use the Tier B mut + accessors, as do the FFI restore writes to `payments`/`contact_profiles`. + +Each `apply_*` doc-comment states why it bypasses the invariant and who may call it. + +**Boundary claim, calibrated** (this is what the refactor actually buys): raw *field* writes +to Tier A are compile errors outside `managed_identity/`; invariant-*bypassing* writes still +exist but are named `apply_*`, greppable, and auditable — the compiler cannot distinguish a +new illegitimate `apply_*` caller from a restore path. The capability-level seal is real for +exactly two things: the high-water cursors (D4 — the only public write path enforces CAS) +and whole-value replacement of the DashPay group (D3). Everything else is +naming-and-audit, which is the honest, proportionate win. + +### D6 — construction + +`DashPayState` derives `Default` (every field defaults empty/None — true today for both +constructors and the cold-load path; all 12 field types are `Default`-able). +`ManagedIdentity::new` / `new_out_of_wallet` set `dashpay: DashPayState::default()`. The FFI +loader keeps `ManagedIdentity::new` + `apply_*`/Tier-B-mut writes. The storage test helper +(`storage/schema/identities.rs:206-237`) switches its literal to +`dashpay: DashPayState::default()` semantics via constructor + an `apply_ignored_sender` +loop (its relationship maps are already defaulted there; contacts load separately). + +### D7 — persistence mapping updates (no wire change) + +`IdentityEntry::from_managed` (lives in `crate::changeset` — reads Tier A through the `pub` +getters, Tier B through `dashpay()`). `IdentityEntry`, `ContactChangeSet`, all merge +functions, the SQLite schema, and every `#[repr(C)]` FFI mirror are **untouched**. The +on-disk format and FFI ABI provably cannot change: nothing serializes `ManagedIdentity` +(derives `Debug, Clone` only), and no FFI struct or `const` size assert is edited. Only +observable representation delta: `Debug` output nests the group under `dashpay:` — no test +parses `Debug` output (verified). + +### D8 — OPTIONAL: facade-level `DashPayView` namespace (decision Q1) + +The origin comment's eye was on `IdentityWallet` — where DashPay ops outnumber identity-core +ops by lines ~10.2k to ~6.6k. The twice-reverted design was a second **owned** facade (two +handles through FFI, two clones per op). A **borrowing view** has none of those costs: + +```rust +pub struct DashPayView<'a, B: TransactionBroadcaster + ?Sized>(&'a IdentityWallet); + +impl IdentityWallet { + pub fn dashpay(&self) -> DashPayView<'_, B> { DashPayView(self) } +} +``` + +The DashPay op definitions (already file-split: `contact_requests.rs`, `contacts.rs`, +`contact_info.rs`, `payments.rs`, `profile.rs`) move their `impl IdentityWallet` blocks to +`impl DashPayView<'_, B>` — one route per op, no forwarding shims. Call sites become +`wallet.identity().dashpay().send_contact_request(…)`. FFI **function signatures are +unchanged** (they re-path internally); Swift is untouched. Cost: a mechanical re-path of the +FFI + internal DashPay call sites and the sync coordinator; zero new state, zero clones. +This is the piece that makes the DashPay/identity boundary visible at every call site — but +it is severable: commits 1–2 stand alone if this is declined. + +## 4. Alternatives rejected + +1. **Separate owned `DashPayWallet` facade (the PR comment's literal suggestion).** Tried and + reverted twice on this very branch (§2 history). The ops straddle the boundary + (`accept_contact_request` = identity signing + DashPay docs; payments = core broadcaster; + contact crypto = identity DIP-9/14 keys), so a second owned facade re-creates the same + handle with a different name, re-splits ops that straddle, and re-introduces FFI + handle-juggling. DashPay is a *layer on the identity aggregate*, not a sibling domain. + Rejected on evidence, not taste. (The borrowing view in D8 is the surviving kernel of + this idea — namespace without ownership.) +2. **Separate DashPay store keyed by identity id** (e.g. `IdentityManager.dashpay: + BTreeMap`). Splits one aggregate into two maps that must stay + key-synchronized through add/remove/apply/load; every combined read becomes a two-map + join; the changeset apply and both restore paths get a second lookup + orphan mode. All + cost, and the "is it one thing?" answer is unchanged — a DashPay state without its + identity is meaningless. +3. **Extension-trait split of `IdentityWallet`** (`DashPayOps` trait). Pure cosmetics: state + stays mixed, callers add trait imports, and the network layer is already file-split by + concern. The borrowing view (D8) achieves the namespacing without the trait ceremony. +4. **Full lockdown (all 12 fields private, no `_mut` escape hatch).** Forces dedicated + methods for per-contact sub-field mutation (3 network sites), the profile fetch-cache + writer, payments recorder internals, and the pending-crypto upsert/drain — roughly + doubles the new-method surface to protect fields with no cross-field invariants and no + observed bypass bugs. Poor cost/benefit now; Tier A→B promotion later is cheap. +5. **Stop after commit 1 (all-`pub` regroup, no encapsulation).** Fixes cost 1 of §1 + (boundary/naming) but leaves costs 2–3 untouched: the high-water cursors stay raw + network writes guarding a documented lost-unignore bug, and the FFI keeps 6 + indistinguishable raw restore writes. The ~14 new methods in commit 2 are earned by + exactly those two costs. +6. **Docs only (comment banner grouping the fields).** Zero enforcement; the next reviewer + asks the same question. + +## 5. Migration & staging + +Own PR, based on `v4.1-dev` **after #3841 merges** (this touches the same files as #3841's +tail; doing it inside would bloat an already-huge diff and re-trigger full re-review). + +Commits, each independently green: + +1. **Mechanical regroup.** Introduce `DashPayState` with ALL fields temporarily `pub` (and + the `dashpay` field `pub`); move the 12 fields (renaming the three `dashpay_`-prefixed + ones); re-path every access (`managed.X` → `managed.dashpay.X`). No visibility change, no + method change. Compile-error-driven; behavior-identical by construction. Also deletes + the orphaned 0-byte `state/managed_identity/tests.rs` left by `914e244401`. +2. **Encapsulate.** Apply tier visibilities + private `dashpay` field; add `dashpay()`, + Tier A getters, Tier B mut accessors, `advance_high_water_*`, the `apply_*` family; + convert the network + FFI + replay sites; move the two high-water free fns into the + state layer with their tests. **Test-fixture conversions budgeted here** (not + "mechanical re-paths"): `wallet/apply.rs:562-567/:1348-1351` (insert → `apply_*`), + `network/contact_requests.rs` fixture sites (~3411-3418 flag write → + `established_contact_mut`; ~3560-3564 `ignored_senders.clear()` — no direct equivalent, + becomes clone-keys + `apply_unignored_sender` loop), `network/payments.rs:1535/1610/2537` + + `network/contact_info.rs` helper (insert → `apply_*`), FFI fixtures + (`ffi/tests/test_data/mod.rs`), `contact_workflow_tests.rs:289` + (→ `established_contact_mut`). +3. **(Optional, decision Q1) `DashPayView` facade namespace** per D8. + +Rollback story: each commit reverts independently of the ones before it. + +## 6. Failure modes & risks + +- **Missed access site** → compile error (loud, the mechanism working as intended). Zero + runtime discovery. +- **High-water semantics drift** (the only logic that *moves*): mitigated by porting the + existing free-fn unit tests unchanged, plus new method-level tests written to pass against + the free fn's behavior BEFORE the move (kill-the-mutant check: `snapshot != current` must + leave the field untouched). +- **Replay-path behavior change**: `apply_*` methods must reproduce today's raw writes + exactly. Verified caller-side semantics that must NOT move into the methods: + `wallet/apply.rs` keys inserts off `entry.request.recipient_id`/`sender_id`, warns on + orphan inserts but is silent on orphan removes, orders inserts-before-removes and + unignore-after-ignore (un-ignore wins) — all stay in `wallet/apply.rs`. The + `ignored_senders` assign-vs-loop equivalence (D5) gets a pinning test. +- **Borrow-checker fallout**: adversarial pass verified all existing sites compile under the + new surface (the three `get_mut` sites touch only the contact + persister while the `&mut` + is live; loops over Tier A maps are read-only in-body; mutating sites collect inputs before + taking `&mut`). New sites holding a getter-returned borrow across a `&mut` call will fail + to compile — clone first in tests. +- **FFI restore path**: `apply_*` parity is exact (raw inserts have no side effects; + `apply_established_contact`'s extra removes are a no-op on fresh maps — D5). +- **Merge risk with in-flight DashPay work**: pure mechanics; land in a quiet window. + Orthogonal to the pending-contact-crypto follow-ups (that spec moved the field *onto* the + identity; this one only re-paths it). +- **Residual escape hatches — the honest list**: `established_contact_mut` (`pub`), Tier B + `pub` fields + mut accessors, and the **`pub apply_*` family itself** (any crate can call + `apply_ignored_sender` instead of `ignore_sender`, skipping the tombstone contract — same + exposure as today's `pub` fields, but now named and greppable). The boundary claim is + D5's calibrated version, not "all DashPay state is sealed". + +## 7. Test & verification plan + +- **Existing suites are the harness** (behavior-preserving refactor): full + `rs-platform-wallet` lib tests (the auto-establish, rotation, ignore/unignore, CAP-eviction, + persist-before-commit pins all keep passing untouched), `contact_workflow_tests`, + `rs-platform-wallet-ffi` lib + integration tests. +- **New unit tests** (written against the CURRENT free-fn behavior first, then the method): + `advance_high_water_{received,sent}` — advance, never-below, `None`-snapshot, + mid-sweep-rewind-preserved; `apply_ignored_sender` loop ≡ wholesale-assign parity; + `apply_sent/incoming_contact_request` parity with today's `wallet/apply.rs` raw inserts + (including the no-auto-establish property of the replay path). +- **Visibility is its own test — for what it actually seals**: after commit 2, a Tier A raw + *field* write or a whole-`dashpay` replacement outside `managed_identity/` is a compile + error. (`apply_*` misuse is not compiler-catchable — that's the calibrated D5 claim.) +- **Local CI mirror**: `cargo clippy --workspace --all-features` + `cargo fmt --check --all` + (targeted `-p` builds miss feature-gated callers). +- **iOS**: rebuild the xcframework + run the existing FFI persistence round-trip tests; no + Swift source change expected (assert: `git diff --stat` on `packages/swift-sdk` is empty). + +## 8. Open questions (decision points for the PR author) + +1. **Include commit 3 (`DashPayView` facade namespace, D8)?** Recommendation: yes — it is + the only part of this spec that changes what the origin comment's author *sees* at the + `IdentityWallet` layer, and it is zero-cost at runtime. But commits 1–2 deliver the + state-layer value standalone; declining Q1 drops D8 with no other edits. +2. Should `payments` be Tier A? `record_dashpay_payment` has rollback-on-persist semantics, + but the FFI restore + overlay paths write it raw; sealing it means two more `apply_*` + methods. Proposed: keep Tier B now. +3. DPNS fields (`dpns_names`, `contested_dpns_names`): once `DashPayState` lands, their + loose placement becomes the next obvious question. Position: deliberately-separate + follow-up using the identical pattern ("dashpay first, dpns next"), not scope here. diff --git a/docs/dashpay/DIP_CONFORMANCE_GAPS.md b/docs/dashpay/DIP_CONFORMANCE_GAPS.md new file mode 100644 index 00000000000..530c6bbea8c --- /dev/null +++ b/docs/dashpay/DIP_CONFORMANCE_GAPS.md @@ -0,0 +1,359 @@ +# DashPay — DIP-15 + DIP-16 conformance gaps (code-verified audit) + +> **Purpose.** A from-scratch re-audit of the DashPay implementation against the +> canonical [DIP-15](https://github.com/dashpay/dips/blob/master/dip-0015.md) +> (DashPay) and [DIP-16](https://github.com/dashpay/dips/blob/master/dip-0016.md) +> (Headers-First SPV synchronization — DIP-15 §12 is built on it), cross-checked +> against the **actual code** on `feat/dashpay-m1-sync-correctness` (not the +> self-reported status in `SPEC.md`/the backlog (now dashpay/platform#4020)). The goal was to catch anything the +> DIPs require that is **missing, stubbed, or only partially wired**, and to +> separate genuine gaps from deliberate divergences. +> +> **Date:** 2026-06-24. **Method:** six parallel code-reading passes (xpub/ECDH/ +> key-purpose; accountReference/multi-account/DoS/label; coreHeight/block-rescan/ +> sync-window; profile/contactInfo/DPNS; dash-spv rescan capability; full DIP-16 +> 9-step sync ordering), each citing `file:line`, plus direct verification of the +> contested findings. SPV evidence is from the pinned `dash-spv` rev `b4779fc` +> (`rust-dashcore`), which the platform-wallet drives via `spv/runtime.rs`. +> +> **Headline.** The DashPay (DIP-15) core flow **fully conforms** and is in places +> *ahead* of the reference clients. The SPV layer (DIP-16) implements the hard +> parts for real (headers-first + checkpoints + masternode-list/quorum +> verification + compact filters) but **deliberately diverges** from the DIP's +> literal phasing (event-driven parallel managers, BIP157 instead of BIP37 for the +> confirmed path, L2 decoupled from L1). Only **two** DIP-15 gaps are +> under-tracked (one a real incoming-payment-loss risk); the rest are correctly +> tracked as deferred (blocked on external resources) or are well-reasoned +> divergences. The §12.6 block-rescan gap (§1.1) turns out to need only a small +> wallet-side trigger — the rescan engine already exists in dash-spv. + +--- + +## 0. Conformance matrix (by DIP-15 section) + +| DIP-15 area | § | Verdict | Evidence | +|---|---|---|---| +| Encrypted xpub = 69-byte compact `fp(4)‖cc(32)‖pk(33)` → 96-byte ciphertext | 8.6 | ✅ **FULLY** | `rs-platform-encryption/src/compact_xpub.rs` (`COMPACT_XPUB_LEN=69`); send asm `network/contact_requests.rs:480-491`; SDK 96-byte assert `rs-sdk/.../contact_request.rs:311-316`; KAT `dip14.rs::compact_xpub_is_69_byte_dip15_plaintext_not_107_byte_encode` | +| ECDH `SHA256(((y&1)|2)‖x)` | 8.3 | ✅ **FULLY** | `rs-platform-encryption/src/ecdh.rs:16-26` + hand-recomputed KAT `:56-85` | +| `senderKeyIndex`/`recipientKeyIndex` purpose policy (liberal receive, ENCRYPTION send fallback, no permanent break on purpose mismatch) | 8.3 | ✅ **FULLY** | send sel `contact_requests.rs:846-871`; validator `crypto/validation.rs:141-251`; purpose-only ≠ broken `:90-92` + drain `:1743-1764` | +| Friendship path `m/9'/coin'/15'/0'/owner256/cp256/index`, DIP-14 256-bit non-hardened CKD | 8.9 | ✅ **FULLY** (account 0) | `crypto/dip14.rs`; byte-identical to dashj per `INTEROP_DESK_CHECK.md` | +| `profile` (displayName/publicMessage/avatarUrl/avatarHash/avatarFingerprint) | 9 | ✅ **FULLY** | `types/dashpay/profile.rs:85-123` — real SHA-256 hash **and** real 8-byte dHash; non-destructive update `network/profile.rs:319-378` | +| Batched profile fetch `$ownerId in [ids]` (counterparties of new requests) | 9.10 | ✅ **FULLY** | `network/profile.rs:738-812` (`In` + required `orderBy`) | +| `contactInfo` (ECB `encToUserId`, CBC `privateData`, `65536'/65537'`, ≥2-contacts gate, varint privateData) | 10 | ✅ **FULLY** | `crypto/contact_info.rs:45-48,232-283`; `rs-platform-encryption/src/contact_info.rs`; gate `network/contact_info.rs:548-556` | +| `accountReference` value + version-bump rotation on re-send | 7, 8.4 | ✅ **send** / ⚪ **receive ignores (by design)** | `account_reference.rs:41-51`; version bump `contact_requests.rs:514-547` | +| `$createdAt` incremental fetch with 10-min skew back-off | 8.8, 8.12 | ✅ **FULLY** | `SYNC_OVERLAP_MS=600_000` → `contact_requests.rs:770-776`; `StartAfter` paging `contact_request_queries.rs:54-108` | +| `$createdAtCoreBlockHeight` populated | 8.7 | ✅ **FULLY** | server-side `document_create_transition/v0/mod.rs:253-256`; client sends `None` `rs-sdk/.../contact_request.rs:478` | +| DPNS name↔identity resolve/search/cache | 11 | 🟡 **PARTIAL** | works (`network/dpns.rs:281-362`); QR-build doesn't fall back to on-chain name | +| **L1 block re-scan from `min(coreHeightCreatedAt)` on new contact** | **8.7, 12.6** | ❌ **MISSING** | never read to drive a rescan; SPV exposes no rescan entry point | +| `encryptedAccountLabel` (48–80B, padded, decrypted) | 8.5 | ✅ **FULLY** | send length-normalized in the crypto primitive (`account_label.rs`); receive decrypted + surfaced via `store_contact_account_label` (incoming-only) → `ContactDetailView` (SPEC.md Milestone 3) | +| `acceptedAccounts` + first-request bloom gating / flood mitigation | 8.4, 10.8 | ❌ **MISSING** | codec only; unpopulated + dropped on ingest | +| Multi-account contacts (`Account ≠ 0`) | 7.1, 8.9 | 🟡 **DEFERRED** | `account_index` hardcoded `0`; blocked on upstream | +| QR auto-accept (`autoAcceptProof`, `m/9'/5'/16'/expiry'`, BIP21/72 URI) | 8.13 | ✅ **FULLY** (iOS-first) | `crypto/auto_accept.rs`; see `QR_AUTO_ACCEPT_SPEC.md` | +| Invitations (asset-lock voucher + claim onboarding, DIP-13) | — | ❌ **NOT STARTED** | queued as "NEXT" in the backlog (dashpay/platform#4020) | + +--- + +## 1. Under-tracked gaps (the value of this audit) + +### 1.1 🔴 No L1 block re-scan from `coreHeightCreatedAt` on new contacts — DIP-15 §8.7 + §12.6 + +**Status: MISSING and not mentioned anywhere in the existing docs.** This is the +only finding with an incoming-**payment-loss** character. + +DIP-15 §8.7 / §12.6 require: when a wallet learns of a new contact request, it must +**resynchronize L1 blocks from the minimum `$coreHeightCreatedAt`** across the new +requests *after* inserting the new address spaces into its filters, so it doesn't +miss payments sent in the device-sync-speed-skew window (a payment that landed on a +DashPay address before that address was being watched). + +What the code actually does: +- `$createdAtCoreBlockHeight` **is** captured and persisted on every request + (`types/dashpay/contact_request.rs:38`), but is **never read** to drive a + re-request. No "minimum across new contacts" is computed anywhere. +- Both account-registration paths — `register_external_contact_account` + (`network/contacts.rs:389`) and `register_contact_account` (`:140`), called from + the G1b sweep at `network/contact_requests.rs:1630,1820` — watch **forward only** + and aren't even passed the height. +- A newly registered contact's addresses **do** enter the compact-filter match set + (`monitored_script_pubkeys` enumerates `all_accounts()`), but only from the + current scan pointer forward — nothing rewinds the pointer to backfill. + +Consequence: the `G1(b)` sync fix rebuilds the address *watch* on restore-from-seed, +but does **not** backfill *history*. An incoming DashPay payment that arrived before +the receiving account was (lazily) registered — restore-from-seed, second device, or +the offline-accept→pay window — can be silently missed until some unrelated full +rescan happens to cover it. + +**The fix is small — the rescan engine already exists.** dash-spv's `FiltersManager` +already performs a targeted backfill rescan whenever a wallet's `synced_height` drops +below the filter scan pointer: `tick` calls `wallets_behind(committed)`, takes the +min stale height, runs `reset_for_rescan()` + `start_download()`, and re-downloads +BIP157 filters from there, re-matches against the now-larger script set, and +re-requests the matching blocks (`dash-spv .../sync/filters/sync_manager.rs:213-236`, +`manager.rs:129-139`). So DIP-15 §12.6 is **a wiring task, not an SPV build**: +1. **platform-wallet (the actual gap):** when the G1b sweep registers a new DashPay + account, lower that wallet's `synced_height` to + `min($coreHeightCreatedAt over the just-built accounts) − 1`. The height is + already on the `ContactRequest`; the existing `FiltersManager` does the rest. +2. **one small upstream piece (`key-wallet-manager`):** `WalletInterface:: + update_wallet_synced_height` is **forward-only by contract** — "a value below the + current is silently ignored" (`wallet_interface.rs:127-129`). A backward rescan + needs a new guard-bypassing method (e.g. `reset_wallet_synced_height_to(id, h)`), + a small upstream change in the vein of rust-dashcore#813. (Optionally expose a + thin `DashSpvClient::rescan_wallet_from(id, h)` convenience wrapper; the + `SpvRuntime` would forward it.) + +Constraints to respect: the backfill floor is the checkpoint the headers were seeded +from (`manager.rs:192`), and the BIP157 filter-headers/filters for that range must be +re-downloadable from peers. Per DIP-15 §12.6, re-request slightly beyond the minimum +height and avoid re-requesting the final ~10 blocks near the tip. It is a genuine +correctness gap, but a contained one — see §6.4 for how it relates to the DIP-16 +filter layer. + +### 1.2 🟡 `encryptedAccountLabel` — the "DONE" padding fix is dead code + +**Status: PARTIAL, and it contradicts a backlog ("DONE + tests pin it") claim (now dashpay/platform#4020).** + +The backlog P1 item records label padding to ≥16 chars (commit `2419159bb3`) as done. In +reality: +- The padded helper `IdentityWallet::encrypt_account_label` + `pad_account_label` + (`network/account_labels.rs:19,49-64`) has **zero live callers** (verified by grep; + only its own unit tests reference it). +- The **live** path — FFI `platform_wallet_send_contact_request_with_signer` + (`rs-platform-wallet-ffi/src/dashpay.rs:236-269`) → `send_contact_request_with_external_signer` + (`network/contact_requests.rs:374`) → `sdk_writer` → rs-sdk — passes the host label + **raw**. The SDK encrypts it unpadded and hard-rejects `<48 || >80` bytes + (`rs-sdk/.../contact_request.rs:319-330`). A **1–15-character label therefore errors + the entire contact-request send** (16-byte plaintext block → 16 ciphertext + 16 IV = + 32 < 48). The FFI accepts a label, so this is reachable, not theoretical. +- The label is **never decrypted on receive**: the ingest path stores + `encrypted_account_label` as raw bytes (`contact_requests.rs:2515`) and nothing calls + `decrypt_account_label` (also dead code in `account_labels.rs:78-107`). The field is + effectively write-only. + +A later refactor (the seedless `ContactCryptoProvider`/`sdk_writer` seam) appears to +have orphaned the padded helper. + +**Resolution (2026-06-24) — send side ✅ fixed; receive surfacing 🟡 remaining.** +The DIP-15 length normalization now lives in the single primitive +`platform_encryption::{encrypt,decrypt}_account_label`: a short/empty label is +space-padded to clear the 48-byte floor **and** an over-long label is truncated (on a +char boundary) to stay under the 80-byte cap — so **no** host-supplied label can error +the broadcast anymore (the review caught that the floor fix alone left a symmetric +`>80` long-label failure). The dead `network/account_labels.rs` helper was deleted (it +duplicated the convention). Red→green test +`account_label_is_always_a_valid_48_to_80_byte_field` pins both bounds + multi-byte + +the exact-48 boundary. + +**Receive-side surfacing — RESOLVED (2026-06-24, 5-lens reviewed; folded into SPEC.md +Milestone 3).** The label is now decrypted in Rust at the two signer-bearing +register sites (drain `RegisterExternal` Ok-branch + `accept_register_external_validated`, +where the ECDH `shared` already lives) and stored on +`EstablishedContact.contact_account_label`. It is **direction-specific** — derived +strictly from the *incoming* request and projected onto the **incoming FFI row only** +(the outgoing row's label is one *we* sent and is never surfaced), so it does **not** +copy the symmetric `alias`/`payment_channel_broken` both-rows pattern. Decrypt +failures / non-printable garbage coerce to `None` (cosmetic — never breaks the +channel); rotation pre-clears the field so it never goes stale. Surfaced through +`ContactRequestFFI.contact_account_label` → `PersistentDashpayContactRequest +.contactAccountLabel` → a read-only "Their account" row in `ContactDetailView`. +Backfill of pre-feature contacts deferred (dev-only; DashPay unreleased). + +**On-device UAT (paloma, 2026-06-25) found a SECOND, decisive bug + fixed it.** +The receive-side surfacing above had nothing to decrypt because the **recurring +sweep's ingest parser `parse_contact_request_doc` silently dropped +`encryptedAccountLabel`** (it read `encryptedPublicKey` + `autoAcceptProof` but not +the label). The send always attached the label and the decrypt was always correct — +the label just never reached the recipient's stored request. (This audit's earlier +"ingest works" claim cited the *sent*-request parser at `:2515`, missing that the +*received* path uses `parse_contact_request_doc`.) **Fix:** the parser now reads +`encryptedAccountLabel`; the sender's local bookkeeping also stores it off the +broadcast doc; and `AddContactView` gained an optional "Account label" field so +labels can be sent in-app. Unit tests missed the bug (they built the incoming +request *with* the label, bypassing the parser) — now pinned by +`parse_contact_request_doc_carries_encrypted_account_label` (red→green). **Verified +full e2e on paloma:** send (48-byte label on-chain) → fresh sweep ingest +(`enc=48`) → accept decrypt (`contactAccountLabel="Bob savings acct"`, incoming row +only / outgoing null) → ContactDetail shows "Their account: Bob savings acct". + +--- + +## 2. Tracked-and-deferred gaps (acknowledged; blocked on external resources) + +These are real DIP-15 gaps, but the existing docs already record them with a correct +blocker — not oversights. + +| Gap | DIP-15 § | Blocker | Doc ref | +|---|---|---|---| +| **True multi-account (`Account ≠ 0`)** — `account_index` hardcoded `0` at the only send site (`contact_requests.rs:476`); friendship path structurally `…/15'/0'/…`. (Key *rotation* via version-bump **is** live.) | 7.1, 8.9 | upstream `rust-dashcore#813` (honor the `index` field) | backlog dashpay/platform#4020 P1/P2 | +| **`acceptedAccounts` + §10.8 flood mitigation** — varint codec carries the field, but publish hardcodes it empty (`network/contact_info.rs:499-506`) and `set_contact_metadata` (`managed_identity/contact_requests.rs:289-299`) **drops** it on ingest. No "first request → bloom filter, additional → require acceptance" gating. | 8.4, 10.8 | query-level DoS filter needs a registered contract change | backlog dashpay/platform#4020 Contract track | +| **Cross-device ignore sync** — ignore is local-only; a per-sender `contactInfo` leaks the ignored target (timing correlation, R1). | 10.7 | needs an encrypted field on the `profile` contract (governance) | backlog dashpay/platform#4020 Contract track | +| **DPNS-name on-chain fallback in QR auto-accept build** — `build_auto_accept_qr` (`rs-platform-wallet-ffi/src/dashpay.rs:801`) uses the locally-cached name; empty for imported/devnet identities. `resolve_name` exists but isn't called from the QR path. | 11 | none (small follow-up) | backlog dashpay/platform#4020 P3 | +| **DashPay Invitations** — asset-lock voucher + claim onboarding (DIP-13 sub-feature `3'`). | — | new feature (L1 funding + identity registration + deep-link) | backlog dashpay/platform#4020 "NEXT" | +| **Devnet/testnet e2e + full add→approve→pay XCUITest** | 11 | funded test harness | backlog dashpay/platform#4020, `SPEC.md` Part 7 | + +--- + +## 3. Deliberate divergences (correct decisions, not bugs) + +- **`accountReference` ASK28 byte order** uses the **iOS** convention + (`be(ASK[28..32])>>4`); iOS and Android genuinely disagree, and the field is a + sender-private one-time-pad the **recipient ignores** (`unmask_account_reference` is + only ever called by the sender's own re-send path), so there is no on-chain interop + break. Documented + KAT-pinned. +- **Reject → reversible local-only `ignore`** (per-sender mute), matching Android's + Accept/Ignore model. No on-chain artifact (R1 privacy). +- **Retained 78/107-byte xpub `decode()` fallback** in `network/contacts.rs:447-461` — + documented insurance for local-only legacy rows; never participates in on-wire + encoding (send only ever emits 69 bytes; the SDK rejects non-69 before encryption). + +### 3.1 Reference-client (dashj / kotlin-platform) source pointers + +For re-checking our behavior against the canonical Android stack — `dashpay/kotlin-platform` +(`org.dashj.platform.dashpay`, the live lib), `dashpay/dashj` (core crypto/keychains), +and `dashpay/dash-wallet` (the app: sync, UI, DAOs), all on `master`. (`android-dashpay` +is the **stale** predecessor, last push 2024-01 — do not diff against it.) The +reference-side anchors that pin each cross-client comparison: + +| Concern | Reference-client anchor | +|---|---| +| `accountReference` ASK28 byte order | `BlockchainIdentity.getAccountReference` = `wrapReversed(ASK).toBigInteger().toInt() ushr 4` (= `u32_le(ASK[0..4])>>4`; we use the iOS `be(ASK[28..32])>>4` — §3 above) | +| Friendship path (receive vs send account) | `FriendKeyChain.getContactPath` — `contact.getUserAccount()` (receive) / `getFriendAccountReference()` (send) | +| `contactRequest` pagination (drain past 100) | `Documents.getAll` loops `startAt = last.id` while `size >= 100`; `retrieveAll` ⇒ `limit(-1)` | +| High-water + 10-min skew overlap | `PlatformSyncService.kt:346-372`, `DashPayContactRequestDao.kt:50-54` (`MAX(timestamp)` per direction) | +| Batched contact-profile fetch | `updateContactProfiles` → `Profiles.getList` (chunks of 100, `whereIn $ownerId`) | +| Non-destructive profile update | `Profiles.replace` — read-modify-write (`profileData.putAll(currentProfile.toObject())`, then overlay) | +| `encryptedAccountLabel` padding | `padAccountLabel()` — pad to ≥16 chars with spaces, always emit | +| Recipient-key selection | kotlin = ENCRYPTION-first with AUTH/HIGH fallback | +| Sent-tx status (live, not stored) | derived from `TransactionConfidence` | +| tx→contact reverse (both directions) | `getFriendFromTransaction` scans sent + received pools | +| Account/keychain self-heal | `checkDatabaseIntegrity` | + +**Perceptual-hash caveat — do NOT write a cross-client exact-match test on +`avatarFingerprint`.** The dHash byte/bit layout coincidentally matches dashj, but the +pixel pipeline differs (greyscale **average vs luma-weighted**, resize filter, 9×9 vs +9×8), so fingerprints **will not be byte-identical cross-client**. That is inherent to +perceptual hashing — the fingerprint is used for Hamming distance, never equality — so a +cross-client exact-match assertion is wrong by construction. + +--- + +## 4. Correction to the existing docs + +- **`SPEC.md` G3 ("`accountReference` hardcoded to 0", deferred to M3) is STALE.** + Code verification shows the send path computes a **real** `accountReference` and + does **version-bump rotation** on re-send (`contact_requests.rs:514-551`, + `account_reference.rs:41-51`). Only the *account-number* multi-account case remains + at `0` (§2 above). The leftover comment `sdk_writer.rs:114` ("DashPay account + reference (currently 0)") is rotted and should be corrected. + +--- + +## 5. Where the implementation is *ahead* of the reference clients + +For calibration (don't "fix" these): +- A real `contactInfo` document type — `kotlin-platform`/`dashj` have **none**. +- A genuine 8-byte dHash `avatarFingerprint` (commonly stubbed/zeroed elsewhere). +- Hand-recomputed ECDH + 69-byte-xpub known-answer tests (not just doc-comment trust). +- Stricter sync re-entrancy/shutdown discipline and a more robust + `reconcile_incoming_payments` self-heal than dashj. + +--- + +## 6. DIP-16 (Headers-First SPV synchronization) conformance + +DIP-15 §12 requires sync to follow **DIP-16**. The SPV client lives in the +`dash-spv` crate (rev `b4779fc`), driven by `packages/rs-platform-wallet/src/spv/`. + +**Two architectural facts frame every verdict:** +1. dash-spv is **not** a literal 4-phase sequential state machine. It is an + **event-driven coordinator** that spawns 8 independent managers (block-headers, + filter-headers, filters, blocks, masternode, chainlock, instantsend, mempool), + each in its own tokio task, progressing reactively off a `SyncEvent` bus + (`dash-spv/src/sync/sync_coordinator.rs:33-62,197-250`). DIP-16's *phases* are + realized as concurrent managers, not ordered stages. +2. The **confirmed-tx receive path uses BIP157/158 compact filters** (pulled from + peers, matched locally against wallet scripts) — **not** BIP37 bloom. BIP37 + `filterload` exists *only* in the optional mempool (unconfirmed-tx) manager. + DIP-16 step 8 literally says "construct a bloom filter"; the implementation + substitutes BIP157 for the confirmed path — a deliberate, stronger-privacy + deviation. + +### 6.1 Conformance matrix (DIP-16 9-step + phasing + locator) + +| DIP-16 element | Verdict | Evidence (`dash-spv` unless noted) | +|---|---|---| +| Step 1 — chain height from **multiple** peers | ✅ IMPLEMENTED (uses `max`, not soft-consensus) | `network/pool.rs:105-151` | +| Step 2 — headers-first from checkpoints + chain/PoW validation | ✅ IMPLEMENTED | `chain/checkpoints.rs:158-725`; `sync/block_headers/pipeline.rs:56-113`; `validation/header.rs:17-46` | +| Step 3 — terminal masternode list + quorums | ✅ IMPLEMENTED | `sync/masternodes/sync_manager.rs:255`; `manager.rs:575` | +| Step 4 — intermediate MN lists to verify quorums | ✅ IMPLEMENTED | `sync/masternodes/sync_manager.rs:42-147,369` | +| Step 5 — verify quorums (real, not stubbed) | ✅ IMPLEMENTED | `sync/masternodes/manager.rs:487,577` | +| Step 6 — retrieve identities | 🟡 PARTIAL (independent, best-effort) | platform-wallet `manager/identity_sync.rs:76,397-437`; `wallet_lifecycle.rs:421` | +| Step 7 — retrieve platform data | ✅ IMPLEMENTED (independent timer) | platform-wallet `manager/dashpay_sync.rs:404-474` | +| **Steps 2–7 ordered in one phase** | ⚪ NOT MODELED (intentional) | `manager/mod.rs:103-195` — no cross-coordinator gating | +| Step 8 — compact-filter build (confirmed path) | ✅ IMPLEMENTED | `sync/filters/manager.rs:654,734,779` | +| Step 8 — **DashPay/contact addresses in filter** | ✅ IMPLEMENTED (once receival acct exists) | `key-wallet .../wallet_info_interface.rs:302-316`; reg `network/contacts.rs:223,233` | +| Step 8 — filter set on **all** peers | 🟡 PARTIAL (eventually-all, looped not atomic; BIP37 mempool only) | `sync/mempool/sync_manager.rs:173-193`; `network/mod.rs:174-176` | +| Step 9 — sync-from block / wallet-birthday checkpoint | ✅ capability present; birthday auto-drive soft | `chain/checkpoints.rs:138-145`; `sync/filters/manager.rs:171-175` | +| Block-locator shape ("last 10 + prev checkpoint + genesis") | 🟡 PARTIAL (single-hash, checkpoint-segmented) | `network/mod.rs:102-107`; `sync/block_headers/segment_state.rs:67-69` | +| Named 4-phase state machine | 🟡 PARTIAL (generic `SyncState`, no named phases) | `sync/progress.rs:9-18` | + +No `todo!`/`unimplemented!`/stub markers were found in the masternode/quorum or +header/filter sync paths — the hard cryptographic parts are real. + +### 6.2 DIP-16 deviations (audit findings — mostly intentional, none are dead stubs) + +1. **No 4-phase ordering; L2 sync decoupled from L1.** Identity/platform sync run on + independent timers with zero gating on SPV header/masternode completion. Notably, + platform-data **proof verification does not consume the local SPV quorum state** — + `SpvRuntime::get_quorum_public_key` exists but no sync manager calls it; proofs go + through the SDK/DAPI path. This is the largest DIP-16 conformance gap, but appears + to be a deliberate UX choice (don't block L2 on full L1 sync). +2. **Single-hash block locator** instead of the DIP's multi-hash fork-recovery + locator. Safe under checkpoint-segmented parallel download (each segment anchor is + a validated checkpoint/tip), but a literal non-conformance with no genesis/previous + fallback hashes in a request. +3. **Height aggregation is `max`, not soft-consensus** — one dishonest peer + advertising a high `start_height` inflates the sync target. Minor, but worth a note. +4. **BIP37 mempool filter is set per-peer in a loop**, not an atomic broadcast. +5. **Birthday-by-timestamp start is available but not obviously auto-driven** from + platform-wallet (`get_sync_checkpoint(creation_time)` exists; default start resumes + from persisted `synced_height`/config height). +6. **DashPay address coverage is conditional** — addresses are watched only *after* + the contact's funds-bearing receival account is registered; there is no pre-emptive + watch. This is the DIP-16-layer facet of the §1.1 gap (below). + +### 6.3 DIP-16 does NOT mandate the §12.6 rescan — confirmed + +Direct fetch of DIP-16 confirms it specifies **no** "re-request blocks from height N +after the address set grows" mechanism. Its filter section says only that Platform-app +address spaces "can be used" in the filter and "a client should set this filter on all +connected peers." The rewind-on-new-address behavior is a **DIP-15 §12.6** obligation +layered on the DIP-16 base — so §1.1 is a DIP-15 gap, not a DIP-16 one. + +### 6.4 The rescan engine already exists at the DIP-16 filter layer + +Relevant to §1.1: dash-spv's filter manager **already implements** the rescan +machinery — `reset_for_rescan()` rolls `committed_height` back and replays when a +wallet's `synced_height` drops below scan progress, and an in-flight `rescan_batch` +re-scans when new gap-limit scripts appear mid-batch +(`sync/filters/manager.rs:129-139,468-505`). It is just never *triggered* for the +DashPay backfill case, because nothing lowers `synced_height` to the contact's +`$coreHeightCreatedAt`. That is why §1.1's fix is a small wallet-side trigger plus one +upstream guard-bypass method, not an SPV build. + +--- + +## 7. Recommended priority + +1. **§1.1 coreHeight block re-scan (DIP-15 §12.6)** — the only untracked + correctness/payment-loss item. Now scoped small: a wallet-side `synced_height` + rewind on new-contact registration + one upstream `reset_wallet_synced_height_to` + method; the dash-spv `FiltersManager` rescan engine already does the rest. +2. **§1.2 account-label** — ✅ DONE. Send length-normalization fixed; receive-side + decryption + UI surfacing implemented (incoming-only) per + SPEC.md Milestone 3. DIP-15 §8.5 now fully conforms. +3. **DIP-16 deviations (§6.2)** — mostly intentional; if any is worth hardening it is + #1 (consider sourcing proof-verification quorum keys from the local SPV engine) and + #3 (height soft-consensus). Track, don't rush. +4. Everything in §2 stays blocked on its external dependency; §3 is intentional. diff --git a/docs/dashpay/IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md b/docs/dashpay/IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md new file mode 100644 index 00000000000..885c213a433 --- /dev/null +++ b/docs/dashpay/IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md @@ -0,0 +1,493 @@ +# Identity-Key Scalar Elimination — derive-sign-destroy for discovered keys + +Status: draft, rev 2 (review must-fixes folded) +Scope: removes the carried 32-byte ECDSA scalar +(`IdentityKeyEntry.private_key` / `KeyWithBreadcrumb.verified_scalar`) from the +identity-key discovery → persist → sign flow, replacing it with a +derive-sign-destroy model in which the per-key secret only ever exists in the iOS +Keychain (as the wallet seed), is derived on demand at sign time, and is never +carried across the Rust→FFI→Swift boundary or stored per-key. + +Supersedes the earlier carried-scalar storage posture (the carry-the-verified-scalar +fix this spec reverses). Aligns with the seed-elimination §4.9-blocker item 3 design +and the sibling decision to stop persisting the DashPay friendship xpub and re-derive +on load. + +> **Review outcome (rev 2).** Four independent reviewers (feasibility, scope, +> security, crypto/domain) audited rev 1 against the code. The crux correctness +> claim — pubkey-compare is byte-for-byte equivalent to +> `validate_private_key_bytes(scalar)` — was **verified exact** for both +> `ECDSA_SECP256K1` (33-byte compressed compare) and `ECDSA_HASH160` +> (`ripemd160_sha256(pubkey)` vs `key.data()`; the double-hash gotcha does **not** +> apply because we compare `key.data()`, not `entry.public_key_hash`). No key is +> wrongly authorized and no wallet-derivable key is wrongly dropped to watch-only. +> The must-fixes folded below are all about the **migration**, where removing the +> carried scalar trades an *intrinsic* scalar↔pubkey binding for a *trusted-path* +> binding — the real lockout surface. The five load-bearing corrections: +> **(MF-1)** the backfill reads the Keychain **metadata blob** (named fields), not +> a parse of the account-string label; **(MF-2)** the backfill **and** the sign +> path **re-derive the pubkey at the path and compare to the row's +> `publicKeyData`** before trusting/signing — a present-but-wrong path otherwise +> signs silently and consensus rejects it (silent lockout); **(MF-3)** the +> scalar-field-deletion gate is a **runtime migration stamp**, not a dev-time +> check, and the field-deleted build still runs the Keychain-driven backfill on +> first launch (the Keychain survives a SwiftData store rebuild); **(MF-4)** the +> SwiftData column addition must be a verified-clean lightweight migration (or an +> explicit `MigrationStage`), since this app has historically rebuilt the V1 store +> from scratch; **(MF-5)** the schema delta is exactly `walletId` + +> `identityDerivationPath`, and the FFI layout guard recomputes to exactly **184**. + +--- + +## 1. Problem + +Identity-key discovery carries a verified 32-byte ECDSA scalar **Rust → FFI → +Swift** so the iOS Keychain stores it directly: + +- `discovery.rs::derive_key_breadcrumbs` derives a candidate scalar per on-chain + key; `breadcrumb_decisions` gates each via + `IdentityPublicKey::validate_private_key_bytes(scalar, network)` and carries the + reproducing scalar as `KeyWithBreadcrumb.verified_scalar` + (`changeset.rs:391`). +- It rides `IdentityKeyEntry.private_key` (`changeset.rs:434`, `#[serde(skip)]`, + redacting `Debug`), is copied by value into `IdentityKeyEntryFFI.private_key:[u8;32]` + (`identity_persistence.rs:312`, with `private_key_is_some`), and Swift writes the + 32 bytes to the Keychain via `storeCarriedIdentityKey` → + `KeychainManager.storeIdentityPrivateKey` under account + `identity_privkey..`. +- At sign time the `keyType < 5` branch reads that stored scalar back out + (`KeychainSigner.swift::lookupIdentityPrivateKey` → `ffiSign`). + +This is not a resident keystore — the scalar transits same-tick, is `Zeroizing`, +never serialized, never written to SQLite, and **no Rust signing path reads it** +(Rust signs via the external `VTableSigner`). It exists because the imported-wallet +flow ran `createWallet` (→ discovery) *before* `storeMnemonic`, so the old +Swift re-derive-from-mnemonic produced 23/23 watch-only keys; carrying the +already-verified scalar removed Swift's mnemonic dependency (commit `c567981c46`). + +**Why change it anyway.** The carried scalar is still a raw secret crossing the FFI +ABI and stored per-key at rest. The clean model — already proven for platform +addresses — keeps the only secret (the seed) in the Keychain and derives each +signing key on demand. Removing the carry yields: the raw scalar never crosses the +FFI; one fewer class of secret at rest (no per-key scalar); Rust discovery never +materializes the scalar at all (verify via public key). + +**The hard part.** This is the single most safety-critical path in the wallet: a +wrong key-storage/resolution change **locks users out of signing**, and the change +is only *validatable* against the iOS Keychain signer (iOS-gated). The design must +make the cutover non-lockout **by construction**. + +--- + +## 2. Current vs. target architecture + +### Current (carried scalar) + +``` +discovery.rs derive candidate scalar ── validate_private_key_bytes(scalar) ──┐ + │ verified_scalar: Some +changeset KeyWithBreadcrumb.verified_scalar ─► IdentityKeyEntry.private_key│ +FFI IdentityKeyEntryFFI.private_key[32] + private_key_is_some (by value) +Swift store storeCarriedIdentityKey ─► Keychain item identity_privkey.. (32 raw bytes) +Swift sign keyType<5 ─► lookupIdentityPrivateKey (read scalar back) ─► ffiSign +``` + +### Target (derive-sign-destroy) + +``` +discovery.rs derive candidate PUBLIC key ── compare to on-chain pubkey ──┐ (no scalar materialized) + │ breadcrumb: Some, scalar: ABSENT +changeset KeyWithBreadcrumb{ key, breadcrumb } (no verified_scalar) +FFI IdentityKeyEntryFFI{ …, wallet_id, identity_index, key_index } (breadcrumb only — already crosses) +Swift store persistIdentityKeys ─► PersistentPublicKey.{walletId, identityDerivationPath} (queryable columns) +Swift sign keyType<5 ─► resolveIdentityKeyContext ─► dash_sdk_sign_with_mnemonic_resolver_and_path + (resolve mnemonic in-callback ─► derive ─► sign ─► zeroize; only the signature returns) +``` + +The breadcrumb `(wallet_id, identity_index, key_index)` **already crosses the FFI** +(`identity_persistence.rs` `wallet_id`/`identity_index`/`key_index`, gated behind +`wallet_id_is_some` / `derivation_indices_is_some` — present whenever the entry has +a breadcrumb, independent of the scalar). It is currently used only to build the +Keychain account label and is then discarded. So `persistIdentityKeys` must guard +the new-column write on `entry.derivationIndices != nil` (the snapshot already +exposes `derivationIndices` and `walletId`). + +--- + +## 3. Chosen approach + +Mirror the **platform-address** derive-sign-destroy path, which already works +end-to-end, and reuse its Rust primitive. + +### 3.1 Discovery verifies via public key (no scalar) + +The DIP-9 identity-auth path `m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'` +is fully hardened, so the candidate pubkey must still be derived from a master +xpriv (resolved on demand inside the FFI, wiped before return — already the case +for external-signable wallets). The change is local to discovery: compute the +candidate **compressed public key** +(`derive_ecdsa_identity_auth_keypair_from_master(..).public_key`) and compare to +the on-chain key — `key.data() == pubkey` for `ECDSA_SECP256K1`, +`ripemd160_sha256(pubkey) == key.data()` for `ECDSA_HASH160` — instead of calling +`validate_private_key_bytes(scalar)`. **Do not populate `candidate_scalars`.** + +This is byte-for-byte the same decision `validate_private_key_bytes` makes +internally; it just never needs the scalar to leave the derive function. The +transient master resolution in discovery is **not** what we eliminate — the +persisted/carried per-key scalar is. + +An uncompressed externally-registered ECDSA key (65-byte on-chain `data()`) +correctly stays watch-only because the wallet only ever derives the **compressed** +form, so the compare gracefully fails — *not* because Platform forbids uncompressed +identity keys (it does not; `UncompressedPublicKeyNotAllowedError` is an asset-lock +constraint only). Stating the real reason avoids a future "optimization" that +assumes uncompressed identity keys can't exist on-chain. + +### 3.2 Sign via the existing resolver primitive (no new FFI) + +`dash_sdk_sign_with_mnemonic_resolver_and_path` +(`rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`) is **generic over the +derivation path and ECDSA-only**. Every wallet-derivable identity auth key is ECDSA +(guaranteed by the discovery verify gate), so the identity signing branch calls +this primitive **unchanged** with the DIP-9 identity-auth path string. No new FFI +signing entry point is required. + +**Sign-time binding check (MF-2).** Today's stored-scalar lookup is *intrinsically* +correct — the scalar it returns was the one verified to reproduce that exact pubkey +at discovery. The resolver path loses that: it routes by `wallet_id_bytes` and +derives at `identityDerivationPath`, but never confirms the result matches the key +being signed for. A mis-mapped resolver slot or a stale path would derive a +*different, valid* scalar and produce a signature consensus silently rejects. So the +identity sign path **must verify the derived compressed pubkey equals the row's +`publicKeyData` before signing** (derive-and-compare inside the FFI, or a +pubkey-preview call before `sign`), and fail loud on mismatch rather than emit a +wrong-key signature. This restores the intrinsic binding the stored scalar gave for +free. + +`signIdentityKeyOnDemand` and `signPlatformAddressOnDemand` differ only in their +SwiftData lookup; the `sigBuf` setup + FFI call + error handling are identical. +Extract a shared private `signOnDemandWithContext(walletId:path:expectedPubKey:data:)` +so the two branches don't duplicate ~30 lines (the identity branch passes +`expectedPubKey`, enabling the MF-2 check; the address branch passes `nil`). + +### 3.3 Persist the breadcrumb as queryable columns + +`PersistentPlatformAddress` carries `walletId: Data` + `derivationPath: String` and +the signer reads them in `resolvePlatformAddressContext`. `PersistentPublicKey` +carries only `privateKeyKeychainIdentifier` — no breadcrumb. Add **exactly two** +columns: `walletId: Data?` and `identityDerivationPath: String?`. Both are required +by the resolver FFI — `wallet_id_bytes` is a mandatory parameter (it keys the +mnemonic-resolver callback), and the full path string is what +`dash_sdk_sign_with_mnemonic_resolver_and_path` derives at. **Do not** add separate +`identityIndex`/`keyIndex` columns — they are redundant with the path string (the +inverse of `getIdentityAuthenticationPath`) and the path is authoritative. +`persistIdentityKeys` writes the two columns, building the path with +`KeyDerivation.getIdentityAuthenticationPath` — the same path +`storeCarriedIdentityKey` already computes and currently throws away — and **always +overwrites** when the FFI breadcrumb is present, so a backfilled value and a +fresh-persister value for the same row are byte-identical (a string-format drift +between the two would otherwise desync the stored path from what the resolver +re-derives). + +**Migration safety (MF-4).** Two optional columns are the additive shape SwiftData +lightweight migration handles — *but* this app's `DashModelContainer` runs +`DashSchemaV1` with `stages: []` and has historically rebuilt the dev store from +scratch on any model-hash change. Adding columns must be confirmed to +lightweight-migrate **a real persisted production store on upgrade** (not just a +fresh install); if SwiftData instead rebuilds the store, every +`PersistentPublicKey` row vanishes and the §5 backfill has no rows to heal. Either +verify the clean lightweight path on a real upgrade or bump to `DashSchemaV2` with +an explicit additive `MigrationStage`. Because the backfill is **Keychain-driven** +(§5) and the Keychain survives a SwiftData rebuild, a wiped row set degrades to +re-materialization from the Keychain rather than to lockout — but the migration +shape must still be pinned, not assumed. + +### 3.4 Alternatives rejected + +- **Keep the carried scalar (status quo).** Rejected: leaves a raw secret crossing + the ABI and a per-key secret at rest; diverges from the platform-address model + and the broader "derive on load, don't persist secrets" direction. +- **Re-derive in Swift from the mnemonic at sign time (the pre-`c567981c46` + path).** Rejected: this is exactly the anti-pattern `swift-sdk/CLAUDE.md` forbids + (Swift running `mnemonic → seed → path → key`), and it was the original + imported-identity bug. The resolver primitive keeps the derive inside Rust with + only the path crossing. +- **New identity-specific FFI signing call carrying `(identity_index, key_index)`.** + Rejected as unnecessary now: the generic path primitive already covers every + ECDSA identity key. (A non-ECDSA wallet-derivable identity key — none exist today + — would be the only reason to add one.) + +--- + +## 4. Phased delivery + +**Hard ordering invariant:** the FFI/changeset scalar field is deleted **last**, +only after every already-materialized identity key is proven to sign via the +resolver path on a real device. Deleting it earlier — even though Rust still +compiles — bricks the still-scalar-based Swift signer = lockout. + +### Phase 1 — headless-safe (Rust + FFI only; additive, removes nothing Swift reads) + +Gate: `cargo test -p platform-wallet` + `-p rs-platform-wallet-ffi` green; +cross-compile `aarch64-apple-ios-sim`. No ABI change. + +1. Compute the pubkey-compare decision in `breadcrumb_decisions` and **assert in + tests** it is byte-equivalent to the scalar decision (`reproduces`). This is + *not* a second parallel derive: the candidate pubkey is already a byproduct of + the existing keypair derivation, so the only change is what the decision logic + compares. Production emission is unchanged — `verified_scalar: Some` still + ships in Phase 1 because Swift still reads it; the switch to pubkey-only happens + in Phase 2 step 6. +2. Test the identity DIP-9 path through `dash_sdk_sign_with_mnemonic_resolver_and_path` + (the existing happy-path test already uses `m/9'/1'/5'/0'/0'/0'/0'` — confirm it + covers the identity case or augment it; this step may be test-only, no new code). +3. Test that the FFI breadcrumb round-trips with `private_key_is_some == false`. + +Steps 1–3 have no internal ordering dependency and can land as one atomic Rust commit. + +### Phase 2 — iOS-gated (Swift + on-device), ordered + +1. Add `walletId` + `identityDerivationPath` columns to `PersistentPublicKey` (both + optional ⇒ SwiftData lightweight migration; existing rows get `nil`; no data + loss). +2. Write the breadcrumb columns in `persistIdentityKeys` — **both** old (Keychain + scalar) and new (columns) during the transition window. +3. **Backfill migration (the lockout defense — see §5).** One-time, Keychain-driven, + self-verifying pass populating the new columns from each `identity_privkey.*` + item's `IdentityPrivateKeyMetadata` blob, re-deriving the pubkey at the path and + comparing to the row's `publicKeyData` before trusting it — no network, no seed. +4. Re-route the `keyType < 5` signer branch through `signIdentityKeyOnDemand` + + `resolveIdentityKeyContext` (mirroring the platform-address pair), calling the + existing resolver primitive. **Resolver-first with legacy fallback:** a row with + no `identityDerivationPath` falls back to `lookupIdentityPrivateKey → ffiSign`; + every fallback hit is logged (count only, no key material). +5. **Validation gate:** transitional build on a funded testnet wallet; exercise + signing for every identity (DPNS register, profile set, contact-request + send+accept, payment); confirm **zero fallback hits** after backfill. +6. **Only after the gate:** flip discovery to pubkey-only; delete + `storeCarriedIdentityKey`, the Swift scalar copy/scrub, and the legacy signer + path; then delete the scalar field from FFI (recompute the layout guard from + `const _: [u8; 224]` to exactly `const _: [u8; 184]` — removing + `private_key_is_some` at offset 184 + `private_key: [u8; 32]` + trailing padding + drops bytes 184–223, alignment stays 8) and changeset; regenerate the header; + rebuild. + +**Runtime deletion gate (MF-3) — not a dev-time gate.** Step 6 must not assume every +device passed through the transitional build. A user can upgrade straight from the +scalar-only build to the field-deleted build, skipping the backfill; their rows have +`identityDerivationPath == nil` and there is no legacy signer left → lockout. So: +(a) the field-deleted build **still runs the Keychain-driven backfill on first +launch** (the Keychain items survive any SwiftData rebuild, so the path is +recoverable even with no transitional run); and (b) deleting the *legacy signer +fallback* is gated on a **persisted migration stamp** (set only after a backfill +pass leaves zero un-pathed rows / after the scalar Keychain items are purged), so a +binary without the stamp keeps the fallback and schedules a backfill. The fallback, +not just the field, is the safety net — it lives until the stamp guarantees the +resolver path covers 100 % of the live key set at runtime. + +--- + +## 5. Migration / back-compat — the lockout defense (new design) + +**Danger:** existing installs have scalars in the Keychain under +`identity_privkey..` and `PersistentPublicKey` rows whose +new breadcrumb columns are `nil` after the lightweight migration. If signing flips +to resolver-only and a row has no `identityDerivationPath`, that key is unsignable +→ the user is locked out of an already-working identity. + +Three layers, all required: + +1. **Keychain-metadata-driven, self-verifying backfill (no network, no seed) + (MF-1, MF-2).** Each `identity_privkey.*` Keychain item carries a first-class + `IdentityPrivateKeyMetadata` JSON blob (`kSecAttrGeneric`) with named `walletId`, + `derivationPath`, `identityIndex`, `keyIndex`, `publicKey` fields — + `KeychainManager.identityPrivateKeyAccount` already walks every row and decodes + it. The one-time backfill reads `walletId` + `derivationPath` from the **blob** + (not from a parse of the `identity_privkey..` account + label, which is fragile and has a legacy no-`walletId` variant). It is driven by + the **Keychain item set**, not the SwiftData row set, so it heals even if the + SwiftData store was rebuilt (MF-4) — it can re-create the `PersistentPublicKey` + linkage from the blob's `publicKey`. Crucially it is **self-verifying**: before + writing `identityDerivationPath`, re-derive the compressed pubkey at that path + (resolver / pubkey-preview FFI) and require it to equal the row's + `publicKeyData`; on mismatch leave the column `nil` so the row falls through to + the fallback / re-discovery rather than to a wrong-key sign. A non-zero count of + parse-or-verify failures is a **hard blocker** on the deletion gate (it is not + enough to test that signing works — every existing item must be accounted for). +2. **Resolver-first with legacy fallback.** During the transition the signer tries + the resolver path first and falls back to the stored scalar when the breadcrumb + is absent, so a row can always sign via at least one path. Non-lockout by + construction. (Note: the fallback covers an *absent* path, not a *present-but- + wrong* one — MF-2's sign-time binding check is what catches the latter.) +3. **Re-discovery heals the rest.** Any row not covered by (1) re-materializes on a + from-0 rescan (now writing the breadcrumb columns, needing only the resolver + mnemonic). Surface a "re-scan identities" affordance. + +**Population that blocks deletion (MF-3 / R5).** A wallet with a materialized scalar +but **no readable mnemonic** (the import-flow case that motivated the carried scalar +originally) can never reach zero resolver-fallbacks — the resolver needs the +mnemonic. For that population the legacy scalar fallback must be **retained**, or an +explicit mnemonic-import step required, before its scalar field/path can be removed. +The "zero fallback hits" criterion is otherwise unachievable for exactly the wallets +the carried scalar was introduced to serve. + +The legacy fallback and the scalar field are deleted **only** after the runtime gate +(MF-3) confirms, per device, that the resolver path covers the full live key set — +not merely after a dev-time test pass. + +--- + +## 6. Failure modes & risk register + +| ID | Risk | Mitigation | +|----|------|------------| +| R1 | Pubkey-verify diverges from scalar-verify → a key wrongly breadcrumbed (signable with an unauthorized key) or wrongly watch-only | Phase-1 byte-equivalence test over `ECDSA_SECP256K1` + `ECDSA_HASH160` + foreign key; assert decision set identical to `breadcrumb_decisions` | +| R2 | Existing rows lack breadcrumb columns → resolver-only signer locks out already-materialized keys | §5: backfill migration + resolver-first-with-fallback + zero-fallback gate before deleting legacy | +| R3 | Wrong network → wrong DIP-9 path → wrong key / sign failure | Resolve network from `PersistentWallet` exactly as `storeCarriedIdentityKey` does; unit-test the built path equals the Keychain account string | +| R4 | ABI/layout drift on field removal → `EXC_BAD_ACCESS` in the callback | Recompute `const _: [u8; N]` + the byte-offset comment; cbindgen regen; round-trip test | +| R5 | Resolver mnemonic missing/locked at sign time (watch-only, biometric-gated, import-only wallet with a scalar but no mnemonic) → sign fails where the stored scalar succeeded; zero-fallback gate unachievable for this population | Existing `mnemonicMissing` UX; **retain the legacy scalar fallback for the no-mnemonic population** (§5) — do not delete its scalar/path until a mnemonic-import step runs | +| R6 | A non-ECDSA wallet-derivable identity key appears (future) → the ECDSA-only resolver rejects it | Pre-existing constraint, **not introduced by this change** (the scalar path is already ECDSA-only via `validate_private_key_bytes`); discovery only breadcrumbs ECDSA; a non-ECDSA key would need a new resolver FFI | +| R7 | Old per-key scalars linger in the Keychain indefinitely → negates "one fewer secret at rest" | **Required (not optional)** purge of `identity_privkey.*` items, gated on the same runtime stamp; also doubles as the MF-3 migration-completed signal | +| R8 | **Skip-version upgrade lockout** — user jumps from scalar-only to field-deleted build, skipping the backfill; rows have `identityDerivationPath == nil` and no legacy signer remains | MF-3: field-deleted build still runs the **Keychain-driven** backfill on first launch (Keychain survives a SwiftData rebuild); legacy-fallback deletion gated on a persisted migration stamp, not a dev-time check | +| R9 | **Wrong-mnemonic / present-but-wrong-path silent signing** — resolver routes by `wallet_id_bytes` and derives a valid-but-wrong scalar; signature fails only at consensus, no local diagnostic | MF-2: derive-and-compare the pubkey to the row's `publicKeyData` before signing (and in the backfill before trusting a path); fail loud on mismatch | +| R10 | SwiftData store rebuild on column add wipes `PersistentPublicKey` rows → backfill has nothing to heal | MF-4: verify clean lightweight migration on a real upgrade or declare a `MigrationStage`; backfill is Keychain-driven so a wiped row set degrades to re-materialization, not lockout | + +--- + +## 7. Test / verification plan (red→green) + +**Phase 1 (headless):** +- `discovery.rs` `#[cfg(test)] mod tests` — `breadcrumb_via_pubkey_equivalence`: + derive a multi-key identity, run both the scalar path and the new pubkey path, + assert identical `(breadcrumb, key)` decisions and that the pubkey path carries no + scalar; extend the existing HASH160 + non-reproducible-key tests. **Red first** + (new path wrong), then green. This is the most important Rust correctness gate (R1). +- `sign_with_mnemonic_resolver.rs` tests — `signs_with_dip9_identity_auth_path` + (identity path string, verify signature). Confirms no new FFI is needed. +- `identity_persistence.rs` tests — breadcrumb survives `from_entry` with + `private_key_is_some == false`; (Phase-2 step 6) update the size guard to the new + `N` and assert no scalar field. +- `rs-platform-wallet-storage` round-trip — `IdentityKeyWire` still has no secret + field; compiles after `private_key` removal. + +**Phase 2 (iOS/sim):** +- `KeychainSignerIdentityResolveTests` — `signIdentityKeyOnDemand` resolves + `(walletId, identityDerivationPath)` from a seeded row and signs via a mock + resolver; `canSign` is true with breadcrumb+mnemonic, false without. **Plus the + MF-2 binding test:** a resolver returning a *wrong* mnemonic (or a row with a + *wrong* path) yields a **sign-failure, not a wrong-key signature** — assert the + pre-sign pubkey compare rejects it. +- `PersistentPublicKeyBreadcrumbMigrationTests` — seed a Keychain + `identity_privkey.*` item (with its `IdentityPrivateKeyMetadata` blob) + a row; + run backfill; assert columns are populated **from the blob** and that the + backfilled path **re-derives to the row's `publicKeyData`** (MF-2 self-verify); + assert a blob whose path does *not* re-derive to its pubkey leaves the column + `nil`; assert a row whose backfilled value and a fresh-persister value are + byte-identical; assert a row without a Keychain item falls back to legacy during + the transition; assert backfill works with the **SwiftData row set empty** + (Keychain-driven, MF-4). +- `BackfillCoverageTests` — every existing `identity_privkey.*` item is accounted + for; a non-zero parse-or-verify-failure count blocks the deletion gate (MF-1). +- `persistIdentityKeys` writes the two columns from a breadcrumb-only + (scalar-absent) entry, guarded on `derivationIndices != nil`. + +**On-device acceptance (the real gate):** +- Transitional build over an existing store with already-materialized identities → + backfill runs → exercise signing for every identity (DPNS / profile / contact + request / payment) → **zero legacy-fallback hits** logged. +- Fresh wipe → import funded testnet seed → discover (pubkey-verify, no scalar + carried) → sign → success. +- Wrong-seed rejection (`verify_seed_binds`) still holds. + +**Field deletion is gated:** `git grep verified_scalar` / +`IdentityKeyEntry.private_key` empty only after the zero-fallback on-device gate +passes. If fallbacks > 0, **do not delete** — the scalar is the safety net until the +resolver path is proven for 100 % of the live key set. + +--- + +## 8. Critical files + +- `packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs` — + pubkey-verify in `breadcrumb_decisions` / `derive_key_breadcrumbs`; equivalence test. +- `packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs` — reuse the + signing primitive; add an **optional `expected_pubkey` param** for the MF-2 + derive-and-compare-before-sign check (the address path passes none); add a + DIP-9-path sign test + a wrong-seed-rejects test. +- `packages/rs-platform-wallet-ffi/src/identity_persistence.rs` — (Phase 2 step 6) + delete `private_key` / `private_key_is_some`; recompute the layout guard + `const _: [u8; 224]` → `const _: [u8; 184]` (drops bytes 184–223; align stays 8); + update the byte-offset comment. +- `packages/rs-platform-wallet/src/changeset/changeset.rs` — (Phase 2 step 6) delete + `KeyWithBreadcrumb.verified_scalar` + `IdentityKeyEntry.private_key`. +- `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs` + — `add_keys`: drop the scalar from the destructure + entry literal. +- `packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift` + — add `walletId` + `identityDerivationPath`. +- `packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift` — + pin the lightweight migration / `MigrationStage` (MF-4); host the persisted + migration stamp gating legacy-path deletion (MF-3). +- `packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift` — + Keychain-driven backfill reads the `IdentityPrivateKeyMetadata` blob (`walletId`, + `derivationPath`); required purge of `identity_privkey.*` after the gate (R7). +- `packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift` — + `signIdentityKeyOnDemand` + `resolveIdentityKeyContext` mirroring the + platform-address pair; fallback dispatch; delete `lookupIdentityPrivateKey` / + `ffiSign` in step 6. +- `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift` + — write breadcrumb columns; delete `storeCarriedIdentityKey` + the scalar + copy/scrub. + +--- + +## 9. As-built notes (what shipped vs. this spec) + +The additive, fallback-protected work (Phase 1 + Phase 2 steps 1–5) shipped on +`feat/dashpay-identity-key-scalar-elimination`. Two deliberate deviations from the +rev-2 design above, plus what is explicitly **not** done: + +- **Resolver FFI accepts `ECDSA_HASH160` (key_type 2), not just SECP256K1.** Full + scalar deletion is impossible otherwise — discovery breadcrumbs both ECDSA key + types. The MF-2 binding disambiguates by `expected_key_data` length: 33 bytes = + compressed-pubkey equality, 20 bytes = `ripemd160_sha256(pubkey)` equality. The + param is nullable (the address path passes none). This widens §3.2's "reuse the + primitive unchanged." +- **The backfill self-check is canonical-path-from-indices, not a pubkey + re-derivation.** §5 layer 1 specified re-deriving the pubkey at the path and + comparing to `publicKeyData`; that needs the seed, which the backfill + deliberately avoids. Instead it rebuilds the canonical DIP-9 path from the + metadata's `(network, identityIndex, keyIndex)` and requires it to equal the + stored `derivationPath` (rejecting format drift), and **relies on the sign-time + MF-2 binding as the real guard** — a present-but-wrong path yields + `ERR_PUBKEY_MISMATCH` at sign time → a logged `IDENTITY_SIGN_FALLBACK`, never a + wrong-key signature. A non-zero backfill failure count is still surfaced. +- **Phase 2 step 6 — the carried scalar IS deleted; the legacy fallback signer is + KEPT (partial by design).** `KeyWithBreadcrumb.verified_scalar`, + `IdentityKeyEntry.private_key`, and `IdentityKeyEntryFFI.{private_key, + private_key_is_some}` are removed (FFI layout guard recomputed `224` → `184`; the + `from_entry` copy + free-scrub gone). Discovery now derives-verifies-**drops** the + candidate scalar instead of emitting it — verification is still + `validate_private_key_bytes`, the scalar just never leaves discovery. The legacy + Keychain-scalar signer (`lookupIdentityPrivateKey` / `ffiSign`) is **retained** so + keys already materialized on existing installs still sign: the §5 lockout defense + is preserved *without* removing the legacy path. New keys are resolver-only. + Merged into `feat/dashpay-m1-sync-correctness` (`fb11783706`; commits `930c100c64` + deletion + `fa1098c081` test). +- **The funded-testnet zero-`IDENTITY_SIGN_FALLBACK` gate (§4 step 5 / §7) was + un-runnable and was substituted.** idb cannot actuate this app's SwiftUI + confirmation controls (the toolbar Create/Cancel, the backup-seed "I wrote it + down" switch), and the macOS-click fallback needs Accessibility / Automation / + Screen-Recording TCC the tmux-hosted shell lacks — so the automated UAT could not + even create a wallet. With explicit sign-off ("delete the field if it passes"), + the gate was replaced by a HEADLESS Swift integration test + (`IdentityResolverSignIntegrationTests`): it seeds a real mnemonic in + `WalletStorage` + a consistent breadcrumb row and asserts `signIdentityKeyOnDemand` + → resolver → on-demand derive → MF-2 binding → valid 65-byte signature (plus a + wrong-path → `.failure`). It stays green *after* the deletion — proof the resolver + path signs through the Swift layer with no stored scalar. Verified end to end: + platform-wallet 314 + FFI (125/26/9) tests, Swift IdentityResolverSign 2/2 + + IdentityKeyBreadcrumb 4/4 + 30 Identity tests, clippy clean, SwiftExampleApp sim + BUILD SUCCEEDED. Because the legacy fallback was retained, the `DashModelContainer` + migration-stamp (MF-3) stays deferred (it only matters once the fallback is + removed). Still unproven: a live on-device discover→materialize→sign over an + existing store. diff --git a/docs/dashpay/INTEROP_DESK_CHECK.md b/docs/dashpay/INTEROP_DESK_CHECK.md new file mode 100644 index 00000000000..205b40c5f7c --- /dev/null +++ b/docs/dashpay/INTEROP_DESK_CHECK.md @@ -0,0 +1,465 @@ +# DashPay cross-client interop desk-check + +> Promoted from `docs/dashpay/research/06-interop-desk-check.md` when the +> transient `research/` directory was trimmed — older citations of +> "`research/06`" refer to this file. Kept in the shipped docs because it is +> the evidence base for the consensus-facing wire-format decisions +> (69-byte compact xpub, key-purpose envelope, ASK28 byte order) cited by +> `SPEC.md` and `DIP_CONFORMANCE_GAPS.md`. + +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/MULTI_ACCOUNT_SPEC.md b/docs/dashpay/MULTI_ACCOUNT_SPEC.md new file mode 100644 index 00000000000..dfdf657a79e --- /dev/null +++ b/docs/dashpay/MULTI_ACCOUNT_SPEC.md @@ -0,0 +1,330 @@ +# DashPay simultaneous multi-account contacts — implementation spec + +> **Problem.** DIP-15 lets a contact expose **multiple DashPay accounts** at once — +> each a separate `contactRequest` with a distinct `accountReference` (DIP-15 §8.4, +> §8.9, §10.8). Our contact-state layer collapses everything to **one channel per +> counterparty** (`BTreeMap`, a single-channel `EstablishedContact`), +> and the rotation machinery actively *supersedes* a contact's prior request rather +> than letting accounts coexist. So we can neither represent nor pay across a +> contact's multiple accounts, we always send our own account `0`, and we drop +> `contactInfo.acceptedAccounts` on ingest. +> +> **Status.** REVIEWED (4 lenses, 2026-06-24) — **KEEP DEFERRED** (see *Review +> outcome* below: a foundational blocker B-1 + reopened DoS + abuse surface, and no +> requirement). This feature was **deliberately +> deferred** by the team as *"conditional, not a requirement"* (backlog +> dashpay/platform#4020 multi-account item; `DIP_CONFORMANCE_GAPS.md` §2). There is **no current product +> requirement** forcing simultaneous multi-account. This spec exists so the work is +> *scoped and reviewed* and can be implemented when a requirement appears — and so +> the decision to keep deferring is an informed one. **Do not implement before this +> spec is reviewed and a requirement exists.** +> +> **Source.** Scope map from the 2026-06-24 blast-radius audit (all file:line below +> verified against `feat/dashpay-m1-sync-correctness`, pinned rust-dashcore `b4779fc`). + +--- + +## Review outcome (2026-06-24, 4 lenses) — **KEEP DEFERRED** + +A four-lens review (DIP-15 domain-fit, state-machine feasibility, scope/go-no-go, +security/abuse), each grounded against the code, converged: **the spec is an +accurate scope map, but the feature must NOT be built as designed, and there is no +product requirement driving it. Keep it deferred.** The reviews also corrected +several claims in §0–§4 below (annotated inline as ⚠**REV**). If a requirement ever +appears, **the first deliverable is a focused "channel identity under an opaque +`accountReference`" design note (resolving B-1) — not code.** + +### Blocking findings (must be resolved in a revision before any code) + +- **B-1 — Channel identity is unsolvable from the wire (the foundational blocker).** + The design keys channels by the raw `accountReference`, but DIP-15's + `accountReference = (version<<28) | (ASK28 ^ account)` is a sender-private one-time + pad: the version nibble is cleartext, but `ASK28 = HMAC(sender_secret, compact_xpub)` + is uninvertible by the recipient, **and a rotation ships a new xpub**, so the + low-28-bit value is *uncorrelated* across a rotation. Result: "rotation of added + channel B" is **information-theoretically indistinguishable** from "a brand-new + account." So channels cannot be keyed by `accountReference` and still collapse + rotations. Channel identity must be **out-of-band** (user-assigned at accept time; + every later rotation re-prompts "which channel does this replace?"). This is + permanent UX, not a TODO — and it gates B-2/B-3 below. (`account_reference.rs:41-66`.) +- **B-2 — Keying the collapse by `accountReference` re-opens the PR #3841 sweep + thrash.** Immutable on-chain docs never disappear; a rotated sender leaves both + old+new docs returning every sweep. `newest_received_per_sender` collapses + per-sender *because rotation mutates the reference*; keying the collapse by the + reference produces two survivors that flip-flop the stored channel forever — the + exact regression #3841 fixed. A fixpoint exists only with a rotation-stable key → + loops back to B-1. (`contact_requests.rs:811-829,1087-1117,3103-3105`.) +- **B-3 — The "local channel index" corrupts the receiving derivation path.** + `DashpayReceivingFunds.index` is a **hardened path component** + (`account_type.rs:489`), not just a map key — it selects the BIP32 path the + counterparty derives against. A fabricated local index desyncs our *advertised* + receiving addresses from our *watched* ones → incoming payments to that channel + become invisible. The receiving index must be our **real** DashPay account number + (the one masked into the published `accountReference`); only the *external* account + may use a namespace. The send-side real-account thread (§2.3) is the only correct + mechanism. (`key-wallet account_type.rs:472-526`.) +- **B-4 — `BTreeMap` silently overwrites on collision.** Keying + by a non-unique 28-bit value means two channels masking equal silently shadow each + other (fund misdirection). The on-chain unique index `($ownerId, toUserId, + accountReference)` (`dashpay.schema.json:148-163`) bounds this **per-sender** (a + sender can't broadcast two colliding docs), but the spec must *state and rely on* + that invariant and **reject-on-collision** (insert returning `Some` = loud error), + never overwrite. +- **B-5 — No per-sender flood cap; the re-key converts a flood into pending-queue + exhaustion.** Today `incoming_contact_requests` is one slot per sender + collapse → + a flood is structurally absorbed. The re-key to `(counterparty, accountReference)` + makes each new reference a pending triage prompt (a permanent doc returning every + sweep). Needs a `MAX_PENDING_ADDITIONAL_ACCOUNTS_PER_SENDER` (mirror + `MAX_AUTO_ACCEPT_QUEUED_PER_OWNER`, but per-(owner,sender)); over-cap → **silently + drop, not enqueue**; wire the gate to the existing `ignored_senders` block. +- **B-6 — "Add account" is a phishing / confused-deputy surface.** A *malicious + established contact* can send an add-request whose xpub points at an + attacker-controlled address space (the crypto binds the channel to the contact's + identity, not to the contact being honest). "Add account" must carry the same + trust gravity as accepting a brand-new contact (surface the derived first address; + no one-tap inline accept). The spec frames the gate as anti-flood only and omits + payment redirection. + +### Corrections to the body (factual) +- ⚠**REV §2.2 / Open Q2:** the version nibble is readable but does **not** correlate a + rotation to a specific channel (B-1). Don't claim "consult the version nibble" + resolves rotation-vs-new — it doesn't. +- ⚠**REV §2.1:** strike the "local channel index" for the receiving account (B-3). +- ⚠**REV §2.2:** `acceptedAccounts` per DIP-15 §10.4 stores **only non-version-0** + references — never write channel-0 into it. +- ⚠**REV §4.4:** same-sender collisions are *blocked on-chain* by the unique index; + state this invariant (it's the saving grace) and reject-on-collision (B-4). +- ⚠**REV §4.3:** migration is essentially free — there is **no in-repo SQLite schema**, + `DashMigrationPlan.stages == []` (dev stores recreate from scratch), and contacts + rebuild from chain (metadata rides `contactInfo`). The spec over-worries; the real + plan is "let the store rebuild," with a "wipe local → re-sync reconstructs" test. +- ⚠**REV §3:** T2 (`accepted_accounts` round-trip) is **not** independently valuable — + it writes a field nothing reads (inert). Fold it into T1; do **not** ship standalone. + T1 itself must split into ≥4 PRs (struct re-key / collapse-inversion / user-gate / + account-index thread), each with its own #3841-style fixpoint test. +- **Conformant lower-cost fallback (R1):** DIP-15 §8.4 allows *"either disregard all + future contact requests ... or preferably ask the user."* Silently disregarding + additional requests (≈ today's collapse) is **also conformant** and avoids the + entire B-2/B-3/B-5/B-6 surface — the cheapest path if multi-account is ever wanted + only nominally. + +### Verdict & recommendation +**KEEP DEFERRED.** Upstream (#813) is unblocked, but the feature has a foundational +information-theoretic blocker (B-1), re-opens a fixed DoS (B-2), and adds real abuse +surface (B-4/B-5/B-6) — for **no current requirement**. The review *prevented building +the wrong thing*, which is the point of the pipeline. **Next step only if a +requirement appears:** a B-1 channel-identity design note, then re-spec around it. + +--- + +## 0. What "multi-account" means here (and what it does NOT) + +Two distinct things share the "different `accountReference` from a known sender" +shape and must not be conflated: + +- **Rotation (LIVE today):** the sender rotated the payment xpub for the *same* + logical account; the new request **supersedes** the old (DIP-15 §8.10 immutability + → rotate via a new request). `apply_rotated_incoming_request` + (`state/managed_identity/contact_requests.rs:337-407`) replaces `incoming_request` + in place, tears down the stale external account, rebuilds from the new xpub. The + sync sweep's `newest_received_per_sender` (`network/contact_requests.rs:811-829`) + **discards all-but-newest per sender** — the comment (`:752-765`) calls this "the + idempotency keystone." +- **Simultaneous multi-account (THIS spec):** the sender exposes *additional* live + accounts that must **coexist** as separate channels (DIP-15 §8.4 "Recipients either + ignore subsequent requests or prompt users to select destination accounts"; + §10.8 "additional contact requests require user acceptance; upon approval the new + account reference joins `acceptedAccounts`"). + +These are **antithetical** — rotation's whole purpose is to *prevent* two live +channels per sender. Multi-account must *invert* that for **accepted** additional +accounts while keeping supersede for genuine rotations. The disambiguation is the +crux of this spec (§2.2). + +**Out of scope:** the §10.8 *query-level* flood mitigation ("only the first request +to the bloom filter; filter blocked senders server-side") needs a registered +`dashpay` contract change and stays blocked (Contract track). This spec covers the +**client-side** multi-account model only. + +--- + +## 1. Research — current state (verified) + +### 1.1 What's already multi-account-ready +- **Upstream derivation (#813, merged, in `b4779fc`):** `AccountType::derivation_path()` + for `DashpayReceivingFunds`/`DashpayExternalAccount` uses + `ChildNumber::from_hardened_idx(*account_index)` (`key-wallet/.../account_type.rs:472-531`) + — the friendship path honors a non-zero account. +- **Account collections** are keyed by `DashpayAccountKey { index, user_identity_id, + friend_identity_id }` (`key-wallet/.../account_collection.rs:25-29`) — the + account/UTXO layer already supports multiple accounts per (user, friend). +- **Provider/register signatures already take an account index:** + `receiving_xpub_for(…, account_index, …)`, `account_reference(…, account_index, + version)`, `register_contact_account(…, account_index, …)` (`network/contacts.rs:140`), + `register_external_contact_account` derives `DashpayAccountKey { index }`. + The `accountReference` masking already folds `account_index` into the low 28 bits + correctly (`network/contact_requests.rs:514-551`). + +### 1.2 The bottleneck — contact state collapses to `Identifier` +`ManagedIdentity` (`state/managed_identity/mod.rs:62-85`): +- `established_contacts: BTreeMap` +- `sent_contact_requests: BTreeMap` +- `incoming_contact_requests: BTreeMap` + +`EstablishedContact` (`types/dashpay/established_contact.rs:14-51`) holds **exactly +one** `outgoing_request` + **one** `incoming_request`. It carries a dead +`accepted_accounts: Vec` (`:34`) + `add/remove_accepted_account` (`:138-146`) +with **zero production callers**. + +### 1.3 Hardcoded account `0` on send/build (≈6 sites) +`network/contact_requests.rs:476` (`let account_index: u32 = 0;`), `contacts.rs:397`, +the build sweep `DashpayAccountKey { index: 0 }` (`contact_requests.rs:1398`), the +register-receiving builds (`:1614`, accept `:2259`). + +### 1.4 `accepted_accounts` is lossy +- Codec round-trips it (`crypto/contact_info.rs:133,238-281`, test `:346-366`). ✅ +- Publish hardcodes empty (`network/contact_info.rs:499-506`, "isn't populated yet"). +- `set_contact_metadata` (`state/managed_identity/contact_requests.rs:279-313`) + copies only `alias/note/display_hidden` — **drops `metadata.accepted_accounts`**. +- Not marshalled to FFI/Swift anywhere. + +### 1.5 The recipient-ignores-`accountReference` asymmetry +DIP-15 makes `accountReference` a sender-private one-time pad the recipient **cannot +reliably un-mask** (the 4-way convention split; `DIP_CONFORMANCE_GAPS.md` §3). So the +recipient **cannot** recover the sender's real account number from the wire. It can +only treat the **raw `accountReference` u32** as an opaque channel discriminator, and +derive the actual addresses from the **decrypted xpub** (which is account-correct). +→ multi-account channels must be keyed by the **raw `accountReference`**, not an +unmasked account number. + +--- + +## 2. Chosen approach + +### 2.1 Re-key contact state by `(counterparty, accountReference)` +Replace the single-channel model with a per-contact set of channels keyed by the raw +`accountReference`: +- `EstablishedContact` becomes multi-channel: a `BTreeMap` where `ContactChannel` holds the `{outgoing_request, + incoming_request, payment_channel_broken}` that are today flat on + `EstablishedContact`. Metadata (`alias`, `note`, `is_hidden`, `accepted_accounts`) + stays **per-contact** (one alias for the person, not per channel). +- `incoming_contact_requests` / `sent_contact_requests` re-key to + `(counterparty, accountReference)`. +- Account registration already keys by `DashpayAccountKey { index }`; the channel's + account index comes from the **decrypted-xpub-derived** account, but since we can't + unmask, we allocate a **local channel index** per accepted accountReference and use + it as the `DashpayAccountKey.index` (the xpub is account-correct regardless; the + index only namespaces our local account collection). + +### 2.2 Disambiguate rotation (supersede) vs new account (coexist) — by USER GATE +We cannot tell from the wire whether a new `accountReference` is a rotation or a new +account (§1.5). DIP-15 §8.4/§10.8 resolves this with a **user gate**: +- The **first** request from a sender → auto-established (channel 0), as today. +- A **subsequent** request with a new `accountReference` from an established contact → + surfaced as a **pending additional-account request**, NOT auto-applied. The current + auto-`apply_rotated_incoming_request` supersede is **replaced** by: enqueue as + pending; the user chooses **"replace addresses" (rotation)** or **"add account" + (coexist)**. + - "Replace" → supersede (today's behavior, the channel's request is swapped). + - "Add" → the `accountReference` joins `accepted_accounts`, a new coexisting channel + is built, and the receival/external accounts are registered under a fresh local + index. +- `accepted_accounts` is the **persistent record of which additional references the + user accepted** — so the gate is sticky across sweeps/restarts (an accepted ref is + never re-prompted; an un-accepted one is dropped per §10.8, not bloom-filtered). + +This **inverts the idempotency keystone** (`newest_received_per_sender` collapse) for +accepted references: the sweep must keep every *accepted* `accountReference`'s newest +doc, and collapse only *within* an accountReference (rotation of that channel). That +is the load-bearing, highest-risk change (§4.1). + +### 2.3 Send side — thread a real account (gated behind a UI affordance) +Thread an `account: u32` param from the send FFI through the ≈6 hardcoded sites. The +example app gains an optional "send from account N" affordance; default stays `0`. +**Not a standalone change** — only meaningful once §2.1 state can hold the result. + +### Alternatives rejected +| Approach | Why rejected | +|---|---| +| Unmask `accountReference` to recover the account number, key by that | Recipient can't reliably un-mask (4-way convention split, §1.5). | +| Auto-accept every new `accountReference` as a new account | Violates §10.8 flood mitigation; an attacker floods accounts. | +| Keep single-channel, just stop dropping `accepted_accounts` (Slice A) | Inert today (nothing produces a non-empty value); preserves a field nothing writes — YAGNI. | +| Reuse rotation as the foundation | Rotation *prevents* coexistence by design (§0); it's scaffolding to bypass, not build on. | + +--- + +## 3. Layered change map (task split) + +| Layer | Change | Rough size | +|---|---|---| +| **T1 — Rust contact state** | multi-channel `EstablishedContact`; re-key the 3 maps to `(counterparty, accountRef)`; per-contact metadata; invert the sweep collapse to per-accountRef; user-gate additional accounts; populate `accepted_accounts` | large, the core | +| **T2 — `accepted_accounts` round-trip** | `set_contact_metadata` copies it; publish reads it; (independently shippable as the data-layer floor of T1) | ~15-30 LOC | +| **T3 — Changeset/persistence** | accountRef in `SentContactRequestKey`/`ReceivedContactRequestKey` + `established` map key; carry `accepted_accounts` | medium | +| **T4 — FFI** | `account_index`/`accepted_accounts` on `ContactRequestFFI` + persist callbacks; +1 send param; pending-additional-account surface | medium | +| **T5 — Swift/SwiftData** | accountRef in `PersistentDashpayContactRequest` unique key; per-account grouping in ContactsView/ContactRequestsView/ContactDetailView/AddContactView/SendDashPayPaymentSheet; "add account vs replace" prompt; send-from-account picker | large, UI-heavy | +| **T6 — Tests** | unit (re-key, coexist, user-gate, accepted_accounts round-trip, rotation-still-supersedes-within-a-channel); `dp_*` e2e multi-account send/receive (devnet) | medium | + +The persistence (T3) + Swift (T5) layers need a **migration** for existing +single-channel rows (map the lone channel to `accountReference` of its stored +request). + +--- + +## 4. Failure modes & risks (for reviewers to stress) + +1. **Inverting the idempotency keystone (T1, highest risk).** `newest_received_per_sender` + collapse and `apply_rotated_incoming_request` supersede are the mechanism that keeps + the recurring sweep from thrashing. Splitting "collapse per sender" into "collapse + per (sender, accountRef), keep all accepted refs" must not reintroduce the + multi-doc sweep thrash that PR #3841 fixed (the `newest_received_per_sender` + comment at `:752-765`). Needs the same red→green pinning as the original fix. +2. **Rotation vs add ambiguity.** If the user picks "replace" we must supersede the + *right* channel; if "add" we must not later mistake the rotation of an added channel + for yet another new account. Channels keyed by raw `accountReference` make a + *rotation within a channel* indistinguishable from a *new account* unless the + version nibble is consulted — but the recipient ignores `accountReference`. Resolve: + does "rotation of an added account" even occur, and how is it keyed? +3. **Migration.** Existing persisted single-channel contacts (SQLite + SwiftData) must + map to the new keyed shape without losing alias/note/hidden/broken state or + double-counting payments. +4. **The `accountReference == 0` collision.** Today everything is accountRef `0`-ish; + re-keying must handle the legacy `0` channel and a genuinely-new `0`-masked account + (collisions are possible — `accountReference` uniqueness isn't guaranteed, DIP-15 §7). +5. **UI blow-up.** A contact rendering as N rows vs one row with N accounts; the send + sheet picking an account; the "add vs replace" prompt. Scope creep risk. +6. **No requirement = speculative surface.** Building this without a driving use case + risks shipping inert complexity (Rule 2). The spec must end with a go/no-go. + +--- + +## 5. Verification plan + +- **T2 (unit):** `set_contact_metadata` preserves `accepted_accounts`; publish emits the + contact's accepted set; round-trip through the codec. (TDD red→green.) +- **T1 (unit):** an established contact accepts a second `accountReference` → two live + channels; a rotation of channel 0 supersedes channel 0 only; the sweep does not thrash + across two recurring passes (mirror the PR #3841 idempotency pin); an un-accepted + additional request stays pending and is not watched. +- **Migration (unit):** a persisted single-channel contact loads as a one-channel + multi-account contact with metadata intact. +- **Integration (`dp_*` e2e, devnet-gated):** send from a non-zero account; receive + + accept a contact's second account; pay across both. + +--- + +## 6. Open questions for review (resolve before any coding) + +1. **Go/no-go:** is there an actual requirement for simultaneous multi-account, or does + this stay deferred? (The spec's existence shouldn't force the build.) +2. **Rotation-within-an-added-channel (§4.2):** does it occur in practice, and how is a + channel keyed if not by raw `accountReference`? (Possibly `(accountReference & + 0x0FFFFFFF)` ignoring the version nibble — but the recipient can't unmask… revisit.) +3. **Metadata granularity:** confirm `alias/note/is_hidden` are per-contact (per person) + and only `accepted_accounts` + `payment_channel_broken` are per-channel. +4. **UI model:** one contact row with N accounts (recommended) vs N rows. Send sheet + default account. +5. **Could T2 (accepted_accounts non-lossy) ship now** as a tiny data-preservation fix + ahead of the rest, or does shipping an inert field invite confusion? (Lean: ship with + T1, not standalone.) +6. **Migration safety** for existing devnet/testnet contacts. diff --git a/docs/dashpay/PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md b/docs/dashpay/PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md new file mode 100644 index 00000000000..10931a33e3a --- /dev/null +++ b/docs/dashpay/PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md @@ -0,0 +1,187 @@ +# Relocate the deferred-crypto queue from the wallet to the identity + +**Status:** reviewed (research + 4-lens spec review folded); implementing. +**Scope:** `packages/rs-platform-wallet` (+ a one-line doc-comment in `rs-platform-wallet-storage`). +Rust-only. FFI signatures, Swift, and whole-struct serialization are **unchanged**. + +## 1. Problem + +`pending_contact_crypto: Vec` lives on the **wallet-level** struct +`PlatformWalletInfo` (`wallet/platform_wallet.rs:57`), a sibling of `identity_manager`. Every +*other* DashPay artifact already lives **per-identity** on `ManagedIdentity` +(`state/managed_identity/mod.rs`): `established_contacts`, `sent_contact_requests`, +`incoming_contact_requests`, `dashpay_rescan_triggered`, `auto_accept_verify_failed`, +`dashpay_payments`. The queue is the lone exception, and each `PendingContactCrypto` entry carries +`owner_identity_id` — manually re-storing the exact container key that is *implicit* for the +others. Identity-network code (`IdentityWallet`) reaches *up* into wallet state to touch it. + +This is **cleanup, not a bug fix**. The queue is functionally correct where it is; the value is +consistency/maintainability. The risk is a routing regression on a signer-gated DashPay path (a +mis-routed drain → a contact account never gets built → a DashPay payment silently can't resolve +its external account). So it is speced, reviewed, isolated (its own commit/PR), and tested. + +## 2. Current architecture (verified in research + review) + +- **Type** (`changeset/changeset.rs:1075`): `PendingContactCrypto { owner_identity_id, contact_id, + op: PendingContactCryptoOp, enqueued_at_ms }`. Dedup key `PendingContactCryptoKey = + (owner_identity_id, contact_id, kind)`; `upsert_pending_contact_crypto` keeps ≤1 entry per key. +- **Receiver**: methods are on `IdentityWallet` (bound to a `wallet_id`, + NOT one identity); reaches the queue via `wm.get_wallet_info(&self.wallet_id)`. +- **`IdentityManager` has TWO buckets** (`state/manager/mod.rs:69-83`): + `wallet_identities: BTreeMap>` and + `out_of_wallet_identities: BTreeMap`. Today's flat wallet-level queue + is **bucket-agnostic**. This is the crux of the refactor's one real trap — see §3 D4 / §5 R1. +- **Enqueue** — exactly THREE production sites, each already holding `&mut PlatformWalletInfo` and + the owner id: `enqueue_pending_auto_accepts` (`contact_requests.rs:1553`), + `enqueue_deferred_contact_crypto` (`:1734`), `enqueue_contact_info_decrypt` + (`contact_info.rs:385`). Each pairs the in-memory `upsert_pending_contact_crypto(&mut + info.pending_contact_crypto, e)` with a changeset `pending_contact_crypto_added: vec![e]` and a + `persister.store(...)`. (`payments.rs:2757` is a **test**, not a production enqueue.) +- **Drain** (`drain_pending_contact_crypto`, `contact_requests.rs:1781`): read-lock → clone the flat + queue → **drop lock** → async match over the owned snapshot (each arm routes every side-effect by + `entry.owner_identity_id`; the loop body never touches the queue) → write-lock → single + `retain_drained_by_snapshot(&mut info.pending_contact_crypto, &cleared)`. + `drain_auto_accepts` (`:2160`) is the signer-gated sibling for `AutoAccept` ops; its removal block + also marks `auto_accept_verify_failed` per owner. +- **Count** (`pending_contact_crypto_count`, `:1763`): `count_account_build_ops` over the flat queue + (excludes `ContactInfoDecrypt`). Backs the "waiting for unlock" UI banner. +- **Op ownership asymmetry (subtle, load-bearing for R1):** `RegisterReceiving` / + `RegisterExternal` are owned-only (`build_contact_accounts` gates on `identity_index.is_some()`, + `contact_requests.rs:1661`); `ContactInfoDecrypt` is owned-only (`contact_info.rs` iterates only + `wallet_identities`). But `AutoAccept` is **NOT** gated — `enqueue_pending_auto_accepts` runs for + every identity in the sweep's `all_identities()` loop (both buckets), so an `AutoAccept` op can + legitimately land on an **out-of-wallet** identity's queue. +- **Changeset** (`PlatformWalletChangeSet.pending_contact_crypto_{added,cleared}`, + `changeset.rs:1189`): flat top-level Vecs; entries carry the owner. +- **Apply is a no-op for the queue** (`wallet/apply.rs:115`): the in-memory queue is mutated + *directly* at the enqueue/drain sites; the changeset deltas are for persistence, not in-memory replay. +- **The queue IS durably persisted** — via the `rs-platform-wallet-storage` SQLite backend: table + `pending_contact_crypto` keyed `(wallet_id, owner_identity_id, contact_id, kind)` + (`migrations/V001__initial.rs:87`), live writer `apply_pending_contact_crypto` + (`sqlite/schema/pending_contact_crypto.rs:49`) driven from `apply_changeset_to_tx` + (`sqlite/persister.rs:1063`), reader `all_pending_contact_crypto` (`:108`), round-trip test + (`:161`). The **FFI/SwiftData** backend has no callback for it, so on iOS it is not durably + persisted — but that is one backend, not "the field is vestigial." **The changeset fields are + load-bearing; nothing here gets deleted.** Because the SQLite writer keys on + `(wallet_id, owner_identity_id, contact_id, kind)`, moving the *in-memory* field per-identity + changes **zero** SQLite writes (the owner stays on every row — D2/D5). +- **Not restored on cold load** (`manager/load.rs:102-114`): starts `Vec::new()`; the sweep + re-enqueues. A restore path is half-wired but blocked upstream — see R6. +- **FFI** (`ffi/dashpay.rs:733, 798`): `platform_wallet_drain_pending_contact_crypto` / + `_count` take a **wallet** handle and call `wallet.identity().()`. Called from Swift + (`PlatformWalletManager.swift:640,968`). D4 keeps the `IdentityWallet` method signatures → + **no FFI or Swift change**. +- **Accessors** (`state/manager/accessors.rs`): `managed_identity(&Identifier)` / + `managed_identity_mut(&Identifier)` (`:70,75`) already resolve across **both** buckets via + `location_index`. Enumerators `all_identities() -> Vec<&Identity>` and `identity_ids() -> + Vec` exist, but **there is no iterator yielding `&ManagedIdentity`** — one is added + (D3). + +## 3. Design + +Move the in-memory Vec to `ManagedIdentity`, keyed by the owning identity. Keep +persistence/apply/FFI shapes unchanged to bound the blast radius. + +- **D1 — Field placement.** Add `pending_contact_crypto: Vec` to + `ManagedIdentity`; remove it from `PlatformWalletInfo`. Init `Vec::new()` in `ManagedIdentity::new` + + `new_out_of_wallet` (next to `established_contacts`); drop the 5 `PlatformWalletInfo` init sites. +- **D2 — Keep `PendingContactCrypto` unchanged (keep `owner_identity_id`).** It is the drain's + routing key (each op's side-effects derive from it) AND the SQLite key column; keeping it holds the + type, dedup key, changeset, and their tests stable. The in-memory redundancy (owner == container) + is benign. *Dropping it is out of scope* (§7). +- **D3 — Access by identity.** + - Enqueue + per-owner removal: existing `managed_identity_mut(&owner)` (spans both buckets). + - Drain-snapshot + count: **add** `IdentityManager::managed_identities(&self) -> impl Iterator` chaining `out_of_wallet_identities.values()` with + `wallet_identities.values().flat_map(|m| m.values())`. Iterate **both buckets** (R1). +- **D4 — Drain/count: flat snapshot → unchanged async loop → per-owner-grouped removal. Both + buckets. Same signatures, same wallet-wide semantics.** Concretely (do NOT write an outer + per-identity loop — it borrows `&mut ManagedIdentity`/holds the lock across `.await` and will not + compile): + 1. **Snapshot:** under a read guard, gather every resident identity's queue into one flat owned + `Vec` (`managed_identities().flat_map(|m| m.pending_contact_crypto.iter() + .cloned())`), then drop the guard. `count` sums `count_account_build_ops` per identity the same + way. + 2. **Async loop:** unchanged — it already keys every lookup/side-effect off + `entry.owner_identity_id` and touches the queue nowhere. + 3. **Removal:** under a write guard, group `cleared_snapshots` by `owner_identity_id` and, per + owner, `retain_drained_by_snapshot(&mut managed_identity_mut(&owner).pending_contact_crypto, + &subset)`. Fully synchronous under one guard — nothing crosses `.await`. + `retain_drained_by_snapshot`'s value-equality (which includes the owner) transfers unchanged. + For `drain_auto_accepts`, the same per-owner hop also carries the `auto_accept_verify_failed` + mark (already per-owner today). + - *Send-drain scope (Q1 resolved → wallet-wide):* keep `payments.rs:575` + `self.drain_pending_contact_crypto` draining every resident identity, not just the sender. It is + safe and useful — the Keychain provider is wallet-**seed**-scoped, so one identity's send + correctly finishes other identities' pending builds as a free, correct side effect; no + cross-identity dependency exists (accounts are keyed by both ids). Narrowing to sender-only is a + one-line snapshot filter with only a mild latency/UX argument — deferred. +- **D5 — Changeset + apply + FFI unchanged.** Keep `pending_contact_crypto_{added,cleared}` flat + (entries carry owner), keep `apply.rs` ignoring them, keep the FFI signatures. Only the *in-memory* + field + its access sites move. The SQLite writer is unaffected (owner-keyed). +- **D6 — `ManagedIdentity` field is persistence-inert. Do NOT add it to `IdentityEntry::from_managed`** + (`changeset/changeset.rs:332`), which explicitly enumerates the persisted per-identity fields. + Leaving it out keeps the queue in-memory-only per identity (like `established_contacts` / + `dashpay_rescan_triggered`), so it is NOT double-persisted (once via the flat changeset delta, + never via a snapshot). This preserves D5. + +## 4. Alternatives rejected + +- **Per-identity changeset routing:** unnecessary — apply ignores the queue deltas and the SQLite + writer already keys by owner. Adds churn + a migration question for zero benefit. +- **Drop `owner_identity_id`:** forces the changeset/SQLite key to carry the owner another way and + rewrites the dedup key + tests. Higher risk, separable, deferred (§7). +- **Move only to `IdentityManager`:** leaves the queue one flat list one struct deeper — still not + keyed by identity. Doesn't achieve the goal. +- **Defer / TODO:** legitimate (a reviewer's call, given this path just absorbed the scalar + elimination). Decision: proceed now as an isolated, tested, reviewed change so it's bisectable. + +## 5. Failure modes & risk register + +| ID | Risk | Mitigation | +|----|------|------------| +| R1 | **[Critical]** Drain/count iterate only the owned bucket → an `AutoAccept` op on an *out-of-wallet* identity is silently never drained/counted (auto-accept never fires; banner under-counts). This reproduces the exact silent signer-gated regression this refactor fears. | D3/D4 iterate **both** buckets (`managed_identities()`). Test: an out-of-wallet identity holding an `AutoAccept` entry is still counted and drained. | +| R2 | Drain restructure holds a `&mut ManagedIdentity` or the wallet-manager guard across `.await` → won't compile / deadlock (the register fns re-acquire the non-reentrant manager lock). | D4: flat owned snapshot → drop guard → async loop → re-lock → synchronous per-owner removal. Never a live `values_mut()` borrow across `.await`; snapshot ids/entries into owned Vecs, re-lookup per owner (mirrors the sweep). | +| R3 | Enqueue routes to a wrong/absent identity. | Owner is already in hand at all 3 sites; add `managed_identity_mut(owner)` before upsert. `None` (identity removed in the narrow collect-guard→write-guard window) → log + drop (benign; the identity is gone). Test: enqueue lands on the owner's queue and nowhere else. | +| R4 | `count`/`drain` totals drift (miss an identity). | Same signatures + wallet-wide semantics over both buckets; test with 2 identities each holding entries asserts the aggregate equals the sum. | +| R5 | Auto-accept verify-failure marking regresses. | Marking is already per-owner (`managed_identity_mut(owner).mark_...`); folds into the same removal hop. Keep its test. | +| R6 | Future cold-load restore drops entries (a persisted row whose owner identity isn't applied yet). | The move introduces an ordering constraint the flat queue didn't have: any future restore must apply identities **before** fanning each persisted row out to its owner's queue. Documented here + in the storage doc-comment so whoever finishes the (currently blocked) restore doesn't reintroduce the drop. Not active today (nothing restores). | +| R7 | Identity removal now GC's its queue (dies with the `ManagedIdentity`). | Behavior change vs the flat wallet Vec (entries used to outlive owner residence). **Accepted** — it's orphan cleanup; a transient remove/re-add loses queued ops that the sweep re-enqueues on re-add. Noted, no code needed. | +| R8 | Identity removed between drain snapshot and removal → its keys aren't retained-off/cleared. | Net-identical to today: the op's target is gone and `apply` ignores the `cleared` delta anyway. Noted. | + +## 6. Change list (critical files) + +- `wallet/platform_wallet.rs` — remove the field + doc. +- `wallet/identity/state/managed_identity/mod.rs` — add the field + doc. +- `wallet/identity/state/managed_identity/identity_ops.rs` — init in `new` + `new_out_of_wallet`; **do not** touch `from_managed`. +- `wallet/identity/state/manager/accessors.rs` — add `managed_identities()` iterator (both buckets). +- `wallet/identity/network/contact_requests.rs` — 2 enqueues (`:1553`, `:1734`); `drain_pending_contact_crypto` (snapshot both buckets, per-owner removal); `pending_contact_crypto_count` (sum both buckets); `drain_auto_accepts`; `empty_info` test helper (`:3254`); the drain/count/auto-accept tests. +- `wallet/identity/network/contact_info.rs` — enqueue (`:385`). +- `wallet/identity/network/payments.rs` — send drain unchanged (`:575`); **re-seed** the drain tests (`:2757`, `:2811`) with real registered identities (out-of-wallet for the `identity_index==None` case). +- `manager/load.rs:114`, `manager/wallet_lifecycle.rs:249`, `wallet/apply.rs:420`, `wallet/platform_wallet_traits.rs:43,56` — drop the `PlatformWalletInfo` init sites. +- `rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs:100-106` — update the doc-comment that names `PlatformWalletInfo.pending_contact_crypto` as the restore target (now per-identity fan-out by `owner_identity_id`; see R6). +- `ffi/dashpay.rs` — unchanged; verify it still compiles. + +## 7. Explicitly out of scope + +- Dropping `owner_identity_id` from `PendingContactCrypto`. +- Deleting/altering the `pending_contact_crypto_{added,cleared}` changeset fields — they are + persisted by the SQLite backend (§2). NOT vestigial. +- Wiring the cold-load restore (blocked upstream); this change only leaves it a correct ordering note. +- Any Swift / FFI-signature change. + +## 8. Test / verification plan + +- **R1 (the one that matters):** an out-of-wallet identity holding an `AutoAccept` entry is counted + by `pending_contact_crypto_count` and processed by the drain — asserts both buckets are iterated. +- **R3:** enqueue lands on the owner identity's queue and no other identity's. +- **R4:** 2 resident identities each holding queue entries → aggregate count + drain == sum. +- **R2:** re-uses `retain_drained_by_snapshot`'s existing value-equality test shape, per-owner. +- **R5:** auto-accept verify-failure marks the right identity. +- Cold-load: a freshly-loaded identity has an empty queue; the sweep re-enqueues (behavior unchanged). +- Keep green (re-seeded where noted): `send_payment_runs_pending_contact_crypto_drain`, + `drain_completes_register_receiving_and_clears_queue`, + `drain_leaves_register_external_it_cannot_complete`, + `account_build_count_excludes_contact_info_decrypt`, the changeset merge/dedup tests. +- `cargo test -p platform-wallet -p platform-wallet-ffi`; `cargo clippy … --all-targets` clean; + `build_ios.sh --target sim` BUILD SUCCEEDED (FFI unchanged → Swift unaffected). diff --git a/docs/dashpay/QA_TESTCASES_SPEC.md b/docs/dashpay/QA_TESTCASES_SPEC.md new file mode 100644 index 00000000000..55d0456ee12 --- /dev/null +++ b/docs/dashpay/QA_TESTCASES_SPEC.md @@ -0,0 +1,225 @@ +# DashPay (DIP-15 / DIP-16) QA test-case expansion — SPEC + +**Status:** reviewed (4-lens multi-agent pass folded in) +**Target file:** `packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md` §4.10 (+ §5, §6, §1) +**Base:** branched off `feat/dashpay-m1-sync-correctness` (PR #3841, +"fix(platform-wallet)!: complete dashpay") — the DashPay views, `docs/dashpay/`, +and the features these rows describe live there, not yet in `v3.1-dev`. +**PR target:** `v3.1-dev`, to merge **after** #3841 lands (pure-docs diff; +rebased so it shows only the TEST_PLAN.md / spec changes). +**Renders in:** [`dashpay/qa-dashboard-site`](https://github.com/dashpay/qa-dashboard-site) +once the sibling seed task re-seeds the `dash-qa` contract from the updated plan. + +--- + +## 1. Problem + +`TEST_PLAN.md` §4.10 (DashPay) had **6 coarse rows** (`DP-01..06` + cross-ref +`MW-03`) at "feature exists" granularity, and their entry points were **stale**: +they cited `FriendsView` / `AddFriendView`, which #3841 replaced with a dedicated +DashPay tab (`DashPayTabView`, `AddContactView`, `ContactsView`, +`ContactRequestsView`, `ContactDetailView`, `DashPayProfileView`, +`IgnoredContactsView`, `SendDashPayPaymentSheet`). + +#3841 implements substantial **DIP-15** surface the catalog did not exercise +(per the branch's audit `docs/dashpay/DIP_CONFORMANCE_GAPS.md`): +`encryptedAccountLabel` send+receive, QR auto-accept (build + paste-to-add), +on-chain `contactInfo` publish, and the §12.6 incoming-payment backfill rescan. +**DIP-16** is the SPV sync layer underneath (covered by `CORE-07`; its +DashPay-specific facet is the single backfill row `DP-10`). + +## 2. Goal & non-goals + +**Goal:** correct the stale `DP-01..06` entry points and add rows for the +**user-observable, simulator-drivable** DIP-15/16 DashPay flows #3841 ships. + +**Non-goals (out of scope):** +- **DIP-15 crypto internals** (ECDH, 69-byte compact xpub, `accountReference` + masking, avatar hash/dHash) — already Rust known-answer tests; not app rows. +- **Gap / absence rows** (`🚫`/`➖`) for unimplemented features (multi-account + `Account≠0`, `acceptedAccounts` flood mitigation, invitations). Drivable only. +- **A DIP-16 section.** SPV sync is `CORE-07`; the DashPay facet is `DP-10`. +- **New table columns.** Keep the uniform 6-col `ID | Action | Layer | Tier | + Status | Entry point & test notes`; cite DIP §s inline. (The dashboard + normaliser reads no section field; a 7th column is dropped on seed.) +- **A `tags` column.** Tag assignment for the v5 contract is the seed tool's job + (not in these repos). Open question §7. + +## 3. Corrections to existing rows (`DP-01..06`) + +Entry points updated to the #3841 DashPay tab; FFI-symbol naming (matches the +existing §4.10 convention). Merged sub-flows folded in as notes: +- **DPNS-add path** → a note on `DP-01` (precedent: `ID-04`/`MW-01` list input + methods in one row; DPNS resolution itself is `DPNS-03`/`DPNS-07`). +- **Payment-channel-broken** state → a note on `DP-03` (precedent: `ID-12`/`DOC-07` + attach gating state to the action row). +- **Avatar** (url + Rust-computed hash/fingerprint) → a note on `DP-04`. +- `DP-06` **Reject → Ignore** rename (the branch made reject a reversible local mute). + +## 4. New rows (drivable DIP-15/16 flows) + +| ID | Tier | DIP-15 § | Behavior | +|---|---|---|---| +| DP-07 | Common | §8.5 | Attach `encryptedAccountLabel` on send; counterparty sees "Their account" (decrypted, incoming-row only). | +| DP-08 | Thorough | §8.13 | QR auto-accept: build "Add me" QR; add via pasted URI → auto-accepted without manual accept. Paste-drivable; camera = Manual variant. | +| DP-09 | Thorough | §10 | Publish encrypted on-chain `contactInfo`; ≥2-contact gate → `.published` / `.deferredUntilTwoContacts` / `.skippedWatchOnly`. | +| DP-10 | Manual | §8.7/§12.6 | Incoming-payment backfill rescan (no UI trigger; `reconcile_dashpay_rescan` rewinds SPV `synced_height`). Env-limited; the §12.6 payment-loss regression pin. | + +`DP-10` note: the branch's `DIP_CONFORMANCE_GAPS.md` §1.1 still marks this MISSING, +but that audit predates the implementing commit `18483e4232` +(`reconcile_dashpay_rescan`, wired in `manager/dashpay_sync.rs`, 4 unit tests) — +so Status=✅ is correct. + +## 5. Cross-cutting edits (applied) + +- **§6 index** — DashPay: `DP-01..06, MW-03` → `DP-01..10, MW-03`. +- **§5 by-tier** — Common `31→32`, Thorough `35→37`, Manual `1→2` (`CORE-08, DP-10`). +- **§5 by-layer (automatable; Manual EXCLUDED)** — Platform `~72→~75`; **Cross + unchanged** (DP-10 is Manual). +- **§1 worked example** — "list the manual tests" → `CORE-08, DP-10`. + +## 6. Final row set + +6 corrections (`DP-01..06`) **+ 4 new** (`DP-07` account label, `DP-08` QR +auto-accept, `DP-09` on-chain `contactInfo`, `DP-10` backfill rescan). + +## 7. Open questions + +1. **Tags** — does the seed tool assign v5 tags (e.g. `dip15`, `sync`) from + Domain/§6, or should the plan encode them? Needs the seed tool (not in repos). +2. **DP-07 a11y id** — the "Their account" block in `ContactDetailView` has no + `accessibilityIdentifier`, so DP-07 asserts on visible text. A 1-line app + change would make it cleanly automatable — a trivial follow-up, deliberately + kept out of this docs-only PR. + +## 8. Verification plan + +1. **Render check** — IDs match `^DP-\d+$`; tier/category present so the dashboard + matrix charts them (the normaliser only hard-requires `testId`). +2. **Drive each new row** with the `simulator-control` skill on a booted sim; two + on-device wallets where a counterparty is needed (`DP-07`/`DP-08`, cf. `MW-03`). + Verify against **persisted SwiftData state**, not UI alone (§1 pass criteria). + `DP-10` is Manual → skip-and-flag in automation. +3. **No code change** — pure TEST_PLAN.md edit. The seed task re-seeds `dash-qa`; + the dashboard renders. + +## 9. Review provenance + +Four independent review lenses (DIP domain-fit, scope/simplicity, automatability/ +entry-point accuracy, catalog conventions) ran against the draft. Key folds: +- Added `DP-09` (on-chain `contactInfo`) — the draft wrongly excluded it as + "local-only / no UI"; it is a drivable DIP-15 §10 publish. +- Merged the draft's separate DPNS / avatar / channel-broken rows into notes on + `DP-01` / `DP-04` / `DP-03` (catalog precedent; lean set). +- Dropped a 7th `DIP-15 §` column (normaliser ignores it; breaks the 6-col shape). +- Confirmed `DP-10`'s rescan is implemented + wired; corrected the `wallet.pass` + SF-symbol-vs-a11y-id confusion in `DP-07`. + +## 10. Runtime verification (simulator) + +Driven on a booted iOS simulator (iPhone 17) against a live devnet build with real +DashPay fixtures (wallet "SimB", 1 identity, 2 contacts, 5 requests), via the +`simulator-control` skill. Read-only structural pass — navigated to each row's +entry point and confirmed the cited screens/controls exist; **no broadcasts fired**. + +Confirmed live: +- `DP-01` — `AddContactView` mode picker + resolved-recipient preview + **Send Request**. +- `DP-03` — `ContactDetailView` `dashpay.detail.sendDash`. +- `DP-04` — `DashPayProfileView` **Edit** (→ editor) + avatar. +- `DP-05` — DashPay tab: `ContactsView` (search, contacts, segment, profile header). +- `DP-07` (send) — `dashpay.addContact.accountLabel` renders once a recipient resolves. +- `DP-08` — build: `dashpay.profile.qrURI` emits a real `dash:?du=…&dapk=…` URI + QR + image; add: `AddViaQRSheet` `dashpay.qr.uriField`. +- `DP-09` — the **Alias / Note / Hide** editor calls `saveContactInfo` → + `setDashPayContactInfo`; the in-app footer confirms the ≥2-contact encrypted-publish + gate. **Refined the row** accordingly — the original "distinct from a local note" + wording was wrong (the same editor caches locally *and* publishes on-chain). + +Code-confirmed but not rendered this pass (no fixture): `DP-02` / `DP-06` +(`dashpay.request.accept` / `.ignore` — need an *incoming* pending request); +`DP-07` receive-side "Their account" (only shows when a contact sent a label); +`DP-10` (no UI by design — automatic in DashPay sync). Live broadcast execution and +the two-wallet loops (`DP-07`/`DP-08`) are the next step, gated on credits + a +counterparty identity. + +A full code re-audit of every row (4 parallel passes) confirmed 8/10 rows + all the +§5/§6/§1 count edits accurate, and corrected 5 row-wording inaccuracies: DP-01 (open +button id `dashpay.addContact` vs the in-sheet mode toggle), DP-02/DP-05 +(`EstablishedContact` is a Rust/FFI handle, **not** a SwiftData model — the tab views +are backed by `PersistentDashpayContactRequest`), DP-03 (channel-broken is any +permanent channel failure, not only key rotation), and DP-08 (TTL is exactly 3600s). + +## 11. Implementation observations (for #3841 — surfaced during verification, NOT addressed here) + +These are defects/smells in the DashPay *implementation* found while auditing the +plan. They are out of scope for this docs PR; recorded for the #3841 author. + +1. **Stale doc-comment** — `ContactRequestsView.swift:5-8` says incoming rows carry + "**Accept / Reject**", but the button is **Ignore** (reject was replaced by the + reversible local mute). Same file `:35-37` carries an internal `§6.4` spec-gate + ref (rots; against the timeless-comment convention). +2. **Multi-wallet mis-attribution risk** — `DashPayProfileEditorView` falls back to + `walletManager.firstWallet` when `walletId` is nil (`IdentityDetailView.swift:1316`); + in a multi-wallet setup a profile update could submit under the wrong identity. + Already acknowledged in an in-code comment as needing tightening. +3. **Handle-leak smell** — `acceptContactRequest`'s returned `EstablishedContact` + (FFI handle wrapper) is discarded with `_ =` at both call sites + (`ContactRequestsView.swift:228`, `AddContactView.swift:487`); leaks per accept + unless the wrapper frees the handle in `deinit` (worth confirming a `deinit`). +4. **QR clock edge** — `build_auto_accept_qr` derives expiry from + `SystemTime::now()…unwrap_or(0)`; a pre-1970 / badly-skewed clock yields an + already-expired QR. Harmless on a real device. +5. **No collision handling in `AddViaQRSheet`** — pasting a URI from someone who + already sent *you* a request broadcasts a duplicate outgoing request rather than + offering "Accept instead" (`AddContactView` handles this; the QR path does not). + +By-design / cosmetic (no action expected): avatar hash does not change if the image +bytes are swapped behind the same URL; a corrupt/hostile incoming account label +unpads to garbage and is coerced to `None` (shows no "Their account" — relevant to +DP-07 negative testing); `setDashPayContactInfo` maps unknown future outcome bytes to +`.published` on the Swift side; a stale memo doc-comment in `SendDashPayPaymentSheet` +(DashPay payments always pass `memo: nil`); a dead `_ = bytes` local + a redundant +`?? nil` duplicated across four profile-cache reads. + +## 12. Live end-to-end run (freshly-built binary) + +Built `build_ios.sh --target sim` from `feat/dashpay-m1-sync-correctness` HEAD +(`47d9044b5a`), installed on two iOS simulators, and drove the flows on-chain +against devnet (two funded identities per side): **Eve** (SimB) ↔ **Alice / Bob / +Dolly(7A8E)** (SimA), each ~25–30B credits. Verified against SwiftData ground +truth (and on-chain for the payment). + +| Row | Result (fresh build) | Evidence | +|---|---|---| +| DP-01 send | ✅ | labeled contact request broadcast (sheet dismissed, no error); also the DP-02 reciprocal send | +| DP-02 accept | ✅ | 7A8E accepted Eve → reciprocal `7A8E→Eve` row created (established) | +| DP-03 payment | ✅ one direction | Eve→Alice **0.001 DASH** real L1 tx (input spent, change `74,899,477`, fee `226` duffs, txid `850433507c88…560e`) — **after starting Core SPV**. ⚠️ only the forward direction was driven; see the bidirectional gap below | +| DP-04 profile | ✅ | publicMessage updated on-chain → SwiftData (`QA fresh-build 16:10`) | +| DP-05 view | ✅ | contacts / requests / profile rendered throughout | +| DP-06 ignore | ✅ | registered a fresh identity (asset-lock funded, ChainLock proof) → sent Eve a request → Eve **ignored** it (→ ignored-senders) → **un-ignored** (reversed). Local-only mute | +| DP-07 label | ✅ fresh first-contact | Bob→**EveN** (fresh pair) labeled send; EveN accepted → decrypted "Their account" = the sent label. Confirms decrypt-on-accept end-to-end | +| DP-08 QR | ✅ fresh first-contact | Alice built `dash:?du=…&dapk=…`; **EveN** pasted + `sendContactRequestFromQR`; Alice **auto-accepted** (reciprocal, no manual Accept) — *after unlocking Alice's wallet* (signer-backed drain; see note) | +| DP-09 contactInfo | ✅ on-chain | log: `Published contactInfo document identity=Eve contact=Alice` — the `.published` outcome, not just local persist | +| DP-10 backfill rescan | ✅ mechanism (logs) | the §12.6 rescan fired live: `DashPay rescan: lowered SPV synced_height … floor=51112` → `dash_spv…filters: synced_height 51112 fell below committed_height 52175, restarting scan`. No UI trigger (Manual tier); the full restore-from-seed payment-recovery remains a device exercise | + +**10/10 flows verified live on the fresh build** — DP-01..09 driven on-chain +(SwiftData + chain; DP-07/DP-08 via a freshly-registered unconnected identity to +get clean first-contact pairs), DP-09's on-chain publish + DP-10's backfill-rescan +both confirmed in the Rust logs. + +**Gap (DP-03 bidirectional):** only the **forward** payment (Eve→Alice) was driven. +The reverse (Alice→Eve) is symmetric by design — once established, each party derives +the other's payment address from the exchanged xpubs — but it was **not** verified +live (SimA's app context had flipped to a separate testnet wallet set). DP-03 now +explicitly requires verifying **both** directions; the reverse remains to be driven. + +**Finding (DP-08):** the QR auto-accept *reciprocal* is signer-backed, so it only +fires once the recipient's wallet is **unlocked** (the "N contacts waiting to finish +setup → Unlock" drain). The request and auto-accept proof reach the recipient +immediately, but the established reciprocal lands after unlock — so "auto-accept" is +not fully hands-off. Worth surfacing in DIP-15 §8.13 expectations. + +Two plan corrections came out of the run: **DP-03** now records the Core-SPV +precondition (a DashPay payment is an L1 broadcast — fails "SPV Client not started" +if SPV is stopped); **DP-07** now states the label decrypts **on accept**, not on +ingest. diff --git a/docs/dashpay/QR_AUTO_ACCEPT_SPEC.md b/docs/dashpay/QR_AUTO_ACCEPT_SPEC.md new file mode 100644 index 00000000000..23b84e11764 --- /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) informed this spec. 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/SIGNER_SEED_ELIMINATION_SPEC.md b/docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md new file mode 100644 index 00000000000..a0594bd68d6 --- /dev/null +++ b/docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md @@ -0,0 +1,666 @@ +# 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 (later reworked into the derive-sign-destroy resolver — see `IDENTITY_KEY_SCALAR_ELIMINATION_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 the backlog (dashpay/platform#4020) (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: the backlog issue dashpay/platform#4020 (authoritative tracker). + +**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 the backlog (dashpay/platform#4020)): §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 the backlog (dashpay/platform#4020). +- **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`; 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..6306eec5f1f --- /dev/null +++ b/docs/dashpay/SPEC.md @@ -0,0 +1,1362 @@ +# 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 +> the DashPay backlog issue [#4020](https://github.com/dashpay/platform/issues/4020) 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. The durable evidence base ships alongside this spec: +> [`INTEROP_DESK_CHECK.md`](./INTEROP_DESK_CHECK.md) (cross-client wire-format +> verdicts + testnet census) and [`CONTACTINFO_FORMAT_SPEC.md`](./CONTACTINFO_FORMAT_SPEC.md) +> (Appendix A). The transient working-research files that once backed the +> remaining citations were trimmed from the tree; they remain in this branch's +> git history (`docs/dashpay/research/`, up to the trim commit). + +--- + +## 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, `INTEROP_DESK_CHECK.md`): 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 the DIPs themselves (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 the deployed +schema, `packages/dashpay-contract/schema/v1/dashpay.schema.json`. 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 (file:line against this branch). + +### 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, +`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; revised 2026-07-04):** originally shipped as a + Send-boxed `#[async_trait]` `DashPaySdkWriter` trait; the trait was later + removed as an unused seam (no test ever injected it — the sync/establish + tests mock the fetch half via `SdkBuilder::new_mock` instead). + `network/sdk_writer.rs` now ships a concrete `SdkWriter` held as an + `Arc`-backed `IdentityWallet` field: it still erases the + 7-type-param `send_contact_request` / `PutDocument` generics behind two + concrete methods, it just isn't swappable. +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)** — `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 + `INTEROP_DESK_CHECK.md`). 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 — + `INTEROP_DESK_CHECK.md` §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. + +**coreHeight backfill rescan (DIP-15 §8.7/§12.6) — DONE (2026-06-24).** Surfaced by +the re-audit after M1's original plan: an incoming payment to a contact's receival +address that landed *before* the address was watched (restore-from-seed, second +device, or the offline-accept→pay window) was silently missed. Fixed by (a) re-import +scanning from birth-height `Some(0)` (`cba515aaf1`) so on-chain history isn't skipped, +and (b) `reconcile_dashpay_rescan` (`18483e4232`, a local-only step of `dashpay_sync` +in `manager/dashpay_sync.rs`) that lowers the wallet's SPV `synced_height` toward +`min($coreHeightCreatedAt)` over established receival contacts so dash-spv's filter +manager re-matches the now-watched addresses against blocks it already scanned. It uses +the inner unconditional height setter (no upstream change) with a per-contact +`dashpay_rescan_triggered` one-shot guard so the recurring sweep doesn't re-lower the +height every pass. The regression is safe (`synced_height` is the filter-scan +checkpoint, decoupled from the monotonic `last_processed_height`); the floor is clamped +to the engine's header/birth floor, and the one SPV caveat is that a rewind into a +never-stored filter range is retried silently every 30s rather than erroring. + +### 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). **Realtime cadence (per §6.4):** the tab drives the + background loop's interval to 4s on foreground / 15s on background via + `setDashPaySyncInterval` (NavigationStack `onAppear`/`onDisappear`), and every + local mutation (send/accept/QR-send/pay) fires a non-blocking `kickDashPaySync` + so the counterparty side converges without waiting for the next tick. +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. + +**Receive-side `encryptedAccountLabel` surfacing (DIP-15 §8.5) — DONE (2026-06-24).** +The send side already length-normalizes the label; the receive side now decrypts and +shows it. Decrypted in Rust at the two signer-bearing register sites (the drain +`RegisterExternal` Ok-branch + `accept_register_external_validated`, where the ECDH +`shared` key lives) by `store_contact_account_label` (`network/contact_requests.rs`), +stored on the derived `EstablishedContact.contact_account_label` field (reset in `new`, +`reestablish_preserving_metadata`, and `apply_rotated_incoming_request` so it never +goes stale). It is surfaced **incoming-only** — it is the contact's label for *their* +account, so it is derived strictly from the incoming request and projected onto the +incoming FFI row only (the outgoing row's label is one *we* sent and is never shown), +via `contact_persistence.rs`. Decrypt failures / garbage / control-chars sanitize to +`None` (cosmetic — never breaks the channel), and it resets on rotation. Renders as a +read-only "Their account" row through Swift `contactAccountLabel` → `ContactDetailView`. + +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 (`INTEROP_DESK_CHECK.md`) + on-chain census remain + the interop evidence; the contactInfo research (`CONTACTINFO_FORMAT_SPEC.md` Appendix A) 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 +│ (empty state → "Set up your DashPay profile" CTA → ProfileEditorView) +├─ Username prompt card → "Register a username" → RegisterNameView +│ (shown only when an on-chain DPNS check confirms the active identity +│ has no name; explains that without a username people can't find you +│ by name, and that the profile display name is cosmetic, not searchable) +├─ 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:)`. + +**Username prompt** (`usernamePromptCard`, below the profile header) +- A second setup CTA, independent of the profile one: shown when the active + identity has **no DPNS username** (confirmed by an on-chain `dpnsGetUsername` + check in `.task` + on app-foreground, so an identity that already has one — + just not yet cached, or registered meanwhile on another device — is never + nagged; mirrors `IdentitiesView`'s lazy fetch. A found name is persisted and + saved; a definitive empty result shows the card; a thrown error retries. + Residual: a name registered on another device *while the user sits on this + tab* clears on the next tab switch / app-foreground). Tap → `RegisterNameView`. + Copy makes the username-vs-profile distinction explicit: a **username** is the + searchable handle people type to add you (contact search); the profile + **display name** is cosmetic and not searchable. On registration the Rust path + persists `dpnsName`, so the prompt hides reactively via `@Query`. + +**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 `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. +- **Realtime cadence (foreground-fast / background-slow)** — the G12 background + loop runs on a tunable interval (`setDashPaySyncInterval`, clamped ≥ 1s Rust-side; + default = `backgroundSyncSeconds` = 15s). The DashPay tab drops it to + `foregroundSyncSeconds` = 4s at *effective foreground* — the tab is on screen + **and** the app is active — and restores 15s otherwise. "On screen" is driven + from the tab's **NavigationStack** `onAppear`/`onDisappear` (so drilling into a + contact detail or presenting a sheet, neither of which fires the stack's + `onDisappear`, keeps the fast cadence; only a *tab switch* relaxes it); "app + active" is driven from `scenePhase`, so backgrounding the app while on the tab + also relaxes to 15s. The cadence acts only on transitions. This keeps neither an + inactive tab nor a backgrounded app sweeping every few seconds, while incoming + requests / acceptances / payments surface in near real time when the user is + actually looking. **Entry kick:** `setDashPaySyncInterval` only takes effect on + the loop's *next* sleep (it stores an atomic, no wakeup — `dashpay_sync.rs:157`), + so entering the foreground also fires one `kickDashPaySync` — otherwise a tab + re-entry could wait out a leftover up-to-15s sleep before the first fast tick. + (A Rust-side `Notify` on `set_interval` would shorten the in-flight sleep + directly; deferred as an internal refinement — the entry kick achieves the same + user-visible result app-side.) Best-effort: a not-yet-configured manager keeps + its current interval. +- **Post-mutation sync kick** — after a local mutation (send request, accept, + send-via-QR, pay) the handler fires a non-blocking `dashPaySyncNow()` + (`kickDashPaySync`) so the counterparty's state and the established pair converge + promptly instead of waiting a full poll tick. Non-blocking: the sheet dismisses + right away and the Rust manager folds an in-flight pass into a no-op (the single + sync-in-progress signal above). *Bounded, not instant:* if a pass was already + running when the mutation landed, the kick no-ops and convergence waits for the + next tick (≤ the foreground 4s) rather than enqueuing a coalesced re-run. + Complements, doesn't replace, the optimistic `@Query` overlay — the overlay + covers the sender's own row, the kick pulls the other side. +- **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). +- **Needs-unlock / verify-failed banner** (seedless wallets) — **DONE (2026-06-23; + `9963923e05` Rust+FFI, `841802c587` Swift).** A signerless sweep enqueues + contact-crypto ops it can't finish while the Keychain is locked; + `pending_contact_crypto_count` (`network/contact_requests.rs`) → FFI + `platform_wallet_pending_contact_crypto_count` (`dashpay.rs`) feeds the ~1 Hz Swift + poller into a per-wallet `DashPayUnlockStatus` / `@Published dashPayUnlockStatus`, + rendered as a banner in `DashPayTabView.swift` (orange "N contact(s) waiting to + finish setup" + Unlock, red on seed-mismatch). The count **excludes** + `ContactInfoDecrypt` ops — those re-enqueue every sweep, so counting them would + falsely re-trip the banner ~15s after every unlock; only the account-build ops + (`RegisterReceiving`/`RegisterExternal`) converge to 0 once the payment account is + built. +- **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). +- **dp_006b (foreground-fast cadence + post-mutation kick, §6.4):** entering the + DashPay tab lowers the sweep interval to 4s **and fires one immediate sweep** + (because `set_interval` only applies on the loop's next sleep); leaving the tab + *or backgrounding the app while on it* restores 15s (`setDashPaySyncInterval` + round-trips through the FFI). A send/accept fires an extra `dashPaySyncNow()` + that no-ops when a pass is already in flight. UI-level cadence + the + scenePhase/tab-visibility state machine + the entry kick are covered by a manual + two-sim e2e — these are SwiftUI-lifecycle/wall-clock timing properties the + simulator harness can't assert deterministically; the FFI set-interval/sync-now + round-trip is unit-tested Rust-side. + +### 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 + `INTEROP_DESK_CHECK.md`):** 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 — evidence sources + +- [`INTEROP_DESK_CHECK.md`](./INTEROP_DESK_CHECK.md) — + cross-client (iOS DashSync / Android dashj) interop evidence + testnet census. +- [`CONTACTINFO_FORMAT_SPEC.md` Appendix A](./CONTACTINFO_FORMAT_SPEC.md) — + contactInfo wire conventions (this repo sets the de-facto convention). + +The transient working-research files (DIP paraphrase, SDK/contract survey with +worktree-relative file:line citations) were trimmed from the tree; find them in +this branch's git history under `docs/dashpay/research/`. diff --git a/docs/dashpay/SYNC_CORRECTNESS_SPEC.md b/docs/dashpay/SYNC_CORRECTNESS_SPEC.md new file mode 100644 index 00000000000..4b8ab6b4a87 --- /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, since a device-local + ignore/blocklist and activity residue should not sync to backup. + +## 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/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 92bf4a4e583..d6e43bcba24 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -3335,8 +3335,8 @@ mod tests { use dashcore::secp256k1::{ ecdsa, rand::rngs::OsRng, Message, PublicKey, Secp256k1, SecretKey, }; - use key_wallet::bip32::DerivationPath; - use key_wallet::signer::{Signer as KwSigner, SignerMethod}; + use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; + use key_wallet::signer::{ExtendedPubKeySigner, Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory signer used only by this test. Mirrors how a /// real KeychainSigner would behave: derive once, sign atomically, @@ -3372,6 +3372,16 @@ mod tests { } } + #[async_trait] + impl ExtendedPubKeySigner for FixedKeySigner { + async fn extended_public_key( + &self, + _path: &DerivationPath, + ) -> Result { + Err("FixedKeySigner does not derive extended public keys".to_string()) + } + } + // Generate a single random key. Using the same key on both sides is // load-bearing: the legacy path signs raw bytes, the signer path // derives + signs inside the trust boundary. If the digest pre-image diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs index 67f95088409..8719b18ea90 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs @@ -204,8 +204,8 @@ async fn try_from_asset_lock_with_signer_and_private_key_signs_multiple_inputs() async fn try_from_asset_lock_with_signers_produces_matching_signature() { use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message}; - use key_wallet::bip32::DerivationPath; - use key_wallet::signer::{Signer as KwSigner, SignerMethod}; + use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; + use key_wallet::signer::{ExtendedPubKeySigner, Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how the /// Swift KeychainSigner behaves: derive once, sign atomically. Path @@ -239,6 +239,16 @@ async fn try_from_asset_lock_with_signers_produces_matching_signature() { } } + #[async_trait] + impl ExtendedPubKeySigner for FixedKeySigner { + async fn extended_public_key( + &self, + _path: &DerivationPath, + ) -> Result { + Err("FixedKeySigner does not derive extended public keys".to_string()) + } + } + let secp = Secp256k1::new(); let asset_lock_secret = RawSecretKey::from_byte_array(&[7u8; 32]).expect("valid secret"); let asset_lock_public = RawPublicKey::from_secret_key(&secp, &asset_lock_secret); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index c31e3b1fc1e..719074e382e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -33,8 +33,8 @@ use platform_version::version::PlatformVersion; use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1, SecretKey}; -use key_wallet::bip32::DerivationPath; -use key_wallet::signer::{Signer as KwSigner, SignerMethod}; +use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; +use key_wallet::signer::{ExtendedPubKeySigner, Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how a /// Swift KeychainSigner behaves: derive once, sign atomically. Path @@ -79,6 +79,16 @@ impl KwSigner for FixedKeySigner { } } +#[async_trait] +impl ExtendedPubKeySigner for FixedKeySigner { + async fn extended_public_key( + &self, + _path: &DerivationPath, + ) -> Result { + Err("FixedKeySigner does not derive extended public keys".to_string()) + } +} + fn make_chain_asset_lock_proof() -> AssetLockProof { AssetLockProof::Chain(ChainAssetLockProof { core_chain_locked_height: 100, 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..9f281be8c60 --- /dev/null +++ b/packages/rs-platform-encryption/src/account_label.rs @@ -0,0 +1,184 @@ +//! DIP-15 DashPay `encryptedAccountLabel` encryption. + +use crate::aes::{decrypt_aes_256_cbc, encrypt_aes_256_cbc}; +use crate::error::CryptoError; + +/// Minimum label length in characters before encryption. DIP-15 fixes +/// `encryptedAccountLabel` at ≥48 bytes (16-byte IV + ≥32-byte AES-CBC +/// ciphertext); a plaintext shorter than 16 bytes encrypts to a single 16-byte +/// block (48 bytes total only at exactly 16). kotlin/dashj pad the label to +/// ≥16 chars with trailing spaces so even a short or empty label clears the +/// floor, and strip the padding on read. Matching this is required for a valid +/// document and for cross-client interop. (≥16 chars ⟹ ≥16 bytes, since each +/// char is ≥1 byte, so this guarantees the ciphertext clears 48 bytes.) +const ACCOUNT_LABEL_MIN_CHARS: usize = 16; + +/// Maximum plaintext length in bytes after padding. DIP-15 caps +/// `encryptedAccountLabel` at 80 bytes (16-byte IV + ≤64-byte ciphertext); via +/// PKCS7, ≤64 ciphertext bytes means ≤63 plaintext bytes. A longer label is +/// truncated (on a char boundary) so no host-supplied string — however long — +/// can push the ciphertext past the contract's cap and error the broadcast. +/// kotlin/dashj likewise bound the label. +const ACCOUNT_LABEL_MAX_BYTES: usize = 63; + +/// Normalize `label` to the DIP-15 plaintext bounds: pad to +/// ≥[`ACCOUNT_LABEL_MIN_CHARS`] chars with trailing spaces (the floor; ≥16 +/// chars ⟹ ≥16 bytes), then truncate to ≤[`ACCOUNT_LABEL_MAX_BYTES`] bytes on a +/// char boundary (the ceiling). The result is always 16..=63 plaintext bytes, +/// so the encrypted field is always 48..=80 bytes for any input. Mirrors +/// kotlin's `padEnd(16, ' ')` plus a length cap. +fn fit_account_label(label: &str) -> String { + // Floor: pad short labels to ≥16 chars. + let deficit = ACCOUNT_LABEL_MIN_CHARS.saturating_sub(label.chars().count()); + let padded = if deficit == 0 { + label.to_string() + } else { + let mut s = String::with_capacity(label.len() + deficit); + s.push_str(label); + s.extend(std::iter::repeat_n(' ', deficit)); + s + }; + + // Ceiling: truncate long labels to ≤63 bytes on a char boundary. (Padding + // only ever reaches 16 chars ≤ 63 bytes, so truncation only fires on a + // genuinely long input, and the truncated prefix is still ≥16 bytes.) + if padded.len() <= ACCOUNT_LABEL_MAX_BYTES { + return padded; + } + let mut end = ACCOUNT_LABEL_MAX_BYTES; + while !padded.is_char_boundary(end) { + end -= 1; + } + padded[..end].to_string() +} + +/// Encrypt an account label for DashPay (DIP-15) +/// +/// The label is normalized to the DIP-15 plaintext bounds before encryption +/// (see [`fit_account_label`]): short labels are space-padded so the output +/// clears the 48-byte floor, and over-long labels are truncated so it never +/// exceeds the 80-byte cap. The output is therefore always a valid +/// `encryptedAccountLabel` for any input. [`decrypt_account_label`] strips the +/// padding. +/// +/// # 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 fitted = fit_account_label(label); + let encrypted_data = encrypt_aes_256_cbc(shared_key, iv, fitted.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) +/// +/// Trailing spaces — the padding [`encrypt_account_label`] adds to clear the +/// 48-byte floor — are stripped, recovering the original label. (A label that +/// intentionally ended in spaces cannot round-trip those spaces; this is +/// inherent to the DIP-15 space-padding convention and matches kotlin/dashj.) +/// +/// # 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)?; + let label = String::from_utf8(decrypted).map_err(|_| CryptoError::InvalidUtf8)?; + Ok(label.trim_end_matches(' ').to_string()) +} + +#[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); + } + + /// DIP-15 fixes `encryptedAccountLabel` at 48..=80 bytes, and the primitive + /// must produce a valid field for ANY input. Short/empty/multi-byte labels + /// are padded to clear the 48-byte floor and round-trip exactly (padding + /// stripped on decrypt); the exactly-16-char case sits on the lower edge; + /// over-long labels are truncated to stay under the 80-byte cap (and so do + /// not round-trip in full — the documented trade-off). The original bug: + /// a <16-char label produced a 32-byte blob and a ≥64-byte label a 96-byte + /// blob, either of which the contract rejects and fails the broadcast. + #[test] + fn account_label_is_always_a_valid_48_to_80_byte_field() { + let key = [0x42u8; 32]; + let iv = [0x11u8; 16]; + + // Floor + exact round-trip for short / empty / multi-byte labels. + for label in ["", "hi", "lunch fund", "café ☕", "🍕🍕🍕"] { + let blob = encrypt_account_label(&key, &iv, label); + assert!( + (48..=80).contains(&blob.len()), + "label {label:?} -> {} bytes, expected 48..=80", + blob.len() + ); + assert_eq!(decrypt_account_label(&key, &blob).unwrap(), label); + } + + // Lower boundary: exactly 16 ASCII chars = 16 bytes -> 32 ciphertext + 16 + // IV = exactly 48. + assert_eq!( + encrypt_account_label(&key, &iv, "sixteen-chars-16").len(), + 48 + ); + + // Ceiling: an over-long label (ASCII and multi-byte) must never exceed 80. + assert!(encrypt_account_label(&key, &iv, &"x".repeat(500)).len() <= 80); + assert!(encrypt_account_label(&key, &iv, &"🍕".repeat(50)).len() <= 80); + } +} 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..82f6a2b7324 --- /dev/null +++ b/packages/rs-platform-encryption/src/account_reference.rs @@ -0,0 +1,177 @@ +//! 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..ecade1d7fd9 100644 --- a/packages/rs-platform-wallet-ffi/src/contact.rs +++ b/packages/rs-platform-wallet-ffi/src/contact.rs @@ -12,10 +12,15 @@ pub unsafe extern "C" fn managed_identity_get_sent_contact_request_ids( out_array: *mut IdentifierArray, ) -> PlatformWalletFFIResult { check_ptr!(out_array); + // Sentinel first: the handle lookup below is fallible, and + // `platform_wallet_identifier_array_free` reconstructs a `Vec` from any + // non-null pointer/count pair — see `IdentifierArray::empty`. + unsafe { *out_array = IdentifierArray::empty() }; let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { identity - .sent_contact_requests + .dashpay() + .sent_contact_requests() .keys() .cloned() .collect::>() @@ -32,10 +37,13 @@ pub unsafe extern "C" fn managed_identity_get_incoming_contact_request_ids( out_array: *mut IdentifierArray, ) -> PlatformWalletFFIResult { check_ptr!(out_array); + // Sentinel first — see `IdentifierArray::empty`. + unsafe { *out_array = IdentifierArray::empty() }; let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { identity - .incoming_contact_requests + .dashpay() + .incoming_contact_requests() .keys() .cloned() .collect::>() @@ -52,10 +60,13 @@ pub unsafe extern "C" fn managed_identity_get_established_contact_ids( out_array: *mut IdentifierArray, ) -> PlatformWalletFFIResult { check_ptr!(out_array); + // Sentinel first — see `IdentifierArray::empty`. + unsafe { *out_array = IdentifierArray::empty() }; let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { identity - .established_contacts + .dashpay() + .established_contacts() .keys() .cloned() .collect::>() @@ -78,7 +89,7 @@ pub unsafe extern "C" fn managed_identity_is_contact_established( let id = unwrap_result_or_return!(unsafe { read_identifier(contact_id) }); let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { - identity.established_contacts.contains_key(&id) + identity.dashpay().established_contacts().contains_key(&id) }); *out_is_established = unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() @@ -97,9 +108,12 @@ pub unsafe extern "C" fn managed_identity_send_contact_request( let request = unwrap_option_or_return!(request_result); let option = MANAGED_IDENTITY_STORAGE.with_item_mut(identity_handle, |identity| { - identity.add_sent_contact_request(request, &ffi_noop_persister()); + // Return the persist result so a failure surfaces through the FFI + // result instead of being swallowed — correct for any persister on this + // handle path (today the infallible `ffi_noop_persister`). + identity.add_sent_contact_request(request, &ffi_noop_persister()) }); - unwrap_option_or_return!(option); + unwrap_result_or_return!(unwrap_option_or_return!(option)); PlatformWalletFFIResult::ok() } @@ -116,33 +130,37 @@ pub unsafe extern "C" fn managed_identity_accept_contact_request( let request = unwrap_option_or_return!(request_result); let option = MANAGED_IDENTITY_STORAGE.with_item_mut(identity_handle, |identity| { - identity.add_incoming_contact_request(request, &ffi_noop_persister()); + // Return the persist result so a failure surfaces through the FFI + // result instead of being swallowed — correct for any persister on this + // handle path (today the infallible `ffi_noop_persister`). + identity.add_incoming_contact_request(request, &ffi_noop_persister()) }); - unwrap_option_or_return!(option); + unwrap_result_or_return!(unwrap_option_or_return!(option)); 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() + // `ignore_sender` returns a `ContactChangeSet`, not a `Result` — there is + // no error to surface. This handle has no persister, so the changeset is + // intentionally dropped; the durable `platform_wallet_ignore_contact_sender` + // path persists it. + drop(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..d44392f94be --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/contact_info.rs @@ -0,0 +1,119 @@ +//! 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 + .dashpay() + .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..d0b79103821 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -101,6 +101,54 @@ 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, + /// The contact's decrypted DIP-15 `encryptedAccountLabel` — the label + /// the contact chose for the account they shared. Heap-allocated + /// NUL-terminated UTF-8, or null when unset. + /// + /// Unlike [`Self::alias`]/[`Self::note`] (owner-private, symmetric + /// relationship metadata replicated onto both rows), this is + /// **direction-specific**: it is the *contact's* label, decrypted from + /// their incoming request, so it is stamped **only on the incoming + /// row** (the outgoing row carries a label *we* chose, which is not + /// surfaced) and is null on the outgoing and pending rows. Released by + /// [`free_contact_requests_ffi`]. + pub contact_account_label: *const std::os::raw::c_char, + /// Heap-allocated copy of `EstablishedContact::accepted_accounts` + /// (DIP-15 rotated-account acceptances), or `null` when empty. Like + /// [`Self::payment_channel_broken`]/[`Self::alias`]/[`Self::note`] this is a + /// property of the relationship, so it is replicated onto BOTH the outgoing + /// and incoming established rows; always `null` for pending + /// `sent_requests` / `incoming_requests` rows. Released by + /// [`free_contact_requests_ffi`]. + pub accepted_accounts: *const u32, + /// Number of `u32` entries in [`Self::accepted_accounts`]; `0` when the + /// pointer is null. + pub accepted_accounts_len: usize, } /// Composite identifier for [`ContactChangeSet::removed_sent`] and @@ -121,6 +169,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 +219,52 @@ 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 (padding to 8) +// 176..=183 contact_account_label *const c_char +// 184..=191 accepted_accounts *const u32 +// 192..=199 accepted_accounts_len usize // -// Total size = 144, alignment = 8 (from u64 / pointer fields). -const _: [u8; 144] = [0u8; std::mem::size_of::()]; +// Total size = 200, alignment = 8 (from u64 / pointer fields). +const _: [u8; 200] = [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 +286,18 @@ 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, + None, + &[], + ) } /// Sibling of [`Self::from_outgoing`] for the incoming direction @@ -183,14 +307,97 @@ 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, + None, + &[], + ) } + /// Build the **outgoing** row of an established contact, stamping + /// the relationship's `payment_channel_broken` flag, the owner-private + /// metadata (alias / note / hidden — contactInfo, M3), and the DIP-15 + /// `accepted_accounts` 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, + accepted_accounts: &[u32], + ) -> Self { + Self::from_parts( + owner_id, + contact_id, + true, + request, + payment_channel_broken, + alias, + note, + is_hidden, + // The outgoing row never carries the contact's account label — + // it is direction-specific (incoming-only). + None, + accepted_accounts, + ) + } + + /// Sibling of [`Self::from_established_outgoing`] for the **incoming** + /// row of an established contact. Carries the contact's decrypted + /// account label (`contact_account_label`) — the one direction that + /// surfaces it. + #[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, + contact_account_label: Option<&str>, + accepted_accounts: &[u32], + ) -> Self { + Self::from_parts( + owner_id, + contact_id, + false, + request, + payment_channel_broken, + alias, + note, + is_hidden, + contact_account_label, + accepted_accounts, + ) + } + + #[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, + contact_account_label: Option<&str>, + accepted_accounts: &[u32], ) -> Self { let (encrypted_public_key, encrypted_public_key_len) = allocate_byte_buffer(&request.encrypted_public_key); @@ -204,6 +411,7 @@ impl ContactRequestFFI { Some(bytes) => allocate_byte_buffer(bytes), None => (ptr::null(), 0), }; + let (accepted_accounts, accepted_accounts_len) = allocate_u32_buffer(accepted_accounts); Self { owner_id, contact_id, @@ -219,10 +427,39 @@ 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, + contact_account_label: allocate_c_string(contact_account_label), + accepted_accounts, + accepted_accounts_len, } } } +/// 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 @@ -236,6 +473,18 @@ fn allocate_byte_buffer(bytes: &[u8]) -> (*const u8, usize) { (Box::into_raw(boxed) as *const u8, len) } +/// `u32` sibling of [`allocate_byte_buffer`] for +/// [`ContactRequestFFI::accepted_accounts`]. Empty slices return `(null, 0)`; +/// released by [`free_u32_buffer`]. +fn allocate_u32_buffer(values: &[u32]) -> (*const u32, usize) { + if values.is_empty() { + return (ptr::null(), 0); + } + let boxed: Box<[u32]> = values.to_vec().into_boxed_slice(); + let len = boxed.len(); + (Box::into_raw(boxed) as *const u32, len) +} + // --------------------------------------------------------------------------- // Destructors // --------------------------------------------------------------------------- @@ -271,6 +520,13 @@ 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); + free_c_string(&mut entry.contact_account_label); + free_u32_buffer( + &mut entry.accepted_accounts, + &mut entry.accepted_accounts_len, + ); } } @@ -285,6 +541,18 @@ fn free_byte_buffer(slot: &mut *const u8, len_slot: &mut usize) { *len_slot = 0; } +/// `u32` sibling of [`free_byte_buffer`] for +/// [`ContactRequestFFI::accepted_accounts`]. Idempotent on null / zero-length +/// slots. +fn free_u32_buffer(slot: &mut *const u32, len_slot: &mut usize) { + if !slot.is_null() && *len_slot > 0 { + let slice = unsafe { std::slice::from_raw_parts_mut(*slot as *mut u32, *len_slot) }; + let _ = unsafe { Box::from_raw(slice as *mut [u32]) }; + } + *slot = ptr::null(); + *len_slot = 0; +} + // --------------------------------------------------------------------------- // Callback signature // --------------------------------------------------------------------------- @@ -305,6 +573,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 +593,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 +642,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 +667,157 @@ 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, + None, + &[], + ); + 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()); + } + + /// The contact's decrypted account label is **direction-specific**: + /// unlike `payment_channel_broken`/`alias`/`note` (symmetric, both + /// rows), it is stamped ONLY on the incoming row (the contact's label) + /// and is null on the outgoing row (which would carry a label *we* + /// sent). Pins that the projection keeps the two apart, so a Swift + /// `@Query` row never mistakes our own label for the contact's. + #[test] + fn established_incoming_row_carries_account_label_outgoing_is_null() { + let request = sample_request(); + let owner = [3u8; 32]; + let contact = [4u8; 32]; + + let mut out = ContactRequestFFI::from_established_outgoing( + owner, + contact, + &request, + false, + None, + None, + false, + &[], + ); + let mut inc = ContactRequestFFI::from_established_incoming( + owner, + contact, + &request, + false, + None, + None, + false, + Some("Main wallet"), + &[], + ); + + assert!( + out.contact_account_label.is_null(), + "the outgoing row must NOT carry the contact's account label" + ); + let label = unsafe { std::ffi::CStr::from_ptr(inc.contact_account_label) }; + assert_eq!( + label.to_str().unwrap(), + "Main wallet", + "the incoming row must carry the contact's decrypted account label" + ); + + unsafe { + free_contact_requests_ffi(&mut out as *mut ContactRequestFFI, 1); + free_contact_requests_ffi(&mut inc as *mut ContactRequestFFI, 1); + } + assert!( + inc.contact_account_label.is_null(), + "free must reclaim + null the account label" + ); + } + + /// `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/contact_request.rs b/packages/rs-platform-wallet-ffi/src/contact_request.rs index eb70298acc5..433adc5d014 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_request.rs @@ -66,7 +66,7 @@ pub unsafe extern "C" fn managed_identity_get_sent_contact_request( let id = unwrap_result_or_return!(unsafe { read_identifier(recipient_id) }); let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { - identity.sent_contact_requests.get(&id).cloned() + identity.dashpay().sent_contact_requests().get(&id).cloned() }); let option = unwrap_option_or_return!(option); let request = unwrap_option_or_return!(option); @@ -87,7 +87,11 @@ pub unsafe extern "C" fn managed_identity_get_incoming_contact_request( let id = unwrap_result_or_return!(unsafe { read_identifier(sender_id) }); let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { - identity.incoming_contact_requests.get(&id).cloned() + identity + .dashpay() + .incoming_contact_requests() + .get(&id) + .cloned() }); let inner = unwrap_option_or_return!(option); let request = unwrap_option_or_return!(inner); @@ -174,6 +178,14 @@ pub unsafe extern "C" fn contact_request_get_encrypted_public_key( ) -> PlatformWalletFFIResult { check_ptr!(out_bytes); check_ptr!(out_len); + // Sentinel first: the handle lookup below is fallible, and + // `platform_wallet_bytes_free` reconstructs a `Vec` from any non-null + // pointer / non-zero length pair — a cleanup-on-error caller must never + // see stack garbage here. + unsafe { + *out_bytes = std::ptr::null_mut(); + *out_len = 0; + } let option = CONTACT_REQUEST_STORAGE.with_item(request_handle, |request| { request.encrypted_public_key.clone() diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 5ab9f0ed543..785ee47db52 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::*; @@ -172,10 +173,16 @@ pub unsafe extern "C" fn platform_wallet_sync_contact_requests( out_array: *mut ContactRequestHandleArray, ) -> PlatformWalletFFIResult { check_ptr!(out_array); + // Publish an FFI-safe sentinel before any fallible work so every early + // return leaves `*out_array` well-defined — a caller that runs symmetric + // cleanup-on-error then feeds an empty (null, 0) array into + // `platform_wallet_contact_request_handle_array_free`, never stale stack + // bytes. + unsafe { *out_array = ContactRequestHandleArray::empty() }; let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity = wallet.identity().clone(); - block_on_worker(async move { identity.sync_contact_requests().await }) + block_on_worker(async move { identity.dashpay().sync_contact_requests().await }) }); let result = unwrap_option_or_return!(option); let list = unwrap_result_or_return!(result); @@ -204,12 +211,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 +230,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)); @@ -235,18 +247,48 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( let proof: Option> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 { None } else { + // Defense-in-depth: bound the copy at the DIP-15 auto-accept proof + // ceiling (102 bytes) BEFORE allocating, so a malformed binding or a + // hostile (ptr, len) pair can't force an oversized allocation. The SDK + // re-validates the exact 38..=102 range after this. + if auto_accept_proof_len > 102 { + return crate::error::PlatformWalletFFIResult::err( + crate::error::PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "auto_accept_proof length {auto_accept_proof_len} exceeds the DIP-15 maximum of 102 bytes" + ), + ); + } Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec()) }; 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 + .dashpay() .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 +299,64 @@ 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 + .dashpay() + .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 +368,51 @@ 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) + .dashpay() + .accept_contact_request_with_external_signer(&request, signer, &provider) .await }) }); @@ -301,17 +423,50 @@ 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_ignore_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 + .dashpay() + .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_reject_contact_request( +pub unsafe extern "C" fn platform_wallet_unignore_contact_sender( wallet_handle: Handle, our_identity_id: *const u8, contact_identity_id: *const u8, @@ -321,7 +476,12 @@ 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 + .dashpay() + .unignore_contact_sender(&our_id, &contact_id) + .await + }) }); let result = unwrap_option_or_return!(option); unwrap_result_or_return!(result); @@ -340,11 +500,15 @@ pub unsafe extern "C" fn platform_wallet_fetch_sent_contact_requests( out_array: *mut ContactRequestHandleArray, ) -> PlatformWalletFFIResult { check_ptr!(out_array); + // Sentinel first: identifier parsing and the gRPC query below are both + // fallible, so publish an empty array before them to keep every early + // return FFI-safe for a cleanup-on-error caller. + unsafe { *out_array = ContactRequestHandleArray::empty() }; let id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) }); let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity = wallet.identity().clone(); - block_on_worker(async move { identity.sent_contact_requests(&id).await }) + block_on_worker(async move { identity.dashpay().sent_contact_requests(&id).await }) }); let result = unwrap_option_or_return!(option); let list = unwrap_result_or_return!(result); @@ -357,15 +521,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 +554,40 @@ 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, + ) + }; + // Same resolver handle, wrapped as a `ContactCryptoProvider`, so + // `send_payment` can drain a deferred external-account build for this + // contact before resolving the account. Same lifetime contract as the + // signer above (no new FFI surface). + let provider = unsafe { + resolver_contact_crypto_provider( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; block_on_worker(async move { identity - .send_payment(&from_id, &to_id, amount_duffs, memo_str) + .dashpay() + .send_payment(&from_id, &to_id, amount_duffs, memo_str, &signer, &provider) .await }) }); @@ -392,3 +600,418 @@ 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::ExtendedPubKeySigner; + 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, platform_wallet::PlatformWalletError> { + self.signer + .ecdh_shared_secret(path, peer) + .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) is **optional**: pass null to +/// run only the provider-derived ops (account build / contactInfo decrypt) and +/// skip the auto-accept pass; pass a valid, non-destroyed `*mut SignerHandle` +/// to also auto-accept proof-bearing inbound requests. +/// - `core_signer_handle` (the wallet-HD resolver) must be a valid, non-destroyed +/// handle. The caller retains ownership of both 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!(core_signer_handle); + check_ptr!(out_drained); + + // The identity signer is optional — null means "provider-only drain". + let signer_addr = if signer_handle.is_null() { + 0usize + } else { + 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 + .dashpay() + .drain_pending_contact_crypto(&provider) + .await; + // The auto-accept pass needs the identity signer for the reciprocal; + // skip it when no identity signer was supplied. + let accepted = if signer_addr != 0 { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity + .dashpay() + .drain_auto_accepts(signer, &provider) + .await + } else { + 0 + }; + 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.dashpay().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 +/// the identity `owner_identity_id`, valid for 1 hour. The QR's `du` is the +/// owner's DPNS name (a scanner resolves it back to the owner's identity). +/// `username` is the locally-cached name when available; pass an **empty** C +/// string (or one resolving to empty) to resolve the name on-chain instead — +/// needed for imported/restored identities whose name isn't cached locally. +/// 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 +/// - `owner_identity_id` must point to 32 readable bytes. +/// - `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, + owner_identity_id: *const u8, + 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); + // Zero-init the out-param before any fallible work so an early return + // (bad id, bad UTF-8, missing wallet, async failure, interior-NUL URI) + // leaves a safe null rather than uninitialized memory the caller might + // free — same discipline as the profile getters. + unsafe { *out_uri = std::ptr::null_mut() }; + + // `read_identifier` null-checks the pointer (matches the sibling QR FFIs). + let owner = unwrap_result_or_return!(read_identifier(owner_identity_id)); + 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 + .dashpay() + .build_auto_accept_qr(&owner, &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 = std::ptr::dangling_mut::(); + 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..0121af9fac2 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dashpay_payment.rs @@ -0,0 +1,349 @@ +//! FFI getter for per-contact DashPay payment history. +//! +//! Swift's `ContactDetailView` renders a payment list per contact +//! (`PaymentEntry` on the managed identity's `DashPayState.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 + // payments map is an open-tier cache, 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_mut().insert( + "aa".repeat(32), + PaymentEntry::new_sent(Identifier::from([1u8; 32]), 12_000, Some("lunch".into())), + ); + managed.dashpay_payments_mut().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..6c83f4d459e 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); } @@ -92,9 +94,18 @@ pub unsafe extern "C" fn managed_identity_get_dashpay_profile( ) -> PlatformWalletFFIResult { check_ptr!(out_profile); check_ptr!(out_has_profile); + // Zero-init the out-params before any fallible work so an early return + // (bad id, missing wallet) leaves a safe all-null struct rather than + // uninitialized memory — `DashPayProfileFFI` owns C-string pointers a + // caller might otherwise free on the error path. + unsafe { + *out_profile = DashPayProfileFFI::empty(); + *out_has_profile = false; + } - let option = MANAGED_IDENTITY_STORAGE - .with_item(identity_handle, |identity| identity.dashpay_profile.clone()); + let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { + identity.dashpay().profile.clone() + }); let profile_opt = unwrap_option_or_return!(option); match profile_opt { Some(profile) => unsafe { @@ -109,6 +120,73 @@ pub unsafe extern "C" fn managed_identity_get_dashpay_profile( PlatformWalletFFIResult::ok() } +/// Live in-memory DashPay sync state for a [`ManagedIdentity`] — the collection +/// counts plus the high-water sync cursors. All scalars (no heap), so no free +/// is needed. The cursors are NOT persisted (they reset to `None` on cold +/// restart), so reading the live handle is the only way to inspect them; the +/// counts let a debugger compare the in-memory state against the persisted +/// SwiftData rows. +#[repr(C)] +pub struct DashPaySyncStateFFI { + pub established_contacts: u32, + pub incoming_requests: u32, + pub sent_requests: u32, + pub ignored_senders: u32, + /// Total cached contact-profile entries (present + negative-cache). + pub contact_profiles: u32, + /// Of `contact_profiles`, how many hold a present profile (the rest are + /// confirmed-absent negative-cache entries). + pub present_contact_profiles: u32, + pub dashpay_payments: u32, + pub has_dashpay_profile: bool, + pub has_high_water_received: bool, + pub high_water_received_ms: u64, + pub has_high_water_sent: bool, + pub high_water_sent_ms: u64, +} + +/// Read the live [`ManagedIdentity`] DashPay sync state — see +/// [`DashPaySyncStateFFI`]. The cursors are in-memory only (not persisted). +#[no_mangle] +pub unsafe extern "C" fn managed_identity_get_dashpay_sync_state( + identity_handle: Handle, + out_state: *mut DashPaySyncStateFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_state); + let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { + let (has_received, received) = match identity.dashpay().high_water_received_ms() { + Some(v) => (true, v), + None => (false, 0), + }; + let (has_sent, sent) = match identity.dashpay().high_water_sent_ms() { + Some(v) => (true, v), + None => (false, 0), + }; + DashPaySyncStateFFI { + established_contacts: identity.dashpay().established_contacts().len() as u32, + incoming_requests: identity.dashpay().incoming_contact_requests().len() as u32, + sent_requests: identity.dashpay().sent_contact_requests().len() as u32, + ignored_senders: identity.dashpay().ignored_senders().len() as u32, + contact_profiles: identity.dashpay().contact_profiles.len() as u32, + present_contact_profiles: identity + .dashpay() + .contact_profiles + .values() + .filter(|e| e.profile.is_some()) + .count() as u32, + dashpay_payments: identity.dashpay().payments.len() as u32, + has_dashpay_profile: identity.dashpay().profile.is_some(), + has_high_water_received: has_received, + high_water_received_ms: received, + has_high_water_sent: has_sent, + high_water_sent_ms: sent, + } + }); + let state = unwrap_option_or_return!(option); + unsafe { *out_state = state }; + PlatformWalletFFIResult::ok() +} + /// Read the cached DashPay profile for a specific identity owned by a wallet. #[no_mangle] pub unsafe extern "C" fn platform_wallet_get_dashpay_profile( @@ -119,17 +197,80 @@ pub unsafe extern "C" fn platform_wallet_get_dashpay_profile( ) -> PlatformWalletFFIResult { check_ptr!(out_profile); check_ptr!(out_has_profile); + // Zero-init the out-params before any fallible work so an early return + // (bad id, missing wallet) leaves a safe all-null struct rather than + // uninitialized memory — `DashPayProfileFFI` owns C-string pointers a + // caller might otherwise free on the error path. + unsafe { + *out_profile = DashPayProfileFFI::empty(); + *out_has_profile = false; + } let id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) }); + // Clone only the `Option` field, not the whole + // `ManagedIdentity` (which carries the full Identity plus the + // established/sent/incoming BTreeMaps and payment history). The two + // unwraps preserve the caller contract unchanged: a missing wallet or + // identity is a NotFound error, a present identity with no profile is a + // successful read with `has_profile == false`. 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(&id).cloned() + let managed = info.identity_manager.managed_identity(&id)?; + Some(managed.dashpay().profile.clone()) }); let inner = unwrap_option_or_return!(option); - let managed = unwrap_option_or_return!(inner); - match managed.dashpay_profile { + let profile = unwrap_option_or_return!(inner); + match 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() +} + +/// 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); + // Zero-init the out-params before any fallible work so an early return + // (bad id, missing wallet) leaves a safe all-null struct rather than + // uninitialized memory — `DashPayProfileFFI` owns C-string pointers a + // caller might otherwise free on the error path. + unsafe { + *out_profile = DashPayProfileFFI::empty(); + *out_has_profile = false; + } + + 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.dashpay().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; @@ -166,7 +307,7 @@ pub unsafe extern "C" fn platform_wallet_sync_dashpay_profiles( ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity = wallet.identity().clone(); - block_on_worker(async move { identity.sync_profiles().await }) + block_on_worker(async move { identity.dashpay().sync_profiles().await }) }); let result = unwrap_option_or_return!(option); let count = unwrap_result_or_return!(result); @@ -193,6 +334,11 @@ pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_s ) -> PlatformWalletFFIResult { check_ptr!(out_profile); check_ptr!(signer_handle); + // `DashPayProfileFFI` owns heap C-string pointer fields freed by + // `dashpay_profile_ffi_free`; publish the empty sentinel before any + // fallible work so an error path never leaves uninitialized stack bytes + // in those pointer fields. Matches the read-side helpers in this file. + *out_profile = DashPayProfileFFI::empty(); let id = unwrap_result_or_return!(read_identifier(identity_id)); @@ -221,10 +367,12 @@ pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_s let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); if do_create { identity + .dashpay() .create_profile_with_external_signer(&id, input, signer) .await } else { identity + .dashpay() .update_profile_with_external_signer(&id, input, signer) .await } @@ -282,7 +430,7 @@ mod tests { let mut managed = platform_wallet::ManagedIdentity::new(make_test_identity(), 0); let mut hash = [0u8; 32]; hash[0] = 0xAB; - managed.dashpay_profile = Some(DashPayProfile { + *managed.dashpay_profile_mut() = Some(DashPayProfile { display_name: Some("Alice".to_string()), bio: Some("Bio text".to_string()), avatar_url: Some("https://example.com/a.png".to_string()), 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..7bcdc34fe76 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dashpay_sync.rs @@ -0,0 +1,254 @@ +//! 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); + // Define the out-slot before the stale-handle early return below can fire, + // so the caller never reads uninitialized stack contents. + *out_running = false; + + 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); + *out_syncing = false; + + 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); + *out_last_sync_unix = 0; + + 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/dpns.rs b/packages/rs-platform-wallet-ffi/src/dpns.rs index fdfeb16a0d5..775d1668562 100644 --- a/packages/rs-platform-wallet-ffi/src/dpns.rs +++ b/packages/rs-platform-wallet-ffi/src/dpns.rs @@ -54,6 +54,11 @@ pub unsafe extern "C" fn platform_wallet_register_dpns_name_with_signer( ) -> PlatformWalletFFIResult { check_ptr!(name); check_ptr!(out_full_domain_name); + // Null the out-pointer before the fallible work below (identifier/name + // parsing, the async registration) so an error return never leaves it + // holding stack garbage for a cleanup-on-error caller to + // `platform_wallet_string_free`. + unsafe { *out_full_domain_name = std::ptr::null_mut() }; check_ptr!(signer_handle); let id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) }); diff --git a/packages/rs-platform-wallet-ffi/src/established_contact.rs b/packages/rs-platform-wallet-ffi/src/established_contact.rs index d38e9511ec5..13fefd13bd5 100644 --- a/packages/rs-platform-wallet-ffi/src/established_contact.rs +++ b/packages/rs-platform-wallet-ffi/src/established_contact.rs @@ -27,7 +27,8 @@ pub unsafe extern "C" fn managed_identity_get_established_contact( let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { identity - .established_contacts + .dashpay() + .established_contacts() .get(&contact_identifier) .cloned() }); @@ -158,6 +159,11 @@ pub unsafe extern "C" fn established_contact_get_note( out_note: *mut *mut std::os::raw::c_char, ) -> PlatformWalletFFIResult { check_ptr!(out_note); + // Null the out-pointer before the fallible lookup so a cleanup-on-error + // caller that unconditionally `platform_wallet_string_free`s the variable + // frees null, not garbage — matching the null-sentinel-first convention + // used across the rest of this FFI surface. + unsafe { *out_note = std::ptr::null_mut() }; let option = ESTABLISHED_CONTACT_STORAGE.with_item(contact_handle, |contact| contact.note.clone()); @@ -216,6 +222,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_manager.rs b/packages/rs-platform-wallet-ffi/src/identity_manager.rs index 95e472a46f3..0fe211ae4bc 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_manager.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_manager.rs @@ -103,6 +103,10 @@ pub unsafe extern "C" fn identity_manager_get_all_identity_ids( out_array: *mut IdentifierArray, ) -> PlatformWalletFFIResult { check_ptr!(out_array); + // Sentinel first: the handle lookup below is fallible, and + // `platform_wallet_identifier_array_free` reconstructs a `Vec` from any + // non-null pointer/count pair — see `IdentifierArray::empty`. + unsafe { *out_array = IdentifierArray::empty() }; let option = IDENTITY_MANAGER_STORAGE.with_item(manager_handle, |manager| manager.identity_ids()); diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 03065120b7b..644d1728229 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -141,16 +141,93 @@ 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, the `is_present` +/// discriminator, and the trailing `checked_at_ms` self-heal timestamp. +/// Confirmed-absent cache entries DO reach this struct, as +/// `is_present == false` tombstones (null strings / zeroed bytes) that +/// tell Swift to delete the persisted row. +/// +/// 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], + /// `true` for a present profile (all fields below are authoritative); + /// `false` for a confirmed-absent contact (every field below is + /// null/zeroed). An absent row tells Swift to DELETE any persisted row + /// for `contact_id` — a contact who removed their profile must not keep + /// showing a stale name/avatar. Without this, the persist side emits only + /// present profiles and the Swift upsert never learns about a deletion. + pub is_present: bool, + /// Heap-allocated `displayName`; `null` when the source field was + /// `None` (or the row is absent). 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. +/// No private material crosses here. For a key this wallet's seed reproduced +/// under discovery's verify gate, the client gets the +/// `(wallet_id, identity_index, key_index)` breadcrumb and derives the key on +/// demand from the Keychain seed at the DIP-9 path when it needs to sign; a +/// watch-only key carries no breadcrumb. `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 @@ -231,12 +308,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)]`): @@ -302,9 +381,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 +425,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 +449,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 +569,84 @@ 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 { + // Confirmed-absent entry: emit an `is_present = false` tombstone row so + // Swift DELETEs any persisted row for this contact. A contact who + // removed their profile (present -> absent) must not keep showing a + // stale name/avatar — skipping the entry would leave the old upserted + // row untouched forever. + let Some(profile) = entry.profile.as_ref() else { + rows.push(ContactProfileRowFFI { + contact_id: contact_id.to_buffer(), + is_present: false, + display_name: ptr::null(), + bio: ptr::null(), + avatar_url: ptr::null(), + avatar_hash: [0u8; 32], + avatar_hash_present: false, + avatar_fingerprint: [0u8; 8], + avatar_fingerprint_present: false, + public_message: ptr::null(), + checked_at_ms: entry.checked_at_ms, + }); + 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(), + is_present: true, + 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, + }); + } + // `rows` is non-empty here: the early return above covers the empty map, + // and every entry (present or absent) now pushes a row. + 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 @@ -581,9 +745,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 +810,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 +887,6 @@ 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. } #[cfg(test)] @@ -727,6 +915,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 +958,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 +1017,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); @@ -857,6 +1051,92 @@ mod tests { unsafe { free_identity_entry_ffi(&mut ffi) }; } + #[test] + fn contact_profile_rows_emit_present_and_absent_tombstone() { + use platform_wallet::{ContactProfileEntry, DashPayProfile}; + // [7;32] (present) sorts before [8;32] (absent) in the BTreeMap, so the + // emitted rows are in that order. + let mut contact_profiles = std::collections::BTreeMap::new(); + contact_profiles.insert( + Identifier::from([7u8; 32]), + ContactProfileEntry { + profile: Some(DashPayProfile { + display_name: Some("Carol".to_string()), + bio: None, + avatar_url: None, + avatar_hash: Some([0x11; 32]), + avatar_fingerprint: None, + public_message: None, + }), + checked_at_ms: 111, + }, + ); + contact_profiles.insert( + Identifier::from([8u8; 32]), + ContactProfileEntry { + profile: None, + checked_at_ms: 222, + }, + ); + let entry = IdentityEntry { + id: Identifier::from([9u8; 32]), + balance: 0, + revision: 0, + identity_index: Some(1), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Active, + wallet_id: None, + dashpay_profile: None, + dashpay_payments: Default::default(), + contact_profiles, + ignored_senders: Default::default(), + }; + let mut ffi = IdentityEntryFFI::from_entry(&entry); + + // A confirmed-absent entry now rides as an `is_present == false` + // tombstone instead of being dropped — both entries are projected. + assert_eq!(ffi.contact_profiles_count, 2); + let rows = + unsafe { std::slice::from_raw_parts(ffi.contact_profiles, ffi.contact_profiles_count) }; + + let present = &rows[0]; + assert_eq!(present.contact_id, [7u8; 32]); + assert!(present.is_present); + assert_eq!( + unsafe { std::ffi::CStr::from_ptr(present.display_name) } + .to_str() + .unwrap(), + "Carol" + ); + assert!(present.avatar_hash_present); + assert_eq!(present.avatar_hash, [0x11; 32]); + assert!(!present.avatar_fingerprint_present); + assert_eq!(present.checked_at_ms, 111); + + let absent = &rows[1]; + assert_eq!(absent.contact_id, [8u8; 32]); + assert!(!absent.is_present); + assert!(absent.display_name.is_null()); + assert!(absent.bio.is_null()); + assert!(absent.avatar_url.is_null()); + assert!(absent.public_message.is_null()); + assert!(!absent.avatar_hash_present); + assert!(!absent.avatar_fingerprint_present); + // The self-heal timestamp must still ride so Swift can delete the stale + // row and the negative-cache backoff is preserved. + assert_eq!(absent.checked_at_ms, 222); + + unsafe { free_identity_entry_ffi(&mut ffi) }; + assert!(ffi.contact_profiles.is_null()); + assert_eq!(ffi.contact_profiles_count, 0); + // Idempotent — second free (null strings on the tombstone included) + // must not double-free. + unsafe { free_identity_entry_ffi(&mut ffi) }; + } + #[test] fn test_identity_entry_ffi_no_wallet() { let entry = IdentityEntry { @@ -872,6 +1152,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); 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/managed_identity.rs b/packages/rs-platform-wallet-ffi/src/managed_identity.rs index 11a62760b8d..71e7c7544ee 100644 --- a/packages/rs-platform-wallet-ffi/src/managed_identity.rs +++ b/packages/rs-platform-wallet-ffi/src/managed_identity.rs @@ -70,10 +70,12 @@ pub unsafe extern "C" fn managed_identity_get_label( out_label: *mut *mut c_char, ) -> PlatformWalletFFIResult { check_ptr!(out_label); + // Null the out-pointer before the fallible handle lookup so even the + // invalid-handle error path leaves it well-defined. + unsafe { *out_label = std::ptr::null_mut() }; let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |_identity| ()); unwrap_option_or_return!(option); - unsafe { *out_label = std::ptr::null_mut() }; PlatformWalletFFIResult::ok() } @@ -196,6 +198,14 @@ pub unsafe extern "C" fn managed_identity_get_public_keys( ) -> PlatformWalletFFIResult { check_ptr!(out_keys); check_ptr!(out_count); + // Sentinel first: the handle lookup below is fallible, and + // `managed_identity_free_public_keys` reconstructs the array (and each + // entry's owned `data_ptr`) from any non-null pointer / non-zero count + // pair — a cleanup-on-error caller must never see stack garbage here. + unsafe { + *out_keys = std::ptr::null_mut(); + *out_count = 0; + } let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { let keys = identity.identity.public_keys(); @@ -228,10 +238,7 @@ pub unsafe extern "C" fn managed_identity_get_public_keys( let buf = unwrap_option_or_return!(option); if buf.is_empty() { - unsafe { - *out_keys = std::ptr::null_mut(); - *out_count = 0; - } + // Out-params already hold the (null, 0) sentinel written above. return PlatformWalletFFIResult::ok(); } let count = buf.len(); diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 5930c1c4db6..7e553a64bc0 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, )) diff --git a/packages/rs-platform-wallet-ffi/src/memory_explorer.rs b/packages/rs-platform-wallet-ffi/src/memory_explorer.rs index 7e20f7daed3..1650e9dcf70 100644 --- a/packages/rs-platform-wallet-ffi/src/memory_explorer.rs +++ b/packages/rs-platform-wallet-ffi/src/memory_explorer.rs @@ -67,6 +67,11 @@ pub unsafe extern "C" fn platform_wallet_list_in_memory_identity_ids( out_array: *mut IdentifierArray, ) -> PlatformWalletFFIResult { check_ptr!(out_array); + // Sentinel first: the handle lookup and wallet-info fetch below are both + // fallible, and `platform_wallet_identifier_array_free` reconstructs a + // `Vec` from any non-null pointer/count pair — see + // `IdentifierArray::empty`. + unsafe { *out_array = IdentifierArray::empty() }; let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let wm = wallet.wallet_manager().blocking_read(); @@ -92,6 +97,8 @@ pub unsafe extern "C" fn platform_wallet_list_in_memory_watched_identity_ids( out_array: *mut IdentifierArray, ) -> PlatformWalletFFIResult { check_ptr!(out_array); + // Sentinel first — see `IdentifierArray::empty`. + unsafe { *out_array = IdentifierArray::empty() }; let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let wm = wallet.wallet_manager().blocking_read(); diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 86b5561518c..578d6d2d46f 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; @@ -84,6 +85,13 @@ pub struct PersistenceCallbacks { /// accumulated writes (success → flush / save) or roll them /// back (failure → discard), making each Rust `store()` a /// single atomic transaction from the client's point of view. + /// + /// Returns 0 on success. A **non-zero** return means the commit + /// itself failed (e.g. the atomic `save()` threw and the staged + /// writes were rolled back); `store()` then returns `Err` so the + /// caller does not advance state against data that never reached + /// durable storage. (Unlike `on_changeset_begin_fn`, this return + /// is honored, not advisory.) pub on_changeset_end_fn: Option< unsafe extern "C" fn(context: *mut c_void, wallet_id: *const u8, success: bool) -> i32, >, @@ -272,8 +280,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 +309,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 +1067,35 @@ 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, + &established.accepted_accounts, )); - 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, + // Direction-specific: the contact's account label + // rides only the incoming row. + established.contact_account_label.as_deref(), + &established.accepted_accounts, )); } let removed_sent: Vec = contacts_cs @@ -1082,7 +1114,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 +1156,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 @@ -1449,6 +1506,16 @@ impl PlatformWalletPersistence for FFIPersister { let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), round_success) }; if result != 0 { eprintln!("Changeset-end callback returned error code {}", result); + // The end callback is where the client COMMITS the round (e.g. + // the SwiftData atomic `save()`). A non-zero return means the + // commit failed and the staged writes were rolled back — the + // round never reached durable storage. Treat it as a + // persistence failure so `store()` returns `Err` and the caller + // does NOT advance / clear its in-memory state (pending queues, + // cleared drain entries, ignored-sender deltas) against data + // that was dropped. Otherwise the failure is silent and the + // dropped writes resurface or are lost with no signal. + round_success = false; } } @@ -1458,7 +1525,9 @@ impl PlatformWalletPersistence for FFIPersister { )); } - // Merge into pending changesets. + // Merge into pending changesets. No secret rides the changeset any + // more — the client derives identity keys on demand from the Keychain + // seed at the breadcrumb path, so nothing here needs scrubbing. let mut pending = self.pending.write(); pending .entry(wallet_id) @@ -2422,6 +2491,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 +2518,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 +2731,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 +3490,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 +3694,386 @@ 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 the managed identity's ignored set. +/// 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.apply_ignored_sender(Identifier::from(*row)); + } +} + +/// Fold a slice of [`PaymentRestoreEntryFFI`] rows into +/// the managed identity's payments map. 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_mut().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 +/// the managed identity's contact-profile cache. 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.dashpay_contact_profiles_mut().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, +) { + if spec.contacts.is_null() || spec.contacts_count == 0 { + return; + } + let rows = slice::from_raw_parts(spec.contacts, spec.contacts_count); + apply_contact_rows(rows, owner_id, managed); +} + +/// Pair the per-direction [`ContactRequestFFI`] rows back into the +/// `ManagedIdentity`'s `sent` / `incoming` / `established` contact maps. +/// Extracted from [`restore_dashpay_contacts`] so the FFI→Rust decode is +/// unit-testable against rows built by the persist constructors (the other +/// restore families have the same `apply_*_rows` seam). The persist side is +/// tested field-by-field; this is the read-back half — where a dropped +/// optional or a swapped key index would otherwise be invisible. +/// +/// # Safety +/// Each row's `*_ptr`/`*_len` byte buffers and C strings must be valid for +/// the call (Swift owns them during load; tests own them via the persist +/// constructors). +unsafe fn apply_contact_rows( + rows: &[ContactRequestFFI], + owner_id: &Identifier, + managed: &mut ManagedIdentity, +) { + use platform_wallet::{ContactRequest, EstablishedContact}; + + /// 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, + contact_account_label: Option, + accepted_accounts: Vec, + } + + 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 u32s = |ptr: *const u32, len: usize| -> Vec { + if ptr.is_null() || len == 0 { + Vec::new() + } else { + 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); + // The contact's account label is direction-specific — it rides + // ONLY the incoming row, so take it from that row (never the + // outgoing one, which may carry a label we sent). + acc.contact_account_label = opt_string(row.contact_account_label); + } + // 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); + } + // Relationship-level, replicated onto both rows — take the first + // non-empty projection. + if acc.accepted_accounts.is_empty() { + acc.accepted_accounts = u32s(row.accepted_accounts, row.accepted_accounts_len); + } + } + + 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; + contact.contact_account_label = acc.contact_account_label; + contact.accepted_accounts = acc.accepted_accounts; + managed.apply_established_contact(contact); + } + (Some(outgoing), None) => { + managed.apply_sent_contact_request(outgoing); + } + (None, Some(incoming)) => { + managed.apply_incoming_contact_request(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 +4651,68 @@ 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; + 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 +4735,345 @@ 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.dashpay().contact_profiles.len(), 2); + + let alice = managed + .dashpay() + .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 + .dashpay() + .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.dashpay().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]))); + } + + /// Persist→restore round-trip for the contact maps. The persist + /// projection (`from_outgoing`/`from_incoming`/`from_established_*`) is + /// tested field-by-field, but the FFI→Rust read-back (`apply_contact_rows`) + /// was untested — so a dropped optional or a swapped key index on restore + /// would be invisible (the exact bug class that shipped on the parse side). + /// This builds rows via the SAME persist constructors the live path uses, + /// decodes them, and asserts field-by-field — including the + /// direction-specific `contact_account_label` (incoming-row only) and + /// distinct sender ≠ recipient key indices (a swap is otherwise invisible, + /// both `u32`). + #[test] + fn apply_contact_rows_round_trips_all_fields() { + use platform_wallet::ContactRequest; + + let owner = Identifier::from([0x11; 32]); + let sent_c = Identifier::from([0xA1; 32]); + let in_c = Identifier::from([0xA2; 32]); + let estab_c = Identifier::from([0xA3; 32]); + + let sent_req = ContactRequest::new(owner, sent_c, 3, 4, 11, vec![1u8; 96], 100, 1000); + let in_req = ContactRequest::new(in_c, owner, 5, 6, 22, vec![2u8; 96], 101, 1001); + let mut estab_out = ContactRequest::new(owner, estab_c, 7, 8, 33, vec![3u8; 96], 102, 1002); + estab_out.auto_accept_proof = Some(vec![9u8; 40]); + let mut estab_in = ContactRequest::new(estab_c, owner, 9, 10, 44, vec![4u8; 96], 103, 1003); + estab_in.encrypted_account_label = Some(vec![0x2au8; 48]); + estab_in.auto_accept_proof = Some(vec![8u8; 38]); + + let mut rows = vec![ + ContactRequestFFI::from_outgoing(owner.to_buffer(), sent_c.to_buffer(), &sent_req), + ContactRequestFFI::from_incoming(owner.to_buffer(), in_c.to_buffer(), &in_req), + ContactRequestFFI::from_established_outgoing( + owner.to_buffer(), + estab_c.to_buffer(), + &estab_out, + true, + Some("ally"), + Some("a note"), + true, + &[7, 42], + ), + ContactRequestFFI::from_established_incoming( + owner.to_buffer(), + estab_c.to_buffer(), + &estab_in, + true, + Some("ally"), + Some("a note"), + true, + Some("Main wallet"), + &[7, 42], + ), + ]; + + let identity_v0 = IdentityV0 { + id: owner, + public_keys: std::collections::BTreeMap::new(), + balance: 0, + revision: 0, + }; + let mut managed = ManagedIdentity::new(Identity::V0(identity_v0), 0); + + unsafe { apply_contact_rows(&rows, &owner, &mut managed) }; + + let s = managed + .dashpay() + .sent_contact_requests() + .get(&sent_c) + .expect("pending sent request restored"); + assert_eq!( + ( + s.sender_key_index, + s.recipient_key_index, + s.account_reference + ), + (3, 4, 11) + ); + let i = managed + .dashpay() + .incoming_contact_requests() + .get(&in_c) + .expect("pending incoming request restored"); + assert_eq!( + ( + i.sender_key_index, + i.recipient_key_index, + i.account_reference + ), + (5, 6, 22) + ); + + let e = managed + .dashpay() + .established_contacts() + .get(&estab_c) + .expect("established contact restored"); + assert_eq!(e.outgoing_request.auto_accept_proof, Some(vec![9u8; 40])); + assert_eq!( + e.incoming_request.encrypted_account_label, + Some(vec![0x2au8; 48]), + "the incoming encrypted label must survive read-back (the shipped-bug class)" + ); + assert_eq!(e.incoming_request.auto_accept_proof, Some(vec![8u8; 38])); + assert_eq!(e.alias.as_deref(), Some("ally")); + assert_eq!(e.note.as_deref(), Some("a note")); + assert!(e.is_hidden); + assert!(e.payment_channel_broken); + assert_eq!( + e.contact_account_label.as_deref(), + Some("Main wallet"), + "contact_account_label must restore from the incoming row only" + ); + assert_eq!( + e.accepted_accounts, + vec![7, 42], + "accepted_accounts must round-trip through the FFI rows (matching the SQLite backend)" + ); + // Key indices restored without a swap (incoming sender=9, recipient=10). + assert_eq!( + ( + e.incoming_request.sender_key_index, + e.incoming_request.recipient_key_index + ), + (9, 10) + ); + + unsafe { free_contact_requests_ffi(rows.as_mut_ptr(), rows.len()) }; + } } diff --git a/packages/rs-platform-wallet-ffi/src/platform_address_types.rs b/packages/rs-platform-wallet-ffi/src/platform_address_types.rs index f4f5a615133..356e22c3ef7 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_address_types.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_address_types.rs @@ -403,6 +403,26 @@ pub struct PlatformAddressChangeSetFFI { pub updated_count: usize, } +impl PlatformAddressChangeSetFFI { + /// FFI-safe empty sentinel: a null pointer with a zero count. + /// + /// The changeset-producing FFI entry points (transfer / withdraw / + /// fund-from-asset-lock) publish this into their `out_changeset` + /// out-param *before* any fallible work, so that an error return leaves + /// the out-param well-defined. `platform_address_wallet_free_changeset` + /// reconstructs `Vec::from_raw_parts(updated, updated_count, ..)` whenever + /// `updated` is non-null, so a caller running symmetric cleanup-on-error + /// over an uninitialized changeset would otherwise feed stale stack bytes + /// into a real `Vec::from_raw_parts` — a double-free. The `(null, 0)` + /// sentinel is skipped by that free path. + pub fn empty() -> Self { + Self { + updated: std::ptr::null_mut(), + updated_count: 0, + } + } +} + // --------------------------------------------------------------------------- // Conversion helpers // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs index bb19f2d2533..ed619cb4bf8 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs @@ -63,6 +63,10 @@ pub unsafe extern "C" fn platform_address_wallet_fund_from_asset_lock_signer( out_changeset: *mut PlatformAddressChangeSetFFI, ) -> PlatformWalletFFIResult { check_ptr!(out_changeset); + // Sentinel first: address decoding, the wallet lookup, and the async + // fund below are all fallible. See `PlatformAddressChangeSetFFI::empty` + // for the double-free rationale. + *out_changeset = PlatformAddressChangeSetFFI::empty(); check_ptr!(addresses); check_ptr!(signer_address_handle); check_ptr!(core_signer_handle); @@ -152,6 +156,10 @@ pub unsafe extern "C" fn platform_address_wallet_resume_fund_from_asset_lock_sig out_changeset: *mut PlatformAddressChangeSetFFI, ) -> PlatformWalletFFIResult { check_ptr!(out_changeset); + // Sentinel first: address decoding, the wallet lookup, and the async + // resume-fund below are all fallible. See + // `PlatformAddressChangeSetFFI::empty` for the double-free rationale. + *out_changeset = PlatformAddressChangeSetFFI::empty(); check_ptr!(addresses); check_ptr!(out_point); check_ptr!(signer_address_handle); diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/transfer.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/transfer.rs index 7a47c3ff94a..73575edc944 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/transfer.rs @@ -37,6 +37,10 @@ pub unsafe extern "C" fn platform_address_wallet_transfer( out_changeset: *mut PlatformAddressChangeSetFFI, ) -> PlatformWalletFFIResult { check_ptr!(out_changeset); + // Sentinel first: output/input parsing, the wallet lookup, and the async + // transfer below are all fallible. See + // `PlatformAddressChangeSetFFI::empty` for the double-free rationale. + *out_changeset = PlatformAddressChangeSetFFI::empty(); check_ptr!(signer_address_handle); let output_map = unwrap_result_or_return!(parse_outputs(outputs, outputs_count)); diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs index 2f170e8c77f..cd9fe15f2a2 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs @@ -35,6 +35,10 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw( out_changeset: *mut PlatformAddressChangeSetFFI, ) -> PlatformWalletFFIResult { check_ptr!(out_changeset); + // Sentinel first: input parsing, the wallet lookup, and the async + // withdraw below are all fallible. See + // `PlatformAddressChangeSetFFI::empty` for the double-free rationale. + *out_changeset = PlatformAddressChangeSetFFI::empty(); check_ptr!(output_script); check_ptr!(signer_address_handle); @@ -125,6 +129,13 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw_to_address( out_changeset: *mut PlatformAddressChangeSetFFI, ) -> PlatformWalletFFIResult { check_ptr!(out_changeset); + // Sentinel first — address parsing, the `ErrorInvalidNetwork` path, and + // the async withdraw are all reachable from a caller-controlled address + // string, so publish the empty changeset before them (and before the + // `core_address` / signer null checks below) to keep every early return + // FFI-safe. See `PlatformAddressChangeSetFFI::empty` for the double-free + // rationale. + *out_changeset = PlatformAddressChangeSetFFI::empty(); check_ptr!(core_address); check_ptr!(signer_address_handle); 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..0225e653a77 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 @@ -67,6 +67,44 @@ pub const SIGN_WITH_RESOLVER_ERR_UNSUPPORTED_KEY_TYPE: u8 = 8; 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; +/// The key derived at the path did not match the caller-supplied +/// `expected_key_data` (compressed pubkey or its hash). The signature is +/// NOT produced — this guards against signing with a wrong/stale key. +pub const SIGN_WITH_RESOLVER_ERR_PUBKEY_MISMATCH: u8 = 11; + +/// The set of DPP key types the mnemonic resolver can derive-and-sign for. +/// +/// Both are secp256k1 ECDSA keys that sign identically — the type only +/// describes the on-chain pubkey representation (`ECDSA_SECP256K1 = 0` is the +/// compressed pubkey; `ECDSA_HASH160 = 2` is its `ripemd160_sha256`). BLS, +/// EdDSA and script-hash keys are not wallet-derivable via this path. +/// +/// Single source of truth for both the sign path's +/// [`SIGN_WITH_RESOLVER_ERR_UNSUPPORTED_KEY_TYPE`] rejection and the +/// [`dash_sdk_resolver_supports_key_type`] preflight predicate. +fn resolver_supports_key_type(key_type: u8) -> bool { + const ECDSA_SECP256K1: u8 = 0; + const ECDSA_HASH160: u8 = 2; + key_type == ECDSA_SECP256K1 || key_type == ECDSA_HASH160 +} + +/// Whether the mnemonic resolver can derive-and-sign for a DPP `key_type`, +/// without needing any data to sign. +/// +/// Lets a Swift caller answer "would `dash_sdk_sign_with_mnemonic_resolver_and_path` +/// accept this key type?" on a preflight path (e.g. a `canSign` check) instead +/// of mirroring the supported set in Swift. Returns `true` exactly for the key +/// types the sign path accepts; every other type would fail there with +/// [`SIGN_WITH_RESOLVER_ERR_UNSUPPORTED_KEY_TYPE`]. +/// +/// # Safety +/// Takes only a plain `u8` by value and touches no pointers — always safe to +/// call. Declared `unsafe extern "C"` for a uniform FFI surface with its +/// siblings in this module. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_resolver_supports_key_type(key_type: u8) -> bool { + resolver_supports_key_type(key_type) +} /// Sign `data` with the ECDSA secp256k1 private key derived from /// `(mnemonic-via-resolver, derivation_path)`. Mnemonic, seed and @@ -79,10 +117,20 @@ pub const SIGN_WITH_RESOLVER_ERR_RESOLVER_FAILED: u8 = 10; /// Same shape as `dash_sdk_sign_with_mnemonic_and_path` /// (rs-sdk-ffi/src/signer_simple.rs) with the mnemonic argument /// replaced by `(wallet_id_bytes, mnemonic_resolver_handle)`. The -/// `key_type` parameter exists for parity (only `0` = -/// `ECDSA_SECP256K1` is supported); other key types fail with +/// derived key is always a secp256k1 ECDSA key; `key_type` accepts +/// `0` (`ECDSA_SECP256K1`) or `2` (`ECDSA_HASH160`) — both sign +/// identically, the type only describes the on-chain representation. +/// Other key types fail with /// [`SIGN_WITH_RESOLVER_ERR_UNSUPPORTED_KEY_TYPE`]. /// +/// When `expected_key_data` is non-null it binds the derived key to a +/// known on-chain key BEFORE signing: the derived compressed public key +/// (or, for a 20-byte `expected_key_data_len`, its `ripemd160_sha256` +/// hash) must equal the supplied bytes, else +/// [`SIGN_WITH_RESOLVER_ERR_PUBKEY_MISMATCH`] and no signature is +/// produced. Pass null / `0` to skip the check (e.g. the address path, +/// whose key is already bound by its own derivation). +/// /// Returns `0` on success, `-1` on error. On error, `*out_error` /// is set to one of the `SIGN_WITH_RESOLVER_ERR_*` tags, /// `*out_signature_len = 0`, and the first @@ -96,6 +144,9 @@ pub const SIGN_WITH_RESOLVER_ERR_RESOLVER_FAILED: u8 = 10; /// C-string for the duration of the call. /// - `data` must point at `data_len` readable bytes (may be zero /// only if `data_len == 0`). +/// - `expected_key_data`, when non-null, must point at +/// `expected_key_data_len` readable bytes (33 for a compressed +/// pubkey, 20 for its hash). /// - `out_signature` must point at `out_signature_capacity` /// writable bytes; `out_signature_len` and `out_error` must be /// writable. @@ -109,6 +160,8 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_resolver_and_path( data_len: usize, key_type: u8, network: FFINetwork, + expected_key_data: *const u8, + expected_key_data_len: usize, out_signature: *mut u8, out_signature_capacity: usize, out_signature_len: *mut usize, @@ -140,9 +193,23 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_resolver_and_path( return fail(SIGN_WITH_RESOLVER_ERR_NULL_POINTER); } - // ECDSA-only entry point. Anything else is a contract violation. - const ECDSA_SECP256K1: u8 = 0; - if key_type != ECDSA_SECP256K1 { + // `expected_key_data` and its length must agree: `(null, 0)` is the + // intentional opt-out of the derived-key binding; `(non-null, > 0)` + // requests it. A mismatched pair — a null pointer with a non-zero length, + // or a real pointer with length 0 — is a cross-language marshaling + // contract violation that would silently skip the impersonation-resistance + // check below and sign unbound, so fail closed instead. + if expected_key_data.is_null() != (expected_key_data_len == 0) { + return fail(SIGN_WITH_RESOLVER_ERR_NULL_POINTER); + } + + // secp256k1-only entry point: both ECDSA key types sign identically + // (the type only describes the on-chain pubkey representation). Anything + // else (BLS, EdDSA, script-hash) is a contract violation. The supported + // set lives in `resolver_supports_key_type` so the preflight predicate + // `dash_sdk_resolver_supports_key_type` and this sign path can never + // disagree about which key types the resolver derives. + if !resolver_supports_key_type(key_type) { return fail(SIGN_WITH_RESOLVER_ERR_UNSUPPORTED_KEY_TYPE); } @@ -200,27 +267,49 @@ 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()) { + // The master and derived `ExtendedPrivKey`s self-wipe their secret scalar + // on `Drop` (upstream key-wallet), covering the early `return` below and any + // panic between here and signing. + let master = match ExtendedPrivKey::new_master(kw_network, seed.as_ref()) { Ok(m) => m, Err(_) => return fail(SIGN_WITH_RESOLVER_ERR_DERIVATION), }; let secp = Secp256k1::new(); - let mut derived = match master.derive_priv(&secp, &path) { + let derived = match master.derive_priv(&secp, &path) { Ok(d) => 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(); + // ---- Bind the derived key to the expected on-chain key ------------------- + // Reject before signing if the key derived here doesn't reproduce the + // caller's known key, so a stale/wrong path or a mis-mapped mnemonic can + // never yield a valid signature under the wrong key. The 33-vs-20-byte + // binding policy lives in `platform_wallet::pubkey_binds_expected_key_data` + // so it stays byte-for-byte aligned with the discovery-time + // `validate_private_key_bytes` decision (33-byte expected = compressed + // pubkey equality; 20-byte expected = `ripemd160_sha256` of it). + if !expected_key_data.is_null() && expected_key_data_len > 0 { + let derived_pubkey = key_wallet::bip32::ExtendedPubKey::from_priv(&secp, &derived) + .public_key + .serialize(); + // Build the expected slice only for the two lengths the binding + // policy accepts (33 or 20), never from the caller-supplied + // `expected_key_data_len` directly: a malformed huge length must not + // widen the `from_raw_parts` read into UB. Any other length takes the + // `_` arm and never dereferences — same fail-closed answer the helper + // gives for unknown lengths. + let matches = match expected_key_data_len { + 33 | 20 => { + let expected = std::slice::from_raw_parts(expected_key_data, expected_key_data_len); + platform_wallet::pubkey_binds_expected_key_data(&derived_pubkey, expected) + } + _ => false, + }; + if !matches { + return fail(SIGN_WITH_RESOLVER_ERR_PUBKEY_MISMATCH); + } + } // ---- Sign --------------------------------------------------------------- let data_slice: &[u8] = if data_len == 0 { @@ -306,6 +395,8 @@ mod tests { data.len(), 0, // ECDSA_SECP256K1 FFINetwork::Testnet, + std::ptr::null(), + 0, sig_buf.as_mut_ptr(), sig_buf.len(), &mut sig_len, @@ -337,6 +428,8 @@ mod tests { data.len(), 0, FFINetwork::Testnet, + std::ptr::null(), + 0, sig_buf.as_mut_ptr(), sig_buf.len(), &mut sig_len, @@ -367,6 +460,8 @@ mod tests { data.len(), 1, // BLS12_381 — not supported FFINetwork::Testnet, + std::ptr::null(), + 0, sig_buf.as_mut_ptr(), sig_buf.len(), &mut sig_len, @@ -377,4 +472,356 @@ mod tests { assert_eq!(err, SIGN_WITH_RESOLVER_ERR_UNSUPPORTED_KEY_TYPE); unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + + /// The resolver path signs with the EXACT key the DIP-9 identity-auth path + /// derives: the produced signature verifies against the compressed pubkey + /// derived independently at the same path from the same mnemonic. This is + /// the evidence that identity-key signing needs no dedicated FFI — the + /// generic path primitive produces a correct, key-bound signature for a + /// `m/9'/coin'/5'/0'/0'/identity'/key'` path just as it does for addresses. + #[test] + fn signs_dip9_identity_path_with_the_derived_key() { + use key_wallet::bip32::ExtendedPubKey; + + // identity_index = 3, key_index = 2. + let path_str = "m/9'/1'/5'/0'/0'/3'/2'"; + let resolver = make_resolver(english_resolve); + let path = CString::new(path_str).unwrap(); + let wallet_id = [0u8; 32]; + let data = b"identity state transition bytes"; + + // Independently derive the compressed pubkey at the same path from the + // same mnemonic the resolver returns. + let mnemonic = parse_mnemonic_any_language(ENGLISH_PHRASE).expect("mnemonic"); + let seed = mnemonic.to_seed(""); + let secp = Secp256k1::new(); + let master = ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master"); + let derived = master + .derive_priv(&secp, &DerivationPath::from_str(path_str).unwrap()) + .expect("derive"); + let expected_pubkey = ExtendedPubKey::from_priv(&secp, &derived) + .public_key + .serialize(); + + let mut sig_buf = [0u8; 128]; + let mut sig_len: usize = 0; + let mut err: u8 = 0; + // Pass the expected pubkey so the binding check also runs on the happy + // path (it must accept the key derived at this path). + let rc = unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + 0, // ECDSA_SECP256K1 + FFINetwork::Testnet, + expected_pubkey.as_ptr(), + expected_pubkey.len(), + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len, + &mut err, + ) + }; + assert_eq!(rc, 0); + assert_eq!(err, SIGN_WITH_RESOLVER_OK); + dash_sdk::dpp::dashcore::signer::verify_data_signature( + data, + &sig_buf[..sig_len], + &expected_pubkey, + ) + .expect("signature must verify against the key derived at the DIP-9 path"); + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// The binding rejects BEFORE signing when the key derived at the path does + /// not reproduce the caller's expected key — a wrong/stale path or a + /// mis-mapped mnemonic can never yield a signature under the wrong key. + #[test] + fn binding_rejects_wrong_expected_pubkey() { + let resolver = make_resolver(english_resolve); + let path = CString::new("m/9'/1'/5'/0'/0'/3'/2'").unwrap(); + let wallet_id = [0u8; 32]; + let data = b"x"; + // A pubkey that is NOT the one derived at the path (all 0x02 — a + // syntactically valid compressed-pubkey prefix, wrong key). + let wrong_pubkey = [0x02u8; 33]; + let mut sig_buf = [0u8; 128]; + let mut sig_len: usize = 0; + let mut err: u8 = 0; + let rc = unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + 0, + FFINetwork::Testnet, + wrong_pubkey.as_ptr(), + wrong_pubkey.len(), + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len, + &mut err, + ) + }; + assert_eq!(rc, -1); + assert_eq!(err, SIGN_WITH_RESOLVER_ERR_PUBKEY_MISMATCH); + assert_eq!(sig_len, 0, "no signature on a binding mismatch"); + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// A `HASH160` identity key (key_type 2) binds by the 20-byte + /// `ripemd160_sha256` of the derived pubkey and signs via the same + /// secp256k1 path — proving the resolver covers every wallet-derivable + /// identity key type (so the carried scalar can be dropped for all of them). + #[test] + fn binding_accepts_hash160_key_and_signs() { + use key_wallet::bip32::ExtendedPubKey; + + let path_str = "m/9'/1'/5'/0'/0'/0'/0'"; + let resolver = make_resolver(english_resolve); + let path = CString::new(path_str).unwrap(); + let wallet_id = [0u8; 32]; + let data = b"hash160 identity sign"; + + let mnemonic = parse_mnemonic_any_language(ENGLISH_PHRASE).expect("mnemonic"); + let seed = mnemonic.to_seed(""); + let secp = Secp256k1::new(); + let master = ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master"); + let derived = master + .derive_priv(&secp, &DerivationPath::from_str(path_str).unwrap()) + .expect("derive"); + let pubkey = ExtendedPubKey::from_priv(&secp, &derived) + .public_key + .serialize(); + let expected_hash = dash_sdk::dpp::util::hash::ripemd160_sha256(&pubkey); + + let mut sig_buf = [0u8; 128]; + let mut sig_len: usize = 0; + let mut err: u8 = 0; + let rc = unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + 2, // ECDSA_HASH160 + FFINetwork::Testnet, + expected_hash.as_ptr(), + expected_hash.len(), + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len, + &mut err, + ) + }; + assert_eq!(rc, 0); + assert_eq!(err, SIGN_WITH_RESOLVER_OK); + dash_sdk::dpp::dashcore::signer::verify_data_signature(data, &sig_buf[..sig_len], &pubkey) + .expect("HASH160 key signature must verify against its derived pubkey"); + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// An `expected_key_data` whose length is neither 33 (pubkey) nor 20 (hash) + /// hits the `_ => false` arm and fails closed — it must NOT silently skip + /// the binding or sign. Guards against a caller passing, e.g., a 32-byte + /// scalar or a 65-byte uncompressed key by mistake. + #[test] + fn binding_rejects_malformed_expected_length() { + let resolver = make_resolver(english_resolve); + let path = CString::new("m/9'/1'/5'/0'/0'/3'/2'").unwrap(); + let wallet_id = [0u8; 32]; + let data = b"x"; + let malformed = [0x02u8; 32]; // 32 bytes — neither 33 nor 20 + let mut sig_buf = [0u8; 128]; + let mut sig_len: usize = 0; + let mut err: u8 = 0; + let rc = unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + 0, + FFINetwork::Testnet, + malformed.as_ptr(), + malformed.len(), + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len, + &mut err, + ) + }; + assert_eq!(rc, -1); + assert_eq!(err, SIGN_WITH_RESOLVER_ERR_PUBKEY_MISMATCH); + assert_eq!(sig_len, 0, "malformed binding length must fail closed"); + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// A mismatched `expected_key_data` pointer/length pair must fail closed. + /// `(null, 0)` opts out of the derived-key binding and `(non-null, > 0)` + /// requests it; a null pointer with a non-zero length, or a real pointer + /// with length 0, is a marshaling contract violation that would otherwise + /// silently skip the impersonation-resistance check and sign unbound. + #[test] + fn inconsistent_expected_key_data_pointer_length_fails_closed() { + let resolver = make_resolver(english_resolve); + let path = CString::new("m/9'/1'/5'/0'/0'/3'/2'").unwrap(); + let wallet_id = [0u8; 32]; + let data = b"x"; + let key = [0x02u8; 33]; + let mut sig_buf = [0u8; 128]; + + // (a) null pointer with a non-zero length. + let mut sig_len: usize = 0; + let mut err: u8 = 0; + let rc = unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + 0, + FFINetwork::Testnet, + std::ptr::null(), + 33, + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len, + &mut err, + ) + }; + assert_eq!(rc, -1); + assert_eq!(err, SIGN_WITH_RESOLVER_ERR_NULL_POINTER); + assert_eq!(sig_len, 0, "null ptr + non-zero len must not sign"); + + // (b) real pointer with length zero. + let mut sig_len2: usize = 0; + let mut err2: u8 = 0; + let rc2 = unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + 0, + FFINetwork::Testnet, + key.as_ptr(), + 0, + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len2, + &mut err2, + ) + }; + assert_eq!(rc2, -1); + assert_eq!(err2, SIGN_WITH_RESOLVER_ERR_NULL_POINTER); + assert_eq!(sig_len2, 0, "non-null ptr + zero len must not sign"); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// The 20-byte (HASH160) binding arm must REJECT a wrong hash, not just + /// accept the right one — a wrong-HASH160 signature is the R9 silent + /// consensus-reject lockout, so its false branch must be pinned. + #[test] + fn binding_rejects_wrong_hash160() { + let resolver = make_resolver(english_resolve); + let path = CString::new("m/9'/1'/5'/0'/0'/0'/0'").unwrap(); + let wallet_id = [0u8; 32]; + let data = b"x"; + // ripemd160_sha256 of an unrelated pubkey is a valid-shaped but WRONG + // 20-byte hash for the key derived at the path above. + let wrong_hash = dash_sdk::dpp::util::hash::ripemd160_sha256(&[0x03u8; 33]); + let mut sig_buf = [0u8; 128]; + let mut sig_len: usize = 0; + let mut err: u8 = 0; + let rc = unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + 2, // ECDSA_HASH160 + FFINetwork::Testnet, + wrong_hash.as_ptr(), + wrong_hash.len(), + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len, + &mut err, + ) + }; + assert_eq!(rc, -1); + assert_eq!(err, SIGN_WITH_RESOLVER_ERR_PUBKEY_MISMATCH); + assert_eq!(sig_len, 0, "wrong HASH160 must fail closed"); + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// The preflight predicate accepts exactly the two wallet-derivable ECDSA + /// key types (0, 2) and rejects everything else (BLS = 1, script-hash = 3, + /// the reserved 0xFF, etc.). + #[test] + fn predicate_reports_supported_key_types() { + assert!(unsafe { dash_sdk_resolver_supports_key_type(0) }); // ECDSA_SECP256K1 + assert!(unsafe { dash_sdk_resolver_supports_key_type(2) }); // ECDSA_HASH160 + assert!(!unsafe { dash_sdk_resolver_supports_key_type(1) }); // BLS12_381 + assert!(!unsafe { dash_sdk_resolver_supports_key_type(3) }); // BIP13_SCRIPT_HASH + assert!(!unsafe { dash_sdk_resolver_supports_key_type(4) }); // EDDSA_25519_HASH160 + assert!(!unsafe { dash_sdk_resolver_supports_key_type(0xFF) }); + } + + /// The preflight predicate and the sign path's UNSUPPORTED_KEY_TYPE + /// rejection must agree for every `u8`: the predicate returns `true` + /// iff the sign path does NOT reject that key type as unsupported. This + /// pins the single-source-of-truth contract so a future change to the + /// supported set can't drift the two apart. + #[test] + fn predicate_agrees_with_sign_path_for_all_key_types() { + let resolver = make_resolver(english_resolve); + let path = CString::new("m/9'/1'/5'/0'/0'/0'/0'").unwrap(); + let wallet_id = [0u8; 32]; + let data = b"x"; + + for key_type in 0u8..=u8::MAX { + let supported = unsafe { dash_sdk_resolver_supports_key_type(key_type) }; + + let mut sig_buf = [0u8; 128]; + let mut sig_len: usize = 0; + let mut err: u8 = 0; + unsafe { + dash_sdk_sign_with_mnemonic_resolver_and_path( + resolver, + wallet_id.as_ptr(), + path.as_ptr(), + data.as_ptr(), + data.len(), + key_type, + FFINetwork::Testnet, + std::ptr::null(), + 0, + sig_buf.as_mut_ptr(), + sig_buf.len(), + &mut sig_len, + &mut err, + ); + } + let rejected_unsupported = err == SIGN_WITH_RESOLVER_ERR_UNSUPPORTED_KEY_TYPE; + assert_eq!( + supported, !rejected_unsupported, + "predicate and sign path disagree for key_type {key_type}" + ); + } + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } } diff --git a/packages/rs-platform-wallet-ffi/src/types.rs b/packages/rs-platform-wallet-ffi/src/types.rs index 4d051ac79fc..f53b0df033c 100644 --- a/packages/rs-platform-wallet-ffi/src/types.rs +++ b/packages/rs-platform-wallet-ffi/src/types.rs @@ -107,13 +107,26 @@ pub struct IdentifierArray { } impl IdentifierArray { + /// FFI-safe empty sentinel: a null pointer with a zero count. + /// + /// The `out_array: *mut IdentifierArray` entry points publish this + /// before any fallible work (e.g. a handle-storage lookup) so an error + /// return never leaves the out-param holding uninitialized stack bytes — + /// `platform_wallet_identifier_array_free` reconstructs a `Vec` from any + /// non-null pointer / non-zero count pair, so a cleanup-on-error caller + /// would otherwise free garbage. The `(null, 0)` sentinel is skipped by + /// that free path. + pub fn empty() -> Self { + Self { + items: std::ptr::null_mut(), + count: 0, + } + } + pub fn new(identifiers: Vec) -> Self { let count = identifiers.len(); if count == 0 { - return Self { - items: std::ptr::null_mut(), - count: 0, - }; + return Self::empty(); } let mut items: Vec<[u8; 32]> = identifiers.into_iter().map(|id| id.to_buffer()).collect(); diff --git a/packages/rs-platform-wallet-ffi/src/utils.rs b/packages/rs-platform-wallet-ffi/src/utils.rs index b9e9a999bbf..bf84c88170e 100644 --- a/packages/rs-platform-wallet-ffi/src/utils.rs +++ b/packages/rs-platform-wallet-ffi/src/utils.rs @@ -2,6 +2,19 @@ use crate::error::*; use crate::{check_ptr, unwrap_result_or_return}; use std::os::raw::{c_char, c_uchar}; +/// RAII guard that scrubs a `secp256k1::SecretKey`'s scalar on drop. `from_slice` +/// allocates a 32-byte scalar copy of the caller's private key, and `SecretKey` +/// has no `Drop` wipe of its own — so without this the copy would survive on the +/// stack after the call returns. Mirrors `WipingSecretKey` in +/// `rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`. +struct WipingSecretKey(dashcore::secp256k1::SecretKey); + +impl Drop for WipingSecretKey { + fn drop(&mut self) { + self.0.non_secure_erase(); + } +} + /// Serialize any object to JSON bytes #[no_mangle] pub unsafe extern "C" fn platform_wallet_serialize_to_json_bytes( @@ -12,6 +25,14 @@ pub unsafe extern "C" fn platform_wallet_serialize_to_json_bytes( check_ptr!(json_string); check_ptr!(out_bytes); check_ptr!(out_len); + // Sentinel first: the UTF-8 check below is fallible, and + // `platform_wallet_bytes_free` reconstructs a `Vec` from any non-null + // pointer / non-zero length pair — a cleanup-on-error caller must never + // see stack garbage here. + unsafe { + *out_bytes = std::ptr::null_mut(); + *out_len = 0; + } let json_str = unwrap_result_or_return!(unsafe { std::ffi::CStr::from_ptr(json_string).to_str() }); @@ -38,6 +59,10 @@ pub unsafe extern "C" fn platform_wallet_deserialize_from_json_bytes( ) -> PlatformWalletFFIResult { check_ptr!(bytes); check_ptr!(out_json_string); + // Null the out-pointer before the fallible UTF-8 / NUL checks below so + // an error return never leaves it holding stack garbage for a + // cleanup-on-error caller to `platform_wallet_string_free`. + unsafe { *out_json_string = std::ptr::null_mut() }; let data = unsafe { std::slice::from_raw_parts(bytes, len) }; let s = unwrap_result_or_return!(std::str::from_utf8(data)); @@ -145,6 +170,57 @@ 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(); + // `WipingSecretKey` scrubs the `from_slice`-allocated scalar copy on every + // exit path (the success return below, the `Err` early return, and any + // panic) — the caller's `private_key` bytes are theirs to manage, but this + // copy must not linger. + let secret_key = match SecretKey::from_slice(sk_bytes) { + Ok(sk) => WipingSecretKey(sk), + Err(_) => return -1, + }; + let pubkey = PublicKey::from_secret_key(&secp, &secret_key.0).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 +260,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..95629084629 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 + /// the managed identity's ignored-senders set 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 the managed identity's contact-profile cache + /// (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 +/// the managed identity's payments map (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 +/// the managed identity's contact-profile cache (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-ffi/tests/test_data/mod.rs b/packages/rs-platform-wallet-ffi/tests/test_data/mod.rs index ff4f216f14b..e7d9fb98f33 100644 --- a/packages/rs-platform-wallet-ffi/tests/test_data/mod.rs +++ b/packages/rs-platform-wallet-ffi/tests/test_data/mod.rs @@ -230,9 +230,15 @@ pub mod scenarios { let req2 = create_contact_request(alice_id, carol_id, 0, 1, 1, 1_700_000_050); let req3 = create_contact_request(alice_id, dave_id, 0, 1, 2, 1_700_000_100); - alice.add_sent_contact_request(req1.clone(), &noop_persister()); - alice.add_sent_contact_request(req2.clone(), &noop_persister()); - alice.add_sent_contact_request(req3.clone(), &noop_persister()); + alice + .add_sent_contact_request(req1.clone(), &noop_persister()) + .expect("test setup persists"); + alice + .add_sent_contact_request(req2.clone(), &noop_persister()) + .expect("test setup persists"); + alice + .add_sent_contact_request(req3.clone(), &noop_persister()) + .expect("test setup persists"); (alice, vec![req1, req2, req3]) } @@ -250,9 +256,15 @@ pub mod scenarios { let req2 = create_contact_request(carol_id, alice_id, 1, 0, 0, 1_700_000_050); let req3 = create_contact_request(dave_id, alice_id, 1, 0, 0, 1_700_000_100); - alice.add_incoming_contact_request(req1.clone(), &noop_persister()); - alice.add_incoming_contact_request(req2.clone(), &noop_persister()); - alice.add_incoming_contact_request(req3.clone(), &noop_persister()); + alice + .add_incoming_contact_request(req1.clone(), &noop_persister()) + .expect("test setup persists"); + alice + .add_incoming_contact_request(req2.clone(), &noop_persister()) + .expect("test setup persists"); + alice + .add_incoming_contact_request(req3.clone(), &noop_persister()) + .expect("test setup persists"); (alice, vec![req1, req2, req3]) } @@ -268,10 +280,8 @@ pub mod scenarios { let contact1 = create_established_contact(bob_id, alice_id, 1_700_000_000, 1_700_000_100); let contact2 = create_established_contact(carol_id, alice_id, 1_700_000_200, 1_700_000_300); - alice.established_contacts.insert(bob_id, contact1.clone()); - alice - .established_contacts - .insert(carol_id, contact2.clone()); + alice.apply_established_contact(contact1.clone()); + alice.apply_established_contact(contact2.clone()); (alice, vec![contact1, contact2]) } @@ -289,19 +299,25 @@ pub mod scenarios { // Established contact with Bob let bob_contact = create_established_contact(bob_id, alice_id, 1_700_000_000, 1_700_000_100); - alice.established_contacts.insert(bob_id, bob_contact); + alice.apply_established_contact(bob_contact); // Pending sent request to Carol (not reciprocated yet) let carol_request = create_contact_request(alice_id, carol_id, 0, 1, 0, 1_700_000_200); - alice.add_sent_contact_request(carol_request, &noop_persister()); + alice + .add_sent_contact_request(carol_request, &noop_persister()) + .expect("test setup persists"); // Pending incoming request from Dave (we haven't sent back yet) let dave_request = create_contact_request(dave_id, alice_id, 1, 0, 0, 1_700_000_300); - alice.add_incoming_contact_request(dave_request, &noop_persister()); + alice + .add_incoming_contact_request(dave_request, &noop_persister()) + .expect("test setup persists"); // Pending incoming request from Eve let eve_request = create_contact_request(eve_id, alice_id, 1, 0, 0, 1_700_000_400); - alice.add_incoming_contact_request(eve_request, &noop_persister()); + alice + .add_incoming_contact_request(eve_request, &noop_persister()) + .expect("test setup persists"); alice } @@ -361,13 +377,14 @@ mod tests { fn test_alice_with_pending_sent_requests() { let (alice, requests) = scenarios::alice_with_pending_sent_requests(); - assert_eq!(alice.sent_contact_requests.len(), 3); + assert_eq!(alice.dashpay().sent_contact_requests().len(), 3); assert_eq!(requests.len(), 3); // Verify requests are in the managed identity for request in &requests { assert!(alice - .sent_contact_requests + .dashpay() + .sent_contact_requests() .contains_key(&request.recipient_id)); } } @@ -376,8 +393,8 @@ mod tests { fn test_alice_with_mixed_contacts() { let alice = scenarios::alice_with_mixed_contacts(); - assert_eq!(alice.established_contacts.len(), 1); // Bob - assert_eq!(alice.sent_contact_requests.len(), 1); // Carol - assert_eq!(alice.incoming_contact_requests.len(), 2); // Dave, Eve + assert_eq!(alice.dashpay().established_contacts().len(), 1); // Bob + assert_eq!(alice.dashpay().sent_contact_requests().len(), 1); // Carol + assert_eq!(alice.dashpay().incoming_contact_requests().len(), 2); // Dave, Eve } } 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..e4ba065bf3c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -16,7 +16,10 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; -#[cfg(feature = "__test-helpers")] +// `any(test, …)`, not `__test-helpers`-only: `load_ignored_senders` (and its +// `decode_pair_key` helper) are gated the same way — they're reached by +// `identities::load_state`, whose gate includes plain `test`. +#[cfg(any(test, feature = "__test-helpers"))] use dpp::prelude::Identifier; #[cfg(feature = "__test-helpers")] use platform_wallet::changeset::{ @@ -24,9 +27,9 @@ use platform_wallet::changeset::{ }; #[cfg(feature = "__test-helpers")] use platform_wallet::wallet::identity::{ContactRequest, EstablishedContact}; -#[cfg(feature = "__test-helpers")] +#[cfg(any(test, feature = "__test-helpers"))] use rusqlite::Connection; -#[cfg(feature = "__test-helpers")] +#[cfg(any(test, feature = "__test-helpers"))] use std::collections::BTreeMap; /// Single source of truth for the `contacts.state` TEXT-column domain. @@ -140,14 +143,24 @@ pub fn apply( } } if !cs.removed_sent.is_empty() { + // Pending-sent tombstone. State-filtered: the contacts table is one + // row per pair, so an unfiltered DELETE would also destroy an + // `established` row — both request blobs plus the user's + // alias/note/hidden/accepted-accounts — if an emitter ever produced + // a pending tombstone for an established pair. The changeset + // contract says `removed_sent` means "the pending sent entry was + // removed", so the writer only ever deletes a pending-sent row. + let sent = contact_state_db_label(ContactState::Sent); let mut stmt = tx.prepare_cached( - "DELETE FROM contacts WHERE wallet_id = ?1 AND owner_id = ?2 AND contact_id = ?3", + "DELETE FROM contacts \ + WHERE wallet_id = ?1 AND owner_id = ?2 AND contact_id = ?3 AND state = ?4", )?; for key in &cs.removed_sent { stmt.execute(params![ wallet_id.as_slice(), key.owner_id.as_slice(), key.recipient_id.as_slice(), + sent, ])?; } } @@ -180,14 +193,20 @@ pub fn apply( } } if !cs.removed_incoming.is_empty() { + // Pending-received tombstone. State-filtered for the same reason + // as `removed_sent` above: never let a pending tombstone destroy + // an `established` pair-row. + let received = contact_state_db_label(ContactState::Received); let mut stmt = tx.prepare_cached( - "DELETE FROM contacts WHERE wallet_id = ?1 AND owner_id = ?2 AND contact_id = ?3", + "DELETE FROM contacts \ + WHERE wallet_id = ?1 AND owner_id = ?2 AND contact_id = ?3 AND state = ?4", )?; for key in &cs.removed_incoming { stmt.execute(params![ wallet_id.as_slice(), key.owner_id.as_slice(), key.sender_id.as_slice(), + received, ])?; } } @@ -199,8 +218,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 +227,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 +245,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 +301,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 +345,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 +359,16 @@ pub(crate) fn load_state( note, is_hidden, accepted_accounts, + payment_channel_broken, + // System-derived incoming-only label; this backend has + // no column for it, so it restores empty and re-derives + // on the next contact-info sweep. + contact_account_label: None, + // Rotation self-heal marker; this backend has no column + // for it, so it restores `None` — which conservatively + // forces the next sweep to re-verify (tear down + rebuild) + // the external account once, then re-stamp it. + external_account_reference: None, }, ); } @@ -329,7 +396,9 @@ fn decode_request( } } -#[cfg(feature = "__test-helpers")] +// Widened to `any(test, …)` alongside `load_ignored_senders`, its only +// `test`-arm caller (the `__test-helpers`-gated readers use it too). +#[cfg(any(test, feature = "__test-helpers"))] fn decode_pair_key(a: &[u8], b: &[u8]) -> Result<(Identifier, Identifier), WalletStorageError> { let a32 = <[u8; 32]>::try_from(a) .map_err(|_| WalletStorageError::blob_decode("contacts.id column is not 32 bytes"))?; @@ -349,6 +418,36 @@ pub fn load_state_for_test( load_state(conn, wallet_id) } +/// Read the wallet's `ignored_senders` rows, grouped per owner identity. +/// +/// This table — not the `ignored_senders` snapshot inside the identity +/// `entry_blob` — is the AUTHORITATIVE ignore record at load time: every +/// ignore INSERTs a row and every un-ignore DELETEs it transactionally, +/// while the blob is only as fresh as the last identity-entry flush. +/// An un-ignore never re-flushes the identity entry (it persists only the +/// `ContactChangeSet`), and `IdentityChangeSet::merge` UNIONs the blob's +/// ignored set across buffered snapshots — so a blob-based restore would +/// resurrect un-ignored senders, stickily (the next snapshot re-persists +/// the resurrected entry). The identity loader therefore restores the +/// ignored set from this reader and disregards the blob field. +#[cfg(any(test, feature = "__test-helpers"))] +pub(crate) fn load_ignored_senders( + conn: &Connection, + wallet_id: &WalletId, +) -> Result>, WalletStorageError> { + let mut stmt = + conn.prepare("SELECT owner_id, sender_id FROM ignored_senders WHERE wallet_id = ?1")?; + let mut map: BTreeMap> = BTreeMap::new(); + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let owner: Vec = row.get(0)?; + let sender: Vec = row.get(1)?; + let (owner, sender) = decode_pair_key(&owner, &sender)?; + map.entry(owner).or_default().insert(sender); + } + Ok(map) +} + #[cfg(test)] mod tests { use super::*; @@ -384,4 +483,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..72b22435f27 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -158,6 +158,11 @@ pub fn load_state( let mut stmt = conn.prepare( "SELECT identity_id, entry_blob, tombstoned FROM identities WHERE wallet_id = ?1", )?; + // The ignored-senders TABLE is the authoritative ignore record (every + // ignore/un-ignore maintains it transactionally); the `entry_blob`'s + // snapshot copy can be stale — see `contacts::load_ignored_senders`. + let mut ignored_by_owner = + crate::sqlite::schema::contacts::load_ignored_senders(conn, wallet_id)?; let mut state = IdentityManagerStartState::default(); let mut rows = stmt.query(params![wallet_id.as_slice()])?; while let Some(row) = rows.next()? { @@ -168,7 +173,8 @@ pub fn load_state( continue; } let entry: IdentityEntry = blob::decode(&payload)?; - let managed = managed_identity_from_entry(&entry, wallet_id); + let ignored = ignored_by_owner.remove(&entry.id).unwrap_or_default(); + let managed = managed_identity_from_entry(&entry, wallet_id, ignored); match entry.identity_index { Some(idx) => { state @@ -193,6 +199,7 @@ pub fn load_state( fn managed_identity_from_entry( entry: &IdentityEntry, wallet_id: &WalletId, + ignored_senders: std::collections::BTreeSet, ) -> platform_wallet::wallet::identity::ManagedIdentity { use dpp::identity::v0::IdentityV0; use dpp::identity::Identity; @@ -203,21 +210,42 @@ fn managed_identity_from_entry( balance: entry.balance, revision: entry.revision, }); - ManagedIdentity { - identity, - identity_index: entry.identity_index, - last_updated_balance_block_time: entry.last_updated_balance_block_time, - last_synced_keys_block_time: entry.last_synced_keys_block_time, - established_contacts: Default::default(), - sent_contact_requests: Default::default(), - incoming_contact_requests: Default::default(), - 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(), + let mut managed = match entry.identity_index { + Some(index) => ManagedIdentity::new(identity, index), + None => ManagedIdentity::new_out_of_wallet(identity), + }; + managed.last_updated_balance_block_time = entry.last_updated_balance_block_time; + managed.last_synced_keys_block_time = entry.last_synced_keys_block_time; + managed.status = entry.status; + managed.dpns_names = entry.dpns_names.clone(); + managed.contested_dpns_names = entry.contested_dpns_names.clone(); + managed.wallet_id = entry.wallet_id.or(Some(*wallet_id)); + // Scalar-snapshot collections ride the identity `entry_blob` + // (payments / profile / contact_profiles), so they restore from + // `entry`. The relational request collections are loaded separately + // from the `contacts` table and stay defaulted here. + // High-water sync cursors, the per-session rescan guard, the + // verify-failed auto-accept markers, and the deferred contact-crypto + // queue (not persisted; a signerless sweep re-enqueues its ops on + // load) are in-memory by design: a cold restore starts them at their + // defaults so the next sweep re-fetches / re-evaluates safely. + // + // Ignored senders restore from the `ignored_senders` TABLE (passed in + // by the loader), NOT from `entry.ignored_senders`: an un-ignore + // deletes only the table row (no fresh identity-entry flush), and the + // changeset merge UNIONs the blob's set across buffered snapshots — + // so the blob copy can resurrect an un-ignored sender. The table is + // maintained transactionally by both the ignore and un-ignore writers + // and is therefore authoritative. The constructor starts a fresh + // empty ignored set, so per-element apply reproduces the table's set + // exactly. + for sender in &ignored_senders { + managed.apply_ignored_sender(*sender); } + *managed.dashpay_profile_mut() = entry.dashpay_profile.clone(); + *managed.dashpay_payments_mut() = entry.dashpay_payments.clone(); + *managed.dashpay_contact_profiles_mut() = entry.contact_profiles.clone(); + managed } /// Insert a stub identity row so identity_keys / dashpay_profiles can @@ -248,6 +276,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..78cb72c2338 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 @@ -184,4 +184,43 @@ mod tests { "expected BlobDecode for trailing-byte garbage, got {err:?}" ); } + + /// `IdentityKeyEntry` carries no key material by construction + /// (derive-sign-destroy removed the carried scalar; the client derives it + /// on demand from the keychain), so the "no key material at rest outside + /// the keychain" guarantee is enforced at the type level and the wire + /// shape only has the breadcrumb metadata to preserve. Pins that a + /// `from_entry` → `into_entry` round-trip keeps the `(wallet_id, + /// derivation_indices)` breadcrumb intact. + #[test] + fn wire_round_trip_preserves_breadcrumb_metadata() { + 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, + }), + }; + + let wire = IdentityKeyWire::from_entry(&entry).expect("encode wire"); + let restored = wire.into_entry().expect("decode wire"); + + // The breadcrumb metadata 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..51fba47e8a2 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs @@ -0,0 +1,238 @@ +//! `pending_contact_crypto` deferred-crypto queue writer + reader. +//! +//! The signerless 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`. Holds nothing +//! sensitive — 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 each identity's +/// each identity's `DashPayState.pending_contact_crypto`, fanned out by `owner_identity_id` +/// (this reader returns entries grouped by `wallet_id`; the restore must apply +/// the wallet's identities BEFORE routing each entry to its owner's queue, or an +/// entry whose owner isn't resident yet is dropped). It 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/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 9a719306e0b..1363f4a6693 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -64,6 +64,14 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "SELECT identity_id, entry_blob, tombstoned", ), ("contacts.rs", "SELECT owner_id, contact_id, state"), + ( + "contacts.rs", + "SELECT owner_id, sender_id FROM ignored_senders", + ), + ( + "pending_contact_crypto.rs", + "SELECT wallet_id, payload FROM pending_contact_crypto", + ), ]; /// TC-P1-003: writer paths in `src/sqlite/schema/*.rs` must not call 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..97cfaf974a7 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,17 @@ 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, + // This backend has no column for the system-derived account label, + // so the read path always reconstructs it as `None`; keep the + // fixture `None` to match through the round-trip. + contact_account_label: None, + // This backend has no column for the rotation self-heal marker, so + // the read path always reconstructs it as `None`; keep the fixture + // `None` to match through the round-trip. + external_account_reference: None, } } @@ -1389,3 +1402,243 @@ fn tc_p4_010_empty_db_default_state() { assert!(logs_contain("wallets_seen=0")); assert!(logs_contain("wallets_pending_rehydration=0")); } + +/// A pending tombstone must never destroy an ESTABLISHED pair-row. The +/// contacts table is one row per `(owner, contact)` pair, so an unfiltered +/// `removed_incoming` / `removed_sent` DELETE aimed at a pending entry +/// would take the established row — both request blobs plus the user's +/// alias/note/hidden/accepted-accounts — with it (the ignore-an- +/// established-contact shape). The writer's state filter must scope the +/// DELETE to the pending state the tombstone describes. Was red against +/// the unfiltered DELETE. +#[test] +fn pending_tombstones_do_not_destroy_established_rows() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA7); + ensure_wallet_meta(&persister, &w); + + let owner = Identifier::from([0x51; 32]); + let contact = Identifier::from([0x52; 32]); + let est_key = SentContactRequestKey { + owner_id: owner, + recipient_id: contact, + }; + + // Flush 1: the pair is established, with full user metadata. + let mut established = std::collections::BTreeMap::new(); + established.insert(est_key, established_contact(0x51, 0x52)); + persister + .store( + w, + PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + established, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + // Flush 2: pending tombstones for the SAME pair (both directions — + // e.g. an `ignore_sender` emitted against an established contact by a + // pre-guard caller). Neither may touch the established row. + let mut cs = ContactChangeSet::default(); + cs.removed_incoming.insert(ReceivedContactRequestKey { + owner_id: owner, + sender_id: contact, + }); + cs.removed_sent.insert(SentContactRequestKey { + owner_id: owner, + recipient_id: contact, + }); + persister + .store( + w, + PlatformWalletChangeSet { + contacts: Some(cs), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &w) + .expect("contacts load_state"); + let survived = state + .established + .get(&est_key) + .expect("established row must survive pending tombstones"); + assert_eq!(survived.alias, Some("best friend".to_string())); + assert_eq!(survived.note, Some("met at conf".to_string())); + assert_eq!(survived.accepted_accounts, vec![1, 7, 42]); + + // Control: the same tombstones DO delete genuinely-pending rows. + let (persister, _tmp2, path2) = fresh_persister(); + let w2 = wid(0xA8); + ensure_wallet_meta(&persister, &w2); + let mut incoming = std::collections::BTreeMap::new(); + incoming.insert( + ReceivedContactRequestKey { + owner_id: owner, + sender_id: contact, + }, + contact_request_entry(0x52, 0x51), + ); + persister + .store( + w2, + PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + incoming_requests: incoming, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + let mut cs = ContactChangeSet::default(); + cs.removed_incoming.insert(ReceivedContactRequestKey { + owner_id: owner, + sender_id: contact, + }); + persister + .store( + w2, + PlatformWalletChangeSet { + contacts: Some(cs), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let p3 = reopen(&path2); + let conn = p3.lock_conn_for_test(); + let state = platform_wallet_storage::sqlite::schema::contacts::load_state_for_test(&conn, &w2) + .expect("contacts load_state"); + assert!( + state.incoming_requests.is_empty(), + "a received-state row is still deleted by its tombstone" + ); +} + +/// Un-ignore must be durable across a restart: the `ignored_senders` +/// TABLE (maintained transactionally by both ignore and un-ignore) is the +/// authoritative restore source — NOT the `entry_blob`'s snapshot copy, +/// which goes stale on un-ignore (nothing re-flushes the identity entry) +/// and is UNION-merged across buffered snapshots. Blob-based restore +/// resurrected the ignore, stickily. Was red against the blob-based +/// loader. +#[test] +fn tc_p4_020_unignore_survives_restart_table_is_authoritative() { + use platform_wallet_storage::sqlite::schema::identities; + use std::collections::BTreeMap; + use std::collections::BTreeSet; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + + let owner = Identifier::from([0x61; 32]); + let sender = Identifier::from([0x62; 32]); + + // Flush 1: the identity entry snapshot carries the sender as ignored + // (as a live sweep would have persisted it), and the contacts + // changeset records the ignore in the table. + let mut entry = identity_entry(0x61, Some(0)); + entry.ignored_senders = BTreeSet::from([sender]); + let mut identities = BTreeMap::new(); + identities.insert(entry.id, entry); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities, + removed: Default::default(), + }), + contacts: Some(ContactChangeSet { + ignored: BTreeSet::from([(owner, sender)]), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + // Flush 2: the user un-ignores. Production persists ONLY the contact + // changeset (no fresh identity snapshot) — the blob still lists the + // sender as ignored. + persister + .store( + w, + PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + unignored: BTreeSet::from([(owner, sender)]), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + // Restart: the restored identity must NOT have the sender ignored — + // the table row was deleted; the stale blob copy must not resurrect it. + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let state = identities::load_state(&conn, &w).expect("load_state"); + drop(conn); + let managed = state + .wallet_identities + .get(&w) + .and_then(|bucket| bucket.get(&0)) + .expect("restored identity"); + assert!( + !managed.is_sender_ignored(&sender), + "un-ignore must survive a restart — the stale entry_blob snapshot \ + must not resurrect the ignore" + ); + + // Control (same loader, opposite direction): with the table row still + // present, the restore DOES ignore the sender — even when the blob + // snapshot never recorded it (blob older than the ignore). + let (persister, _tmp2, path2) = fresh_persister(); + let w2 = wid(0xC2); + ensure_wallet_meta(&persister, &w2); + let entry = identity_entry(0x61, Some(0)); // blob: no ignored senders + let mut identities = BTreeMap::new(); + identities.insert(entry.id, entry); + persister + .store( + w2, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities, + removed: Default::default(), + }), + contacts: Some(ContactChangeSet { + ignored: BTreeSet::from([(owner, sender)]), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let p3 = reopen(&path2); + let conn = p3.lock_conn_for_test(); + let state = identities::load_state(&conn, &w2).expect("load_state"); + drop(conn); + let managed = state + .wallet_identities + .get(&w2) + .and_then(|bucket| bucket.get(&0)) + .expect("restored identity"); + assert!( + managed.is_sender_ignored(&sender), + "an ignore recorded in the table restores even when the blob snapshot predates it" + ); +} 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..eb36c856894 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -361,6 +361,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..d4c589323ce 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 { @@ -327,8 +341,10 @@ impl IdentityEntry { contested_dpns_names: managed.contested_dpns_names.clone(), status: managed.status, wallet_id: managed.wallet_id, - dashpay_profile: managed.dashpay_profile.clone(), - dashpay_payments: managed.dashpay_payments.clone(), + dashpay_profile: managed.dashpay().profile.clone(), + dashpay_payments: managed.dashpay().payments.clone(), + contact_profiles: managed.dashpay().contact_profiles.clone(), + ignored_senders: managed.dashpay().ignored_senders().clone(), } } } @@ -349,19 +365,37 @@ 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 — the unit +/// `ManagedIdentity::add_keys` consumes and `discovery::breadcrumb_decisions` +/// produces. +/// +/// Discovery derives a candidate scalar, validates it against the on-chain +/// key, and emits a breadcrumb (the DIP-9 coordinates) only when it matches. +/// The scalar itself is never carried out — the client derives the key on +/// demand from the Keychain seed at the breadcrumb path. `breadcrumb` is +/// `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, +} + /// 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 and a breadcrumb pointing at the wallet +/// derivation that produced it. No private material crosses here: the client +/// derives the key on demand from the Keychain seed at the breadcrumb path +/// (`m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'`). When +/// `derivation_indices` is `None` the key is watch-only from this wallet's +/// point of view. +#[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeyEntry { /// Owning identity. @@ -495,6 +529,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); } @@ -568,21 +617,20 @@ pub struct ReceivedContactRequestKey { /// pair, so `apply_changeset` can reconstruct the contact without /// access to any prior runtime state. /// -/// # Merge ordering hazard -/// -/// `ContactChangeSet::merge` is a pure `extend` over every field — it -/// does NOT cancel an insert against a same-key tombstone in the -/// opposing field. Callers must NOT merge a `removed_sent` for key K -/// followed by a `sent_requests` insert for key K and expect the -/// insert to win: apply runs inserts before removes, so the final -/// state is "removed", losing the intended re-send. The same applies -/// to `incoming_requests` vs `removed_incoming`. +/// # Merge reconciliation /// -/// In practice this is latent — every current emitter produces either -/// an insert XOR a tombstone for a given key in a single mutation, -/// not both. If a future caller needs the merged-cancellation -/// semantics, the merge impl should resolve `sent_requests ∩ -/// removed_sent` by last-seen rather than carrying both. +/// Every apply layer (in-memory, SQLite, FFI projection) runs all +/// inserts before all removes, so a merged changeset that carried the +/// same key in both an insert map and its opposing tombstone set would +/// always resolve to "removed". `ContactChangeSet::merge` therefore +/// reconciles each insert-vs-tombstone pair last-write-wins per key: +/// the newer delta's action for a key cancels the older opposing action +/// (a `sent_requests` insert clears a prior `removed_sent`, an un-ignore +/// clears a prior ignore, and vice versa), keeping the two sets +/// disjoint. This covers `sent_requests` vs `removed_sent`, +/// `incoming_requests` vs `removed_incoming`, and `ignored` vs +/// `unignored`. `established` has no opposing tombstone set and rides +/// plain last-write-wins. #[derive(Debug, Clone, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ContactChangeSet { @@ -600,15 +648,58 @@ 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. + /// Reconciled last-write-wins against [`Self::unignored`] 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 { fn merge(&mut self, other: Self) { + // Insert-vs-tombstone pairs are reconciled last-write-wins per key: + // `other` is the newer delta, so a key it inserts cancels an older + // same-key tombstone and vice versa. Without this the two sets could + // both carry the same key and apply (which runs inserts before + // removes at every layer) would always resolve to "removed" — losing + // a re-send / re-ignore that happened after a remove / un-ignore. + // The three pairs share this idiom; `established` has no opposing + // tombstone set and rides plain last-write-wins. + for key in other.removed_sent.iter() { + self.sent_requests.remove(key); + } + for key in other.sent_requests.keys() { + self.removed_sent.remove(key); + } self.sent_requests.extend(other.sent_requests); self.removed_sent.extend(other.removed_sent); + + for key in other.removed_incoming.iter() { + self.incoming_requests.remove(key); + } + for key in other.incoming_requests.keys() { + self.removed_incoming.remove(key); + } self.incoming_requests.extend(other.incoming_requests); self.removed_incoming.extend(other.removed_incoming); + self.established.extend(other.established); + + for key in other.unignored.iter() { + self.ignored.remove(key); + } + for key in other.ignored.iter() { + self.unignored.remove(key); + } + self.ignored.extend(other.ignored); + self.unignored.extend(other.unignored); } fn is_empty(&self) -> bool { @@ -617,6 +708,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 +997,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 +1182,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 +1293,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 +1320,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 +1344,154 @@ 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"), + } + } + #[test] fn test_platform_address_changeset_merge() { let wallet_id: WalletId = [9u8; 32]; @@ -1170,6 +1550,210 @@ mod tests { assert!(a.removed_balances.contains(&(identity_b, token_y))); } + fn ignore_key() -> (Identifier, Identifier) { + (Identifier::from([0xAA; 32]), Identifier::from([0xBB; 32])) + } + + /// ignore → un-ignore for the same key resolves to exactly "un-ignored": + /// the newer un-ignore cancels the older ignore, so the key lands in + /// `unignored` only and never in `ignored`. Without cancellation the key + /// would sit in both sets and apply (inserts before removes) would drop + /// the block. + #[test] + fn contact_merge_ignore_then_unignore_last_write_wins() { + let key = ignore_key(); + + let mut base = ContactChangeSet { + ignored: BTreeSet::from([key]), + ..Default::default() + }; + let newer = ContactChangeSet { + unignored: BTreeSet::from([key]), + ..Default::default() + }; + + base.merge(newer); + + assert!( + !base.ignored.contains(&key), + "the newer un-ignore must clear the older ignore" + ); + assert!(base.unignored.contains(&key), "the key ends up un-ignored"); + assert_eq!(base.ignored.len(), 0); + assert_eq!(base.unignored.len(), 1); + } + + /// un-ignore → re-ignore for the same key resolves to exactly "ignored": + /// the newer ignore cancels the older un-ignore (the F4 case — a + /// transient-flush re-merge of an un-ignore followed by a re-ignore must + /// keep the sender blocked). + #[test] + fn contact_merge_unignore_then_ignore_last_write_wins() { + let key = ignore_key(); + + let mut base = ContactChangeSet { + unignored: BTreeSet::from([key]), + ..Default::default() + }; + let newer = ContactChangeSet { + ignored: BTreeSet::from([key]), + ..Default::default() + }; + + base.merge(newer); + + assert!( + base.ignored.contains(&key), + "the newer re-ignore must win over the older un-ignore" + ); + assert!( + !base.unignored.contains(&key), + "the newer re-ignore must clear the older un-ignore" + ); + assert_eq!(base.ignored.len(), 1); + assert_eq!(base.unignored.len(), 0); + } + + /// Cancellation is per key: an un-ignore of one sender must not disturb a + /// separate sender's ignore carried in the same merge. + #[test] + fn contact_merge_ignore_cancellation_is_per_key() { + let blocked = (Identifier::from([1u8; 32]), Identifier::from([2u8; 32])); + let unblocked = (Identifier::from([1u8; 32]), Identifier::from([3u8; 32])); + + let mut base = ContactChangeSet { + ignored: BTreeSet::from([blocked, unblocked]), + ..Default::default() + }; + let newer = ContactChangeSet { + unignored: BTreeSet::from([unblocked]), + ..Default::default() + }; + + base.merge(newer); + + assert!( + base.ignored.contains(&blocked), + "untouched sender stays ignored" + ); + assert!(!base.ignored.contains(&unblocked)); + assert!(base.unignored.contains(&unblocked)); + } + + fn sent_key() -> SentContactRequestKey { + SentContactRequestKey { + owner_id: Identifier::from([1u8; 32]), + recipient_id: Identifier::from([2u8; 32]), + } + } + + fn sent_entry() -> ContactRequestEntry { + ContactRequestEntry { + request: ContactRequest::new( + Identifier::from([1u8; 32]), + Identifier::from([2u8; 32]), + 0, + 0, + 0, + vec![0u8; 96], + 100_000, + 0, + ), + } + } + + /// remove-sent → re-send for the same key resolves to exactly "sent": the + /// newer insert cancels the older tombstone, so the re-send survives apply + /// (which runs inserts before removes). + #[test] + fn contact_merge_remove_sent_then_resend_last_write_wins() { + let key = sent_key(); + + let mut base = ContactChangeSet { + removed_sent: BTreeSet::from([key]), + ..Default::default() + }; + let mut newer = ContactChangeSet::default(); + newer.sent_requests.insert(key, sent_entry()); + + base.merge(newer); + + assert!( + base.sent_requests.contains_key(&key), + "the newer re-send must win over the older tombstone" + ); + assert!( + !base.removed_sent.contains(&key), + "the newer re-send must clear the older tombstone" + ); + } + + /// send → remove-sent for the same key resolves to exactly "removed": the + /// newer tombstone cancels the older insert. + #[test] + fn contact_merge_send_then_remove_sent_last_write_wins() { + let key = sent_key(); + + let mut base = ContactChangeSet::default(); + base.sent_requests.insert(key, sent_entry()); + let newer = ContactChangeSet { + removed_sent: BTreeSet::from([key]), + ..Default::default() + }; + + base.merge(newer); + + assert!( + !base.sent_requests.contains_key(&key), + "the newer tombstone must clear the older insert" + ); + assert!(base.removed_sent.contains(&key)); + } + + /// Same last-write-wins reconciliation for the incoming pair + /// (`incoming_requests` vs `removed_incoming`). + #[test] + fn contact_merge_incoming_insert_vs_tombstone_last_write_wins() { + let key = ReceivedContactRequestKey { + owner_id: Identifier::from([2u8; 32]), + sender_id: Identifier::from([1u8; 32]), + }; + let entry = ContactRequestEntry { + request: ContactRequest::new( + Identifier::from([1u8; 32]), + Identifier::from([2u8; 32]), + 0, + 0, + 0, + vec![0u8; 96], + 100_000, + 0, + ), + }; + + // tombstone then re-insert → insert wins. + let mut base = ContactChangeSet { + removed_incoming: BTreeSet::from([key]), + ..Default::default() + }; + let mut newer = ContactChangeSet::default(); + newer.incoming_requests.insert(key, entry.clone()); + base.merge(newer); + assert!(base.incoming_requests.contains_key(&key)); + assert!(!base.removed_incoming.contains(&key)); + + // insert then tombstone → tombstone wins. + let mut base = ContactChangeSet::default(); + base.incoming_requests.insert(key, entry); + let newer = ContactChangeSet { + removed_incoming: BTreeSet::from([key]), + ..Default::default() + }; + base.merge(newer); + assert!(!base.incoming_requests.contains_key(&key)); + assert!(base.removed_incoming.contains(&key)); + } + #[test] fn test_take_empty_changeset() { let mut cs = PlatformWalletChangeSet::default(); 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 30c6ead44f4..473e82e5491 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), @@ -165,6 +175,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 04d3ef3a2d2..fb1077a1ae7 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -21,6 +21,7 @@ pub mod manager; pub mod spv; #[cfg(test)] pub(crate) mod test_support; +mod util; pub mod wallet; pub use error::PlatformWalletError; @@ -37,6 +38,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, @@ -57,12 +62,14 @@ 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, + derive_contact_payment_addresses, derive_contact_xpub, pubkey_binds_expected_key_data, + unmask_account_reference, BlockTime, ContactProfileEntry, ContactRequest, ContactXpubData, + DashPayProfile, DashPayState, DpnsNameInfo, EstablishedContact, IdentityLocation, IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PrivateKeyData, ProfileUpdate, RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, }; 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..00c861dee72 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -0,0 +1,758 @@ +//! 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, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use tokio::sync::RwLock; + +use crate::error::PlatformWalletError; +use crate::manager::loop_cancel::LoopCancelGuard; +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>>>, + /// Generation-guarded cancel-token slot for the background loop — + /// see [`LoopCancelGuard`] for the stale-loop shutdown invariant. + cancel_guard: LoopCancelGuard, + 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, + /// Unix seconds of the last completed pass. `0` = never. + last_sync_unix: AtomicU64, +} + +impl DashPaySyncManager { + pub fn new(wallets: Arc>>>) -> Self { + Self { + wallets, + cancel_guard: LoopCancelGuard::new(), + interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), + is_syncing: AtomicBool::new(false), + quiescing: AtomicBool::new(false), + 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.cancel_guard.is_running() + } + + /// 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.cancel_guard.install() 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.cancel_guard.clear_if_current(my_generation); + }); + }) + .expect("failed to spawn dashpay-sync thread"); + } + + /// 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.cancel_guard.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.dashpay().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.dashpay().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.dashpay().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.dashpay().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.dashpay().reconcile_incoming_payments().await { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DashPay incoming-payment reconcile failed" + ); + } + + // Local-only: DIP-15 §12.6 coreHeight backfill — lower SPV synced_height + // to re-scan for incoming payments that landed on a contact's receival + // address before it was watched (restore-from-seed / 2nd device / + // offline-accept→pay). After the reconcile above so newly established + // receival accounts are visible; a per-contact guard prevents + // re-triggering and thrashing the in-flight backfill. + if let Err(e) = identity.dashpay().reconcile_dashpay_rescan().await { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DashPay rescan reconcile failed" + ); + } + + // Local-only: confirm `Pending` `Sent` payments the persisted core + // record reports final (mined or InstantSend-locked). + if let Err(e) = identity.dashpay().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 (the guard's `install` / + /// `clear_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. The pure-guard variant + /// lives with [`LoopCancelGuard`]; this one pins the manager-level + /// wiring (`stop()` / `is_running()` route through the guard). + #[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 + .cancel_guard + .install() + .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 + .cancel_guard + .install() + .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.cancel_guard.clear_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/identity_sync.rs b/packages/rs-platform-wallet/src/manager/identity_sync.rs index 8730398f978..9fe2efa1a6d 100644 --- a/packages/rs-platform-wallet/src/manager/identity_sync.rs +++ b/packages/rs-platform-wallet/src/manager/identity_sync.rs @@ -49,14 +49,13 @@ use std::collections::BTreeMap; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Mutex as StdMutex, + Arc, }; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use dpp::balances::credits::TokenAmount; use dpp::prelude::Identifier; use tokio::sync::RwLock; -use tokio_util::sync::CancellationToken; use dash_sdk::platform::tokens::identity_token_balances::{ IdentityTokenBalances, IdentityTokenBalancesQuery, @@ -64,6 +63,7 @@ use dash_sdk::platform::tokens::identity_token_balances::{ use dash_sdk::platform::FetchMany; use crate::changeset::{PlatformWalletPersistence, TokenBalanceChangeSet}; +use crate::manager::loop_cancel::LoopCancelGuard; use crate::wallet::platform_wallet::WalletId; /// Default cadence for the identity-token sync loop. @@ -158,12 +158,9 @@ where /// over `P` so every `persister.store(...)` call on the hot sync /// loop dispatches statically. persister: Arc

, - /// Cancel token for the background loop, if running. - background_cancel: StdMutex>, - /// Monotonically increasing generation counter. Incremented each - /// time `start()` installs a new cancel token so the exiting - /// thread can tell whether its token is still current. - background_generation: AtomicU64, + /// Generation-guarded cancel-token slot for the background loop — + /// see [`LoopCancelGuard`] for the stale-loop shutdown invariant. + cancel_guard: LoopCancelGuard, interval_secs: AtomicU64, is_syncing: AtomicBool, /// Set by [`quiesce`](Self::quiesce) to gate new passes while it @@ -203,8 +200,7 @@ where Self { sdk, persister, - background_cancel: StdMutex::new(None), - background_generation: AtomicU64::new(0), + cancel_guard: LoopCancelGuard::new(), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), quiescing: AtomicBool::new(false), @@ -320,10 +316,7 @@ where /// 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) + self.cancel_guard.is_running() } /// Whether a sync pass is in flight right now. @@ -395,14 +388,9 @@ where /// The first pass runs immediately; subsequent passes fire every /// [`interval`](Self::interval). pub fn start(self: Arc) { - let mut guard = self.background_cancel.lock().expect("bg_cancel poisoned"); - if guard.is_some() { + let Some((cancel, my_generation)) = self.cancel_guard.install() else { return; - } - let cancel = CancellationToken::new(); - *guard = Some(cancel.clone()); - let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1; - drop(guard); + }; let handle = tokio::runtime::Handle::current(); let this = self; @@ -424,13 +412,7 @@ where } } - // Only clear the slot if no newer start() has - // installed a replacement token since we launched. - if let Ok(mut guard) = this.background_cancel.lock() { - if this.background_generation.load(Ordering::Acquire) == my_gen { - *guard = None; - } - } + this.cancel_guard.clear_if_current(my_generation); }); }) .expect("failed to spawn identity-sync thread"); @@ -445,12 +427,7 @@ where /// 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() - { + if let Some(token) = self.cancel_guard.take() { token.cancel(); } } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 8e7af9be1c7..f355eca497a 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -183,7 +183,13 @@ impl PlatformWalletManager

{ if !inserted_in_manager.is_empty() { let mut wm = self.wallet_manager.write().await; for id in &inserted_in_manager { - let _ = wm.remove_wallet(id); + if let Err(e) = wm.remove_wallet(id) { + tracing::warn!( + wallet_id = %hex::encode(id), + error = %e, + "rollback after load failure: remove_wallet failed" + ); + } } } return Err(err); diff --git a/packages/rs-platform-wallet/src/manager/loop_cancel.rs b/packages/rs-platform-wallet/src/manager/loop_cancel.rs new file mode 100644 index 00000000000..b8b7e114cce --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/loop_cancel.rs @@ -0,0 +1,164 @@ +//! Generation-guarded cancel-token slot shared by the background sync +//! managers ([`DashPaySyncManager`](super::dashpay_sync::DashPaySyncManager), +//! [`IdentitySyncManager`](super::identity_sync::IdentitySyncManager), +//! [`PlatformAddressSyncManager`](super::platform_address_sync::PlatformAddressSyncManager), +//! and the shielded coordinator). +//! +//! Every manager runs its loop on a dedicated OS thread whose exit is +//! asynchronous with respect to `stop()`/`start()` calls, so they all +//! share the same shutdown hazard — and must all share the same guard. +//! Keeping the invariant in one type (instead of a per-manager copy) +//! means a fix or audit here covers every loop at once. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex as StdMutex; + +use tokio_util::sync::CancellationToken; + +/// Cancel-token slot for a background sync loop, guarded by a +/// monotonically increasing **loop generation** so a stale, draining +/// loop can never clobber a newer loop's token. +/// +/// Without the 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 `slot = 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(...)` (or firing host callbacks) +/// through a freed FFI context. +pub(crate) struct LoopCancelGuard { + /// The active loop's cancel token, if one is running. + slot: StdMutex>, + /// Bumped on every [`install`](Self::install). The background loop + /// captures its generation at install time and clears the slot on + /// exit **only if its generation is still current** (see + /// [`clear_if_current`](Self::clear_if_current)). + generation: AtomicU64, +} + +impl LoopCancelGuard { + pub fn new() -> Self { + Self { + slot: StdMutex::new(None), + generation: AtomicU64::new(0), + } + } + + /// 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_if_current`](Self::clear_if_current) on + /// exit). Returns `None` if a loop is already running — preserving + /// the managers' `start()` idempotency. + /// + /// The generation bump happens under the same 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. + pub fn install(&self) -> Option<(CancellationToken, u64)> { + let mut guard = self.slot.lock().expect("bg_cancel poisoned"); + if guard.is_some() { + return None; + } + let cancel = CancellationToken::new(); + *guard = Some(cancel.clone()); + let generation = self + .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 `install` + /// has handed out a replacement. Called by the loop on exit. + pub fn clear_if_current(&self, my_generation: u64) { + let mut guard = self.slot.lock().expect("bg_cancel poisoned"); + if self.generation.load(Ordering::Acquire) == my_generation { + *guard = None; + } + } + + /// Take the active loop's token out of the slot, if any. The + /// managers' cancel-only `stop()` is `take()` + `token.cancel()`. + pub fn take(&self) -> Option { + self.slot.lock().expect("bg_cancel poisoned").take() + } + + /// Whether a loop's token is currently installed. + pub fn is_running(&self) -> bool { + self.slot.lock().map(|g| g.is_some()).unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression: a stale, draining loop's cleanup must **not** clobber + /// a newer loop's cancel token. + /// + /// We drive the token lifecycle directly (`install` / + /// `clear_if_current`) rather than spawning a real loop: the loops + /// run on OS threads under `Handle::block_on`, so their 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. + #[test] + fn stale_loop_cleanup_does_not_clobber_newer_loop_token() { + let slot = LoopCancelGuard::new(); + + // Loop A starts: installs token_A at generation G_A. + let (token_a, gen_a) = slot.install().expect("first install starts a loop"); + assert!(slot.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. + slot.take().expect("token_A installed").cancel(); + assert!(token_a.is_cancelled()); + assert!(!slot.is_running(), "take() clears the slot immediately"); + + // Loop B starts BEFORE loop A's cleanup runs: installs token_B + // at a newer generation G_B. + let (token_b, _gen_b) = slot.install().expect("second install starts a new loop"); + assert!(slot.is_running()); + + // Loop A FINALLY drains and runs its cleanup with its own (now + // stale) generation. The guard must make this a no-op; an + // unconditional clear would null loop B's token here. + slot.clear_if_current(gen_a); + + // Loop B's token must still be installed and uncancelled. + assert!( + slot.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. + slot.take().expect("token_B still installed").cancel(); + assert!( + token_b.is_cancelled(), + "loop B must remain cancellable after the stale cleanup" + ); + assert!(!slot.is_running()); + } + + /// `install` while a loop is running returns `None` (start + /// idempotency), and a loop that exits *without* being replaced + /// clears its own slot so a later install succeeds. + #[test] + fn install_is_exclusive_and_clear_reopens_slot() { + let slot = LoopCancelGuard::new(); + + let (_token, generation) = slot.install().expect("fresh slot installs"); + assert!(slot.install().is_none(), "second install must be refused"); + + slot.clear_if_current(generation); + assert!(!slot.is_running()); + assert!( + slot.install().is_some(), + "slot must be reusable after the loop clears it" + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 718993e8a4e..7962d6551f1 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -1,8 +1,10 @@ //! Multi-wallet manager with SPV coordination. pub mod accessors; +pub mod dashpay_sync; pub mod identity_sync; mod load; +mod loop_cancel; pub mod platform_address_sync; #[cfg(feature = "shielded")] pub mod shielded_sync; @@ -18,6 +20,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 +28,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 +56,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 +134,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 +162,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 +182,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")] @@ -329,9 +355,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. @@ -347,6 +374,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/platform_address_sync.rs b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs index e1a229806c2..36fe1449c28 100644 --- a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs +++ b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Mutex as StdMutex, + Arc, }; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -21,10 +21,10 @@ use key_wallet::PlatformP2PKHAddress; use crate::wallet::PlatformAddressTag; use tokio::sync::RwLock; -use tokio_util::sync::CancellationToken; use crate::error::PlatformWalletError; use crate::events::PlatformEventManager; +use crate::manager::loop_cancel::LoopCancelGuard; use crate::wallet::platform_wallet::WalletId; use crate::wallet::PlatformWallet; @@ -95,8 +95,9 @@ impl PlatformAddressSyncSummary { pub struct PlatformAddressSyncManager { wallets: Arc>>>, event_manager: Arc, - /// Cancel token for the background loop, if running. - background_cancel: StdMutex>, + /// Generation-guarded cancel-token slot for the background loop — + /// see [`LoopCancelGuard`] for the stale-loop shutdown invariant. + cancel_guard: LoopCancelGuard, interval_secs: AtomicU64, is_syncing: AtomicBool, /// Set by [`quiesce`](Self::quiesce) to gate new passes while it @@ -124,7 +125,7 @@ impl PlatformAddressSyncManager { Self { wallets, event_manager, - background_cancel: StdMutex::new(None), + cancel_guard: LoopCancelGuard::new(), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), quiescing: AtomicBool::new(false), @@ -160,10 +161,7 @@ impl PlatformAddressSyncManager { /// 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) + self.cancel_guard.is_running() } /// Whether a sync pass is in flight right now. @@ -195,13 +193,9 @@ impl PlatformAddressSyncManager { /// The first pass runs immediately; subsequent passes fire every /// [`interval`](Self::interval). pub fn start(self: Arc) { - let mut guard = self.background_cancel.lock().expect("bg_cancel poisoned"); - if guard.is_some() { + let Some((cancel, my_generation)) = self.cancel_guard.install() else { return; - } - let cancel = CancellationToken::new(); - *guard = Some(cancel.clone()); - drop(guard); + }; let handle = tokio::runtime::Handle::current(); let this = self; @@ -223,9 +217,7 @@ impl PlatformAddressSyncManager { } } - if let Ok(mut guard) = this.background_cancel.lock() { - *guard = None; - } + this.cancel_guard.clear_if_current(my_generation); }); }) .expect("failed to spawn platform-address-sync thread"); @@ -241,12 +233,7 @@ impl PlatformAddressSyncManager { /// the host can free the event-handler context — use /// [`quiesce`](Self::quiesce). pub fn stop(&self) { - if let Some(token) = self - .background_cancel - .lock() - .expect("bg_cancel poisoned") - .take() - { + if let Some(token) = self.cancel_guard.take() { token.cancel(); } } diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 482674b4322..609e9820464 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -28,14 +28,14 @@ use std::collections::BTreeMap; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Mutex as StdMutex, + Arc, }; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; -use tokio_util::sync::CancellationToken; use crate::events::PlatformEventManager; +use crate::manager::loop_cancel::LoopCancelGuard; use crate::wallet::platform_wallet::WalletId; use crate::wallet::shielded::{NetworkShieldedCoordinator, ShieldedSyncSummary}; @@ -139,16 +139,9 @@ pub struct ShieldedSyncManager { /// run first, so an empty slot guarantees no shielded state /// exists). coordinator_slot: Arc>>>, - /// Cancel token for the background loop, if running. - background_cancel: StdMutex>, - /// Monotonically increasing generation counter. Bumped on every - /// `start()` so the exiting thread can tell whether its - /// generation is still the active one before clearing - /// `background_cancel`. Without this, a `stop()` → `start()` - /// overlap lets the prior thread's cleanup strip the new - /// generation's token, leaving the new loop running but - /// untrackable via `is_running()`. - background_generation: AtomicU64, + /// Generation-guarded cancel-token slot for the background loop — + /// see [`LoopCancelGuard`] for the stale-loop shutdown invariant. + cancel_guard: LoopCancelGuard, interval_secs: AtomicU64, is_syncing: AtomicBool, /// Set by [`quiesce`](Self::quiesce) to gate new passes while it @@ -170,8 +163,7 @@ impl ShieldedSyncManager { Self { event_manager, coordinator_slot, - background_cancel: StdMutex::new(None), - background_generation: AtomicU64::new(0), + cancel_guard: LoopCancelGuard::new(), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), quiescing: AtomicBool::new(false), @@ -194,10 +186,7 @@ impl ShieldedSyncManager { /// 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) + self.cancel_guard.is_running() } /// Whether a sync pass is in flight right now. @@ -222,17 +211,9 @@ impl ShieldedSyncManager { /// GRPC client state isn't `Send + Sync`). Same trade-off as /// [`PlatformAddressSyncManager::start`](super::platform_address_sync::PlatformAddressSyncManager::start). pub fn start(self: Arc) { - let mut guard = self.background_cancel.lock().expect("bg_cancel poisoned"); - if guard.is_some() { + let Some((cancel, my_generation)) = self.cancel_guard.install() else { return; - } - let cancel = CancellationToken::new(); - *guard = Some(cancel.clone()); - // Bump the generation while we still hold the slot lock so - // the load below in any prior thread's cleanup observes - // `current_gen != my_gen` ordered against this token swap. - let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1; - drop(guard); + }; let handle = tokio::runtime::Handle::current(); let this = self; @@ -261,17 +242,7 @@ impl ShieldedSyncManager { } } - // Only clear `background_cancel` if the active - // generation is still ours. Without this guard a - // tight `stop()` → `start()` reschedule has the - // exiting thread overwrite the *new* generation's - // token, leaving the new loop running but - // unreflectable via `is_running()` / `stop()`. - if this.background_generation.load(Ordering::Acquire) == my_gen { - if let Ok(mut guard) = this.background_cancel.lock() { - *guard = None; - } - } + this.cancel_guard.clear_if_current(my_generation); }); }) .expect("failed to spawn shielded-sync thread"); @@ -286,12 +257,7 @@ impl ShieldedSyncManager { /// nothing more will be persisted" barrier — required by Clear, /// unregister, and rebind — use [`quiesce`](Self::quiesce). pub fn stop(&self) { - if let Some(token) = self - .background_cancel - .lock() - .expect("bg_cancel poisoned") - .take() - { + if let Some(token) = self.cancel_guard.take() { token.cancel(); } } diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 25ee5df914a..e8a38200715 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 @@ -325,7 +332,13 @@ impl PlatformWalletManager

{ "failed to persist wallet registration changeset" ); let mut wm = self.wallet_manager.write().await; - let _ = wm.remove_wallet(&wallet_id); + if let Err(e) = wm.remove_wallet(&wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "rollback: remove_wallet failed while unwinding a failed wallet registration" + ); + } return Err(PlatformWalletError::WalletCreation(format!( "Failed to persist wallet registration changeset: {}", e @@ -368,7 +381,13 @@ impl PlatformWalletManager

{ Ok(state) => state, Err(e) => { let mut wm = self.wallet_manager.write().await; - let _ = wm.remove_wallet(&wallet_id); + if let Err(e) = wm.remove_wallet(&wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "rollback: remove_wallet failed while unwinding a failed wallet setup" + ); + } return Err(PlatformWalletError::WalletCreation(format!( "Failed to load persisted wallet state: {}", e @@ -383,7 +402,13 @@ impl PlatformWalletManager

{ .await { let mut wm = self.wallet_manager.write().await; - let _ = wm.remove_wallet(&wallet_id); + if let Err(e) = wm.remove_wallet(&wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "rollback: remove_wallet failed while unwinding a failed wallet setup" + ); + } return Err(PlatformWalletError::WalletCreation(format!( "Failed to restore persisted platform address state: {}", e @@ -444,7 +469,13 @@ impl PlatformWalletManager

{ .unwrap_or_default(), None => Vec::new(), }; - let _ = wm.remove_wallet(wallet_id); + if let Err(e) = wm.remove_wallet(wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "remove_wallet: inner wallet-manager removal failed (state may be inconsistent)" + ); + } ids }; @@ -668,7 +699,7 @@ mod register_wallet_duplicate_tests { manager .create_wallet_from_seed_bytes( network, - seed_bytes, + &seed_bytes, WalletAccountCreationOptions::Default, Some(0), ) @@ -681,7 +712,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/util.rs b/packages/rs-platform-wallet/src/util.rs new file mode 100644 index 00000000000..32dfde95325 --- /dev/null +++ b/packages/rs-platform-wallet/src/util.rs @@ -0,0 +1,13 @@ +//! Small crate-internal helpers shared across wallet modules. + +/// Current wall-clock time in milliseconds since the Unix epoch. +/// +/// Used only for display-only timestamps and cost rate-limiting (never as a +/// correctness-bearing ordering key), so a clock that is briefly behind or a +/// pre-epoch read (falling back to `0`) is harmless everywhere it is called. +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 8c690543ee0..01668a762f9 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 @@ -172,11 +178,12 @@ impl PlatformWalletInfo { // 3. Contacts. Each entry routes to its owning ManagedIdentity by // `(owner, contact)` key; orphans (owner not in the wallet) - // are logged and skipped. Trivial map ops (sent / incoming - // insert and remove) are inlined here — no helper earns its - // name for a single `insert` / `shift_remove` call. Only - // `apply_established_contact` is a method because it has - // real logic (drops both pending sides per the contract). + // are logged and skipped. Every map mutation goes through the + // `apply_*` replay methods — the relationship maps are sealed + // to the state layer, and the replay methods reproduce + // persisted state without re-running the live invariants + // (`apply_established_contact` additionally drops both + // pending sides per the contract). if let Some(contact_cs) = contacts { let crate::changeset::ContactChangeSet { sent_requests, @@ -184,14 +191,14 @@ impl PlatformWalletInfo { incoming_requests, removed_incoming, established, + ignored, + unignored, } = contact_cs; for (key, entry) in sent_requests { match self.identity_manager.managed_identity_mut(&key.owner_id) { Some(managed) => { - managed - .sent_contact_requests - .insert(entry.request.recipient_id, entry.request); + managed.apply_sent_contact_request(entry.request); } None => tracing::warn!( owner = %key.owner_id, @@ -202,9 +209,7 @@ impl PlatformWalletInfo { for (key, entry) in incoming_requests { match self.identity_manager.managed_identity_mut(&key.owner_id) { Some(managed) => { - managed - .incoming_contact_requests - .insert(entry.request.sender_id, entry.request); + managed.apply_incoming_contact_request(entry.request); } None => tracing::warn!( owner = %key.owner_id, @@ -214,12 +219,12 @@ impl PlatformWalletInfo { } for key in removed_sent { if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) { - managed.sent_contact_requests.remove(&key.recipient_id); + managed.apply_removed_sent(&key.recipient_id); } } for key in removed_incoming { if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) { - managed.incoming_contact_requests.remove(&key.sender_id); + managed.apply_removed_incoming(&key.sender_id); } } // Established promotions — drop any matching pending @@ -235,6 +240,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.apply_ignored_sender(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.apply_unignored_sender(&sender_id); + } + } } // 3b. DashPay profile/payment overlays. Applied AFTER identities @@ -243,14 +270,14 @@ impl PlatformWalletInfo { if let Some(profiles) = dashpay_profiles { for (id, profile) in profiles { if let Some(managed) = self.identity_manager.managed_identity_mut(&id) { - managed.dashpay_profile = profile; + *managed.dashpay_profile_mut() = profile; } } } if let Some(payments) = dashpay_payments_overlay { for (id, payments_map) in payments { if let Some(managed) = self.identity_manager.managed_identity_mut(&id) { - managed.dashpay_payments.extend(payments_map); + managed.dashpay_payments_mut().extend(payments_map); } } } @@ -529,12 +556,8 @@ mod tests { let mut id_cs = IdentityChangeSet::default(); let mut managed = ManagedIdentity::new(make_test_identity(1, 0), 0); - managed - .sent_contact_requests - .insert(other_id, make_test_contact_request(1, 2)); - managed - .incoming_contact_requests - .insert(other_id, make_test_contact_request(2, 1)); + managed.apply_sent_contact_request(make_test_contact_request(1, 2)); + managed.apply_incoming_contact_request(make_test_contact_request(2, 1)); id_cs .identities .insert(owner_id, IdentityEntry::from_managed(&managed)); @@ -566,9 +589,18 @@ mod tests { .identity_manager .managed_identity(&owner_id) .expect("owner present"); - assert!(managed.established_contacts.contains_key(&other_id)); - assert!(!managed.sent_contact_requests.contains_key(&other_id)); - assert!(!managed.incoming_contact_requests.contains_key(&other_id)); + assert!(managed + .dashpay() + .established_contacts() + .contains_key(&other_id)); + assert!(!managed + .dashpay() + .sent_contact_requests() + .contains_key(&other_id)); + assert!(!managed + .dashpay() + .incoming_contact_requests() + .contains_key(&other_id)); } #[test] @@ -1012,7 +1044,8 @@ mod tests { .identity_manager .managed_identity_mut(&owner) .expect("a owner") - .add_sent_contact_request(make_test_contact_request(1, 2), &p); + .add_sent_contact_request(make_test_contact_request(1, 2), &p) + .expect("setup persists"); // Build the replay changeset from A's state: the request ended up in // `sent_contact_requests` (no auto-establishment because there was no @@ -1037,9 +1070,12 @@ mod tests { .identity_manager .managed_identity(&owner) .expect("b owner"); - assert!(b_owner.sent_contact_requests.contains_key(&recipient)); - assert!(b_owner.incoming_contact_requests.is_empty()); - assert!(b_owner.established_contacts.is_empty()); + assert!(b_owner + .dashpay() + .sent_contact_requests() + .contains_key(&recipient)); + assert!(b_owner.dashpay().incoming_contact_requests().is_empty()); + assert!(b_owner.dashpay().established_contacts().is_empty()); } #[test] @@ -1064,7 +1100,8 @@ mod tests { .identity_manager .managed_identity_mut(&owner) .expect("a owner") - .add_incoming_contact_request(make_test_contact_request(2, 1), &p); + .add_incoming_contact_request(make_test_contact_request(2, 1), &p) + .expect("setup persists"); // Snapshot the incoming request changeset for B replay step 1. let incoming_req = make_test_contact_request(2, 1); @@ -1084,14 +1121,16 @@ mod tests { .identity_manager .managed_identity_mut(&owner) .expect("a owner") - .add_sent_contact_request(make_test_contact_request(1, 2), &p); + .add_sent_contact_request(make_test_contact_request(1, 2), &p) + .expect("setup persists"); // After auto-establishment: A's established_contacts should have `other`. let a_established = info_a .identity_manager .managed_identity(&owner) .expect("a owner") - .established_contacts + .dashpay() + .established_contacts() .get(&other) .cloned() .expect("established in A"); @@ -1125,12 +1164,18 @@ mod tests { .identity_manager .managed_identity(&owner) .expect("b owner"); - assert!(a_owner.established_contacts.contains_key(&other)); - assert!(b_owner.established_contacts.contains_key(&other)); - assert!(a_owner.sent_contact_requests.is_empty()); - assert!(b_owner.sent_contact_requests.is_empty()); - assert!(a_owner.incoming_contact_requests.is_empty()); - assert!(b_owner.incoming_contact_requests.is_empty()); + assert!(a_owner + .dashpay() + .established_contacts() + .contains_key(&other)); + assert!(b_owner + .dashpay() + .established_contacts() + .contains_key(&other)); + assert!(a_owner.dashpay().sent_contact_requests().is_empty()); + assert!(b_owner.dashpay().sent_contact_requests().is_empty()); + assert!(a_owner.dashpay().incoming_contact_requests().is_empty()); + assert!(b_owner.dashpay().incoming_contact_requests().is_empty()); } #[test] @@ -1155,7 +1200,8 @@ mod tests { .identity_manager .managed_identity_mut(&owner) .expect("a") - .add_sent_contact_request(make_test_contact_request(1, 2), &p); + .add_sent_contact_request(make_test_contact_request(1, 2), &p) + .expect("setup persists"); let mut insert_cs = ContactChangeSet::default(); insert_cs.sent_requests.insert( SentContactRequestKey { @@ -1188,8 +1234,8 @@ mod tests { let a_owner = info_a.identity_manager.managed_identity(&owner).expect("a"); let b_owner = info_b.identity_manager.managed_identity(&owner).expect("b"); - assert!(a_owner.sent_contact_requests.is_empty()); - assert!(b_owner.sent_contact_requests.is_empty()); + assert!(a_owner.dashpay().sent_contact_requests().is_empty()); + assert!(b_owner.dashpay().sent_contact_requests().is_empty()); } // ---------------------------------------------------------------------- @@ -1311,9 +1357,7 @@ mod tests { let owner = Identifier::from([1u8; 32]); let other = Identifier::from([2u8; 32]); let mut managed = ManagedIdentity::new(make_test_identity(1, 0), 0); - managed - .sent_contact_requests - .insert(other, make_test_contact_request(1, 2)); + managed.apply_sent_contact_request(make_test_contact_request(1, 2)); let mut id_cs = IdentityChangeSet::default(); id_cs .identities @@ -1334,7 +1378,10 @@ mod tests { .identity_manager .managed_identity(&owner) .expect("present"); - assert!(!restored.sent_contact_requests.contains_key(&other)); + assert!(!restored + .dashpay() + .sent_contact_requests() + .contains_key(&other)); } #[test] @@ -1383,8 +1430,8 @@ mod tests { let a = info_a.identity_manager.managed_identity(&id).expect("a"); let b = info_b.identity_manager.managed_identity(&id).expect("b"); - assert_eq!(a.dashpay_profile, b.dashpay_profile); - assert_eq!(b.dashpay_profile, Some(profile)); + assert_eq!(a.dashpay().profile, b.dashpay().profile); + assert_eq!(b.dashpay().profile, Some(profile)); } #[test] @@ -1412,7 +1459,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"); @@ -1430,7 +1478,7 @@ mod tests { .identity_manager .managed_identity(&owner) .expect("b managed"); - assert_eq!(b_managed.dashpay_payments.get(&tx_id), Some(&payment)); + assert_eq!(b_managed.dashpay().payments.get(&tx_id), Some(&payment)); } #[test] @@ -1458,7 +1506,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 +1524,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 @@ -1491,11 +1541,110 @@ mod tests { .managed_identity(&owner) .expect("b managed"); assert_eq!( - b_managed.dashpay_payments.get(&tx_id).map(|p| p.status), + b_managed.dashpay().payments.get(&tx_id).map(|p| p.status), Some(PaymentStatus::Confirmed) ); } + /// 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") + .dashpay_contact_profiles_mut() + .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 + .dashpay() + .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") + .dashpay_contact_profiles_mut() + .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 + .dashpay() + .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; @@ -1548,7 +1697,8 @@ mod tests { .identity_manager .managed_identity(&id) .expect("a") - .dashpay_profile, + .dashpay() + .profile, None ); assert_eq!( @@ -1556,7 +1706,8 @@ mod tests { .identity_manager .managed_identity(&id) .expect("b") - .dashpay_profile, + .dashpay() + .profile, None ); } 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..6585ba0bb9f 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,258 @@ 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. +/// 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`]. /// -/// 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. +/// 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. /// -/// # Note -/// -/// This verification requires access to the same wallet that generated the -/// proof, because the public key is derived from the wallet seed. If you only -/// have the proof and a standalone public key, use -/// [`verify_auto_accept_proof_with_pubkey`] instead (if available). -/// -/// For a standalone (no-wallet) verification, the caller must derive or know -/// the public key externally. This function performs the full derivation. -pub fn verify_auto_accept_proof( +/// # 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)"))?; + // Bound the base58 work an unauthenticated QR / deep link can force. A + // valid `KEY_BLOB_LEN`-byte DIP-15 blob base58-encodes to ~52 chars, and + // `decode_auto_accept_key_blob` rejects wrong-length blobs anyway — but + // only after `bs58::decode` allocates ~0.73 × len bytes. Cap the input so + // a hostile multi-megabyte `dapk` value can't force a large allocation + // per scan/paste before any structural validation runs. + const MAX_DAPK_BASE58_LEN: usize = 128; + if dapk.len() > MAX_DAPK_BASE58_LEN { + return Err(invalid(format!( + "dapk too long ({} chars; max {MAX_DAPK_BASE58_LEN})", + dapk.len() + ))); } + 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 +547,138 @@ 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 parse_contact_uri_caps_oversized_dapk_before_decoding() { + // A hostile QR / deep link with a huge base58 `dapk` must be rejected + // up front rather than base58-decoded into a large allocation. A valid + // blob encodes to ~52 chars; this is far over the 128-char cap. + let huge = "z".repeat(5000); + let uri = format!("dash:?du=alice&dapk={huge}"); + let err = parse_dashpay_contact_uri(&uri).expect_err("oversized dapk must be rejected"); + assert!( + err.to_string().contains("too long"), + "expected a length-cap rejection, got: {err}" + ); + + // The cap must not reject a normal-length (valid) dapk. + let key = SecretKey::from_slice(&[0x09u8; 32]).unwrap(); + let blob = encode_auto_accept_key_blob(&key, 1); + let uri = encode_dashpay_contact_uri("alice", &blob); + assert!( + parse_dashpay_contact_uri(&uri).is_ok(), + "a valid-length dapk must still parse" + ); + } + + #[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..b70b385ad68 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs @@ -0,0 +1,547 @@ +//! 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; + +/// The deployed schema's `privateData` maximum length (bytes, IV included): +/// `"privateData": { "maxItems": 2048 }` in `dashpay.schema.json`. +const PRIVATE_DATA_MAX_LEN: usize = 2048; + +/// Plaintext ceiling so `IV(16) ‖ AES-256-CBC/PKCS7(plaintext)` fits the +/// schema's 2048-byte cap: PKCS7 always adds 1..=16 padding bytes, so the +/// largest admissible plaintext is `(2048 - 16) - 1 = 2031` bytes (a +/// 2031-byte plaintext pads to a 2032-byte ciphertext; 2032 + 16 = 2048). +pub const MAX_PLAINTEXT_LEN: usize = PRIVATE_DATA_MAX_LEN - 16 - 1; + +/// 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 +} + +/// [`encode_private_data`] with the schema's size cap enforced. +/// +/// The publish path MUST use this (before persisting any local state): +/// an over-cap plaintext produces a `privateData` blob the contract's +/// 2048-byte `maxItems` rejects at broadcast — a PERMANENT failure, not a +/// transient one, so it has to surface as an error to the caller instead +/// of leaving locally-persisted metadata durably divergent from chain. +/// Unlike the account-label codec (which truncates to its 80-byte field), +/// alias/note are user-visible verbatim, so we reject rather than +/// silently truncate. +pub fn encode_private_data_bounded( + data: &ContactInfoPrivateData, +) -> Result, PlatformWalletError> { + let out = encode_private_data(data); + if out.len() > MAX_PLAINTEXT_LEN { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "contactInfo privateData plaintext is {} bytes; the encrypted document would \ + exceed the contract's {PRIVATE_DATA_MAX_LEN}-byte cap (max plaintext \ + {MAX_PLAINTEXT_LEN} bytes) — shorten the alias/note", + out.len() + ))); + } + Ok(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" + ); + } + + /// Over-cap payloads are rejected BEFORE encryption/persist: the + /// contract's `privateData` cap is 2048 bytes (IV + CBC ciphertext), + /// so a plaintext past [`MAX_PLAINTEXT_LEN`] would fail the broadcast + /// permanently — after the publish path already persisted local + /// metadata. The bounded encoder must error, not truncate. Was red + /// against the cap-less encoder. + #[test] + fn private_data_over_cap_is_rejected_at_cap_is_accepted() { + // Fixed overhead around the note: version(4) + alias varstr(1, empty) + // + note varint prefix + displayHidden(1) + accepted count varint(1). + // A ~2100-byte note is safely over the 2031-byte plaintext cap. + let over = ContactInfoPrivateData { + alias_name: None, + note: Some("x".repeat(2100)), + display_hidden: false, + accepted_accounts: Vec::new(), + }; + assert!( + encode_private_data_bounded(&over).is_err(), + "a plaintext past MAX_PLAINTEXT_LEN must be rejected, not encrypted" + ); + + // Boundary: size the note so the encoded plaintext lands EXACTLY on + // MAX_PLAINTEXT_LEN — must be accepted and round-trip. + let mut at_cap = ContactInfoPrivateData { + alias_name: None, + note: Some(String::new()), + display_hidden: false, + accepted_accounts: Vec::new(), + }; + // Find the note length whose encoding hits the cap exactly: encode + // once to measure the fixed overhead of a 3-byte varstr prefix + // (lengths ≥ 253 use 0xFD + u16). + let overhead = { + let probe = ContactInfoPrivateData { + alias_name: None, + note: Some("y".repeat(300)), + display_hidden: false, + accepted_accounts: Vec::new(), + }; + encode_private_data(&probe).len() - 300 + }; + at_cap.note = Some("y".repeat(MAX_PLAINTEXT_LEN - overhead)); + let encoded = encode_private_data_bounded(&at_cap).expect("at-cap payload is admissible"); + assert_eq!(encoded.len(), MAX_PLAINTEXT_LEN); + assert_eq!(decode_private_data(&encoded).expect("decode"), at_cap); + } + + /// 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..74b70c27732 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,17 @@ //! 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, }; +pub use validation::pubkey_binds_expected_key_data; 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..3c3a18f0e97 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs @@ -3,6 +3,7 @@ //! Validates that the sender and recipient identities have the correct key //! types and purposes before a contact request is submitted to the platform. +use dash_sdk::platform::dashpay::recipient_key_purpose_is_valid; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{Identity, KeyType, Purpose}; @@ -16,6 +17,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 +48,8 @@ impl Default for ContactRequestValidation { is_valid: true, errors: Vec::new(), warnings: Vec::new(), + purpose_mismatch: false, + hard_error: false, } } } @@ -34,10 +60,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 +84,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 +99,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 +161,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 +214,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. The + // accepted cohort is owned by the shared SDK predicate so this + // validator and the recipient-key selector cannot disagree. + if !recipient_key_purpose_is_valid(key.purpose()) { + validation.add_purpose_error(format!( + "Recipient key {} has purpose {:?}, but ENCRYPTION or DECRYPTION is \ + required for contact requests", + recipient_key_index, + key.purpose(), + )); + } + // Must not be disabled. if let Some(disabled_at) = key.disabled_at() { validation.add_error(format!( @@ -166,6 +251,42 @@ pub fn validate_contact_request( validation } +/// Decide whether a derived compressed secp256k1 public key binds to the +/// caller's known on-chain key data — the sign-time / verify-time +/// public-key-binding policy, shared by every ECDSA key path so it cannot +/// drift from the discovery-time ownership decision. +/// +/// `derived_pubkey` is the 33-byte compressed pubkey re-derived at a +/// breadcrumb path (`ExtendedPubKey::from_priv(..).public_key.serialize()`). +/// `expected_key_data` is the on-chain key's `data`, discriminated by length: +/// +/// - **33 bytes** → the on-chain key is an `ECDSA_SECP256K1` key whose `data` +/// is the compressed pubkey; binds iff the two byte strings are equal. +/// - **20 bytes** → the on-chain key is an `ECDSA_HASH160` key whose `data` is +/// `ripemd160_sha256` of the compressed pubkey; binds iff that hash equals +/// the expected bytes. +/// - **any other length** → fails closed (`false`), never binds. +/// +/// This is byte-for-byte the same decision +/// `IdentityPublicKey::validate_private_key_bytes` makes from the secret +/// scalar: for `ECDSA_SECP256K1` it compares `data` to the compressed pubkey, +/// and for `ECDSA_HASH160` it compares `data` to `ripemd160_sha256` of that +/// same compressed pubkey (`identity_public_key/v0/methods/mod.rs`). Length is +/// the wire discriminator here because the caller (the FFI resolver-signing +/// binding, `sign_with_mnemonic_resolver.rs`) holds raw expected bytes rather +/// than a typed `IdentityPublicKey`; the 33/20 split is exactly the ECDSA +/// arms' two representations, so the policies stay aligned. The +/// `pubkey_reproduces` / `validate_private_key_bytes` equivalence is pinned in +/// `discovery.rs::pubkey_verify_matches_scalar_verify_for_every_key`. +pub fn pubkey_binds_expected_key_data(derived_pubkey: &[u8; 33], expected_key_data: &[u8]) -> bool { + use dpp::util::hash::ripemd160_sha256; + match expected_key_data.len() { + 33 => derived_pubkey.as_slice() == expected_key_data, + 20 => ripemd160_sha256(derived_pubkey.as_slice()).as_slice() == expected_key_data, + _ => false, + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -332,4 +453,300 @@ 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()); + } + + // ----------------------------------------------------------------------- + // Pubkey-binding policy (`pubkey_binds_expected_key_data`). The 33/20 split + // is the sign-time / verify-time binding shared by the FFI resolver path; + // these pin that it matches AND fails closed on the wrong bytes, and that + // it is byte-for-byte identical to `validate_private_key_bytes`. + // ----------------------------------------------------------------------- + + /// Derive the compressed secp256k1 pubkey (`[u8; 33]`) for a fixed + /// in-range scalar — the shape a breadcrumb re-derivation produces. + fn fixed_scalar_and_compressed_pubkey() -> ([u8; 32], [u8; 33]) { + use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + let mut scalar = [0u8; 32]; + scalar[31] = 7; + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&scalar).expect("in-range scalar"); + let pubkey = PublicKey::from_secret_key(&secp, &sk).serialize(); + (scalar, pubkey) + } + + #[test] + fn binds_matching_33_byte_pubkey() { + let (_scalar, pubkey) = fixed_scalar_and_compressed_pubkey(); + assert!(pubkey_binds_expected_key_data(&pubkey, &pubkey)); + } + + #[test] + fn rejects_wrong_33_byte_pubkey() { + let (_scalar, pubkey) = fixed_scalar_and_compressed_pubkey(); + // A syntactically valid compressed-pubkey prefix, wrong key. + let wrong = [0x02u8; 33]; + assert!(!pubkey_binds_expected_key_data(&pubkey, &wrong)); + } + + #[test] + fn binds_matching_20_byte_hash() { + use dpp::util::hash::ripemd160_sha256; + let (_scalar, pubkey) = fixed_scalar_and_compressed_pubkey(); + let hash = ripemd160_sha256(&pubkey); + assert!(pubkey_binds_expected_key_data(&pubkey, &hash)); + } + + #[test] + fn rejects_wrong_20_byte_hash() { + use dpp::util::hash::ripemd160_sha256; + let (_scalar, pubkey) = fixed_scalar_and_compressed_pubkey(); + // ripemd160_sha256 of an unrelated pubkey — valid-shaped, wrong hash. + let wrong = ripemd160_sha256(&[0x03u8; 33]); + assert!(!pubkey_binds_expected_key_data(&pubkey, &wrong)); + } + + /// An expected length that is neither 33 nor 20 must fail closed — never + /// silently bind (guards a caller passing a 32-byte scalar or a 65-byte + /// uncompressed key by mistake). + #[test] + fn malformed_expected_length_fails_closed() { + let (_scalar, pubkey) = fixed_scalar_and_compressed_pubkey(); + assert!(!pubkey_binds_expected_key_data(&pubkey, &[0x02u8; 32])); + assert!(!pubkey_binds_expected_key_data(&pubkey, &[0x02u8; 65])); + assert!(!pubkey_binds_expected_key_data(&pubkey, &[])); + } + + /// The pubkey-only binding decision is byte-for-byte identical to + /// `IdentityPublicKey::validate_private_key_bytes` (which decides from the + /// secret scalar) for both ECDSA representations — the guarantee that the + /// FFI sign-time binding cannot drift from the discovery-time ownership + /// decision. Mirrors `discovery.rs::pubkey_verify_matches_scalar_verify_*`. + #[test] + fn binding_matches_validate_private_key_bytes_for_both_ecdsa_types() { + use dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; + use dpp::util::hash::ripemd160_sha256; + + let network = dashcore::Network::Testnet; + let (scalar, pubkey) = fixed_scalar_and_compressed_pubkey(); + + // ECDSA_SECP256K1: on-chain data = the 33-byte compressed pubkey. + let secp_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(pubkey.to_vec()), + disabled_at: None, + }); + // ECDSA_HASH160: on-chain data = ripemd160_sha256 of the pubkey. + let hash160_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: dpp::platform_value::BinaryData::new(ripemd160_sha256(&pubkey).to_vec()), + disabled_at: None, + }); + + for key in [&secp_key, &hash160_key] { + let expected = key.data().as_slice(); + let scalar_decision = key + .validate_private_key_bytes(&scalar, network) + .unwrap_or(false); + let pubkey_decision = pubkey_binds_expected_key_data(&pubkey, expected); + assert_eq!( + scalar_decision, + pubkey_decision, + "pubkey-binding diverged from validate_private_key_bytes for {:?}", + key.key_type() + ); + assert!(pubkey_decision, "the correct key must bind"); + } + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/mod.rs index 66d1cbdfa90..54b7b8bb300 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/mod.rs @@ -26,14 +26,16 @@ 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, pubkey_binds_expected_key_data, + unmask_account_reference, ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, +}; +pub use network::{DashPayView, IdentityWallet}; +pub use state::{ + BlockTime, DashPayState, IdentityLocation, IdentityManager, ManagedIdentity, RegistrationIndex, }; -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 deleted file mode 100644 index 768590b65a1..00000000000 --- a/packages/rs-platform-wallet/src/wallet/identity/network/account_labels.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! DIP-15 account-label encryption and decryption. - -use platform_encryption::CryptoError; - -use super::*; -use crate::broadcaster::TransactionBroadcaster; -use crate::error::PlatformWalletError; - -// --------------------------------------------------------------------------- -// Account label encryption / decryption (DIP-15) -// --------------------------------------------------------------------------- - -impl IdentityWallet { - /// Encrypt an account label using CBC-AES-256 with a shared ECDH key. - /// - /// Uses the `platform_encryption` crate which prepends a random 16-byte IV - /// to the ciphertext. - /// - /// # Arguments - /// - /// * `label` - The account label to encrypt. - /// * `shared_key` - 32-byte shared secret derived via ECDH. - /// - /// # Returns - /// - /// Encrypted label bytes (48-80 bytes: 16-byte IV + 32-64 byte ciphertext). - pub fn encrypt_account_label( - label: &str, - shared_key: &[u8; 32], - ) -> Result, PlatformWalletError> { - use dashcore::secp256k1::rand::thread_rng; - use dashcore::secp256k1::rand::RngCore; - let mut iv = [0u8; 16]; - thread_rng().fill_bytes(&mut iv); - - let encrypted = platform_encryption::encrypt_account_label(shared_key, &iv, label); - - Ok(encrypted) - } - - /// Decrypt an account label using CBC-AES-256 with a shared ECDH key. - /// - /// The first 16 bytes of `encrypted` are taken as the IV. - /// - /// # Arguments - /// - /// * `encrypted` - Encrypted label bytes (48-80 bytes). - /// * `shared_key` - 32-byte shared secret derived via ECDH. - /// - /// # Returns - /// - /// The decrypted label string. - pub fn decrypt_account_label( - encrypted: &[u8], - shared_key: &[u8; 32], - ) -> Result { - platform_encryption::decrypt_account_label(shared_key, encrypted).map_err(|e| match e { - CryptoError::DecryptionFailed => { - PlatformWalletError::InvalidIdentityData("Account label decryption failed".into()) - } - CryptoError::InvalidUtf8 => PlatformWalletError::InvalidIdentityData( - "Decrypted account label is not valid UTF-8".into(), - ), - CryptoError::InvalidCiphertextLength => PlatformWalletError::InvalidIdentityData( - "Invalid encrypted account label length".into(), - ), - }) - } -} 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..77b883a6094 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs @@ -0,0 +1,776 @@ +//! 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_bounded, + 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 DashPayView<'_, B> { + /// 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. + // Surface a persist failure rather than swallow it. + if managed + .set_contact_metadata(&decrypted.contact_id, decrypted.data, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "contactInfo metadata not persisted: {e}" + )) + })? + { + 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 = crate::util::now_ms(); + 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; + }; + let Some(managed) = info.identity_manager.managed_identity_mut(owner_id) else { + tracing::warn!( + owner = %owner_id, + "contactInfo-decrypt enqueue for a non-resident identity; dropping" + ); + return; + }; + upsert_pending_contact_crypto( + managed.dashpay_pending_contact_crypto_mut(), + 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 { + // Surface a persist failure: the caller's `Err` arm leaves this + // `ContactInfoDecrypt` entry queued so the alias/note re-applies + // next drain, instead of clearing the only retry hook. + if managed + .set_contact_metadata(&contact_id, data, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "contactInfo metadata not persisted: {e}" + )) + })? + { + 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. + // External-signer entry point: the contact key, the three metadata fields + // (alias / note / hidden), and the two signer/crypto handles are all + // distinct caller inputs the FFI passes through verbatim — bundling them + // would only move the argument list behind a single-use struct. + #[allow(clippy::too_many_arguments)] + 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(), + }; + + // 0. Size gate BEFORE any local persist. An over-cap alias/note + // would fail the broadcast against the contract's 2048-byte + // `privateData` cap — a PERMANENT failure with no retry queue — + // after step 1 already durably persisted the local metadata, + // leaving local state divergent from chain for good. Encode once + // here; the encrypt step below reuses these bytes. + let plaintext = encode_private_data_bounded(&metadata)?; + + // 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) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "contactInfo metadata not persisted: {e}" + )) + })? + { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Contact {contact_id} is not established for identity {identity_id}" + ))); + } + let established_count = managed.dashpay().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(); + // Shared own-ECDH-root selector (same policy as the + // contact-request send path); `Option` preserved — a missing + // key defers the publish rather than erroring here. + let root_key_id = + crate::wallet::identity::network::contact_requests::select_own_encryption_key( + &managed.identity, + ) + .ok() + .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?; + // (doc_id, revision, derivation_index, root_index) — `root_index` is + // kept so an update re-seals under the doc's ORIGINAL root, not a + // since-rotated current key (see the match below). + let mut existing: Option<(Identifier, u64, u32, 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, + raw.root_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, write_root_id) = match existing { + // Update path: re-seal under the matched doc's ORIGINAL root index, + // not the current `root_key_id`. The contract's unique index is + // (ownerId, rootEncryptionKeyIndex, derivationEncryptionKeyIndex); + // if our encryption key rotated since the doc was written, + // re-rooting a Replace would move the doc's coordinate to + // (new_root, idx) and could collide with an independently-allocated + // doc there (Drive rejects the Replace). Keeping the original root + // holds the coordinate stable. (We can still derive the old root's + // keys — rotation disables the identity key but the HD tree still + // reproduces the scalar.) + Some((id, rev, idx, root_idx)) => (Some(id), rev + 1, idx, root_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, + root_key_id, + ) + } + }; + + // 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 the doc's own root (current key for a fresh + // doc; the original root for an update — see above). + let root_path = super::identity_auth_derivation_path_for_type( + self.sdk.network, + key_wallet::bip32::KeyDerivationType::ECDSA, + identity_index, + write_root_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(), + // Pre-encoded (and size-gated) in step 0. + &plaintext, + &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 helper. + 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(write_root_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..0b5c6221149 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,303 @@ 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::{ExternalAccountRegistration, 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. Returned in [`zeroize::Zeroizing`] so the DIP-15 friendship AES + /// key is scrubbed on drop — it stays wrapped through the send / accept / + /// drain flows and is only dereferenced at the final crypto boundary. + async fn ecdh_shared_secret( + &self, + path: &key_wallet::bip32::DerivationPath, + peer: &dashcore::secp256k1::PublicKey, + ) -> Result, 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; + + /// 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, +} -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") +#[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 ecdh_shared_secret( + &self, + path: &key_wallet::bip32::DerivationPath, + peer: &dashcore::secp256k1::PublicKey, + ) -> Result, PlatformWalletError> { + let xprv = self.wallet.derive_extended_private_key(path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("test ecdh derive: {e}")) + })?; + Ok(zeroize::Zeroizing::new( + platform_encryption::derive_shared_key_ecdh(&xprv.private_key, peer), + )) + } + + 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 sign_create_witness( + async fn contact_info_open( &self, - key: &K, - data: &[u8], - ) -> Result { - self.0.sign_create_witness(key, data).await + 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, + }) } +} - fn can_sign_with(&self, key: &K) -> bool { - self.0.can_sign_with(key) +#[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,15 +313,49 @@ where // Send contact request // --------------------------------------------------------------------------- -impl IdentityWallet { +/// 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 DashPayView<'_, B> { /// Send a contact request to another identity using an /// externally-supplied signer for the document state-transition. /// /// 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 +363,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 +417,157 @@ impl IdentityWallet { .ok_or_else(|| PlatformWalletError::IdentityNotFound(*recipient_identity_id))? }; - // 3. Resolve key indices. - let sender_encryption_key = sender_identity - .public_keys() - .iter() - .find(|(_, k)| k.purpose() == Purpose::ENCRYPTION) - .map(|(_, k)| k.clone()) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Sender identity has no encryption key".to_string(), - ) - })?; + // 3. Resolve key indices. The sender selects its own ENCRYPTION key + // (the live convention for both cohorts); ECDSA_SECP256K1 is + // required for ECDH. Shared selector — same enabled-only policy + // as the contactInfo publish path, so both DashPay surfaces + // agree on the ECDH root and a disabled (rotated-away) first + // key can't hard-fail the pre-send validator below while an + // enabled replacement exists. + let sender_encryption_key = select_own_encryption_key(&sender_identity)?.clone(); 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 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(); - let account_type = AccountType::DashpayReceivingFunds { - index: account_index, - user_identity_id: sender_identity_id.to_buffer(), - friend_identity_id: recipient_identity_id.to_buffer(), - }; - let account_path = account_type - .derivation_path(self.sdk.network) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive DashPay receiving account path: {err}" - )) - })?; - let account_xpub = 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(); + // 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 = IdentityWallet::::identity_auth_derivation_path( + self.sdk.network, + key_wallet::bip32::KeyDerivationType::ECDSA, + identity_index, + sender_encryption_key.id(), + )?; - let ecdh_key = Self::derive_encryption_private_key( - wallet, - self.sdk.network, - identity_index, - &sender_encryption_key, - )?; + // 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 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,73 +590,101 @@ 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 write helper 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 helper 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 helper. All inputs are resolved + // above; the helper assembles the SDK `EcdhProvider` + xpub + // closure and dispatches `Sdk::send_contact_request`, keeping + // the seven-generic SDK signature out of this call site — 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`. - let contact_request = ContactRequest::new( + // 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 mut 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), ); + // Mirror the broadcast doc's optional `encryptedAccountLabel` onto the + // local outgoing row so it matches what landed on Platform (same reason + // the 96-byte ciphertext is read off the doc above). Without this the + // sender's own row never reflects the label it just sent. + contact_request.encrypted_account_label = result + .document + .properties() + .get("encryptedAccountLabel") + .and_then(|v: &Value| v.to_binary_bytes().ok()); { let mut wm = self.wallet_manager.write().await; @@ -294,33 +695,353 @@ impl IdentityWallet { .identity_manager .managed_identity_mut(sender_identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*sender_identity_id))?; - managed.add_sent_contact_request(contact_request.clone(), &self.persister); + managed + .add_sent_contact_request(contact_request.clone(), &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "sent contact request not persisted: {e}" + )) + })?; } - 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 [`IdentityWallet::resolve_name`] (reached through the view's +/// `Deref`), which is defined on the non-generic `impl IdentityWallet`. +impl DashPayView<'_> { + /// 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)) +} + +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 +} + +/// Sent-side analog of [`newest_received_per_sender`], keyed by +/// **recipient**. Immutable `contactRequest` docs are never deleted +/// on-chain, so a rotation (re-key) re-send leaves MULTIPLE of our own sent +/// docs to the same recipient — the old reference plus the bumped one — and +/// the sweep re-fetches them all. `fetch_sent_contact_requests` orders them +/// `$createdAt`-ASC, so a restore-from-seed ingesting them raw would +/// auto-establish against the OLDEST doc (frozen at the first send) and drop +/// the newer ones on the same-reference / already-established guard. Collapse +/// to the single newest doc per recipient (newest by `$createdAt`, tiebreak +/// on `account_reference`) so the sent-side establishes / rotation-supersedes +/// with the freshest outgoing reference — otherwise the next rotation +/// collides on the contract's `($ownerId, toUserId, accountReference)` unique +/// index. +fn newest_sent_per_recipient( + requests: impl IntoIterator, +) -> std::collections::BTreeMap { + let mut newest: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for req in requests { + let recipient = req.recipient_id; + let replace = newest + .get(&recipient) + .map(|cur| { + (req.created_at, req.account_reference) > (cur.created_at, cur.account_reference) + }) + .unwrap_or(true); + if replace { + newest.insert(recipient, req); + } + } + newest +} + +/// Snapshot-aware removal of drained queue entries. Removes from `queue` +/// only those entries still **value-equal** to a snapshot in `drained` — +/// the full entries snapshotted before the lock-free drain. An entry a +/// concurrent `upsert_pending_contact_crypto` refreshed mid-drain is LEFT +/// queued (its live value no longer equals the stale snapshot's), so a +/// payload changed under the drain survives to the next drain instead of +/// being clobbered by a key-only removal. Whole-value equality rather than +/// an `enqueued_at_ms` freshness token: the timestamp is wall-clock +/// milliseconds (documented as observability/ordering only), so a same-ms +/// upsert or a clock rollback could alias a token that a changed payload +/// can never alias. A refresh that reproduces the identical payload +/// compares equal and is removed — fine, since the drain just processed +/// exactly those bytes. Returns the keys actually removed, so the caller +/// emits exactly those to the persisted `pending_contact_crypto_cleared` +/// delta (never a key whose fresher entry is still queued). +fn retain_drained_by_snapshot( + queue: &mut Vec, + drained: &[crate::changeset::PendingContactCrypto], +) -> Vec { + let mut removed = Vec::new(); + queue.retain(|e| { + let stale_match = drained.contains(e); + if stale_match { + removed.push(e.key()); + } + !stale_match + }); + removed +} + +/// Whether a registered outbound `DashpayExternalAccount` for `contact` +/// must be torn down + rebuilt because it was NOT built from the contact's +/// current `incoming_request.account_reference`. +/// +/// Only a **registered** account (`has_external == true`) can be stale — a +/// missing account is handled by the ordinary build-candidate collection. +/// A permanently-broken channel is left alone (the sweep never rebuilds +/// broken contacts; they heal on a superseding request). The staleness test +/// is `external_account_reference != Some(incoming_request.account_reference)`: +/// a mismatch (or a `None` marker from a cold restart that did not carry it) +/// means the persisted, tombstone-less account row rebuilt the rotated-away +/// xpub while the contact already tracks the new reference — so `send_payment` +/// would derive addresses the contact no longer watches until it is rebuilt. +fn external_account_needs_rebuild(contact: &EstablishedContact, has_external: bool) -> bool { + has_external + && !contact.payment_channel_broken + && contact.external_account_reference != Some(contact.incoming_request.account_reference) +} + +/// 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). +/// +/// The accepted cohort (DECRYPTION or ENCRYPTION) is the shared +/// [`dash_sdk::platform::dashpay::recipient_key_purpose_is_valid`] membership +/// policy; only the preference ORDER below (DECRYPTION first, ENCRYPTION +/// second) is local to the selector. +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(), + )) +} + +/// Select our OWN ECDH root key: the first **enabled** `ECDSA_SECP256K1` +/// ENCRYPTION key on the identity (`BTreeMap` order → lowest key id). +/// +/// The single selection policy shared by the contact-request send path and +/// the contactInfo publish path, so the two DashPay surfaces always agree +/// on which key is the ECDH root. Skipping disabled keys mirrors +/// [`select_recipient_key_index`] and the validator's disabled-key gate: +/// after a disable-and-replace key rotation, selecting the disabled +/// lowest-id key would hard-fail pre-send validation on every new outgoing +/// request even though an enabled replacement exists. +pub(crate) fn select_own_encryption_key( + identity: &Identity, +) -> Result<&IdentityPublicKey, PlatformWalletError> { + identity + .public_keys() + .iter() + .find(|(_, k)| { + k.purpose() == Purpose::ENCRYPTION + && k.key_type() == KeyType::ECDSA_SECP256K1 + && k.disabled_at().is_none() + }) + .map(|(_, k)| k) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Identity has no enabled ECDSA_SECP256K1 encryption key".to_string(), + ) + }) +} + // --------------------------------------------------------------------------- // Sync contact requests from platform // --------------------------------------------------------------------------- -impl IdentityWallet { +/// 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 DashPayView<'_, B> { /// 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,388 +1049,3348 @@ 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.dashpay().high_water_received_ms(), + m.dashpay().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, - None => continue, - }; - - let sender_id = doc.owner_id(); + // Max `$createdAt` over docs FETCHED this sweep (not over docs that + // survive ingest's collapse/dedup) — the cursor records how far this + // sweep got. The fetch returned without error, so the received + // cursor may advance to that max; the sent cursor advances only if + // `sent_ok`. The fetch is `$createdAt`-ascending and may stop at a + // per-sweep page budget, so this may be a partial — advancing to the + // max fetched is exactly what lets the next sweep resume the rest. + 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(); - // 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) - { + // --- 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(); + // Track whether every ingest reached disk. A swallowed persist + // failure would let the cursor advance past a request that + // never persisted, so the next `$createdAt >` sweep would skip + // it. On failure we stop ingesting that direction and leave its + // cursor unadvanced so the next sweep re-fetches and retries. + let mut received_persist_ok = true; + let mut sent_persist_ok = true; - let props = doc.properties(); + // (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); - let sender_key_index = match props - .get("senderKeyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - { - Some(v) => v, - None => { - tracing::warn!( + 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, - "Skipping contact request document: missing senderKeyIndex" + account_reference = contact_request.account_reference, + "Skipping ignored sender's contact request" ); continue; } - }; - let recipient_key_index = match props - .get("recipientKeyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - { - Some(v) => v, - None => { - tracing::warn!( - sender = %sender_id, - recipient = %identity_id, - "Skipping contact request document: missing recipientKeyIndex" - ); + // 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 + .dashpay() + .incoming_contact_requests() + .get(&sender_id) + .map(|r| r.account_reference) + .or_else(|| { + managed + .dashpay() + .established_contacts() + .get(&sender_id) + .map(|c| c.incoming_request.account_reference) + }); + if tracked_reference == Some(contact_request.account_reference) { continue; } - }; - let account_reference = match props - .get("accountReference") - .and_then(|v: &Value| v.to_integer::().ok()) - { - Some(v) => v, - None => { - tracing::warn!( - sender = %sender_id, - recipient = %identity_id, - "Skipping contact request document: missing accountReference" - ); + + 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. + match managed.apply_rotated_incoming_request( + contact_request.clone(), + &self.persister, + ) { + Ok(true) => rotated_contacts.push(sender_id), + Ok(false) => {} + Err(e) => { + tracing::error!( + recipient = %identity_id, error = %e, + "received-request rotation persist failed; leaving received cursor for retry" + ); + received_persist_ok = false; + break; + } + } + all_requests.push(contact_request); continue; } - }; - let encrypted_public_key = match props - .get("encryptedPublicKey") - .and_then(|v: &Value| v.as_bytes()) - .cloned() - { - Some(v) => v, - None => { - tracing::warn!( - sender = %sender_id, - recipient = %identity_id, - "Skipping contact request document: missing encryptedPublicKey" + + if let Err(e) = managed + .add_incoming_contact_request(contact_request.clone(), &self.persister) + { + tracing::error!( + recipient = %identity_id, error = %e, + "received-request ingest persist failed; leaving received cursor for retry" ); - continue; + received_persist_ok = false; + break; } - }; + all_requests.push(contact_request); + } + + // (2) Ingest our own sent requests. `add_sent_contact_request` + // guards itself against duplicates / metadata loss. + // Collapse to the single newest doc per recipient FIRST + // (see `newest_sent_per_recipient`): a rotation re-send + // leaves the old + bumped docs on-chain and the fetch is + // `$createdAt`-ASC, so ingesting raw would establish + // against the stale OLDEST reference on a restore-from-seed + // and collide on the next rotation. + let parsed_sent = sent_docs.iter().filter_map(|(_doc_id, maybe_doc)| { + let doc = maybe_doc.as_ref()?; + // For a sent request the recipient is `toUserId`. + let recipient_id = doc + .properties() + .get("toUserId") + .and_then(|v: &Value| v.to_identifier().ok())?; + Self::parse_sent_contact_request_doc(doc, identity_id, recipient_id) + }); + let newest_by_recipient = newest_sent_per_recipient(parsed_sent); + for (_recipient_id, contact_request) in newest_by_recipient { + if let Err(e) = + managed.add_sent_contact_request(contact_request, &self.persister) + { + tracing::error!( + owner = %identity_id, error = %e, + "sent-request ingest persist failed; leaving sent cursor for retry" + ); + sent_persist_ok = false; + break; + } + } + + // (2a') Rotation self-heal across restart: an external account + // rebuilt from the persisted (tombstone-less) registration + // row after a restart can carry the STALE xpub while the + // established contact already tracks the new incoming + // reference (the deferred rebuild was lost with the + // in-memory queue at load). Such an account exists but was + // NOT built from the current reference, so the plain + // `has_external` gate would skip it forever. Detect a + // registered external account whose recorded + // `external_account_reference` does not match the contact's + // current `incoming_request.account_reference` (including a + // `None` marker from a cold restore that didn't carry it), + // and enqueue it for teardown + rebuild alongside the + // in-pass rotations. Idempotent: once rebuilt the marker is + // stamped, so the next sweep sees a match and skips it. + // Accesses the managed identity's established contacts and + // `info.core_wallet` as DISJOINT fields of `info` via a + // plain `for` loop (the same split the teardown loop below + // relies on) — a closure capturing whole `info` would + // collide with the mutable `managed` reborrow. + { + use key_wallet::account::account_collection::DashpayAccountKey; + for (contact_id, contact) in managed.dashpay().established_contacts().iter() { + 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 external_account_needs_rebuild(contact, has_external) + && !rotated_contacts.contains(contact_id) + { + rotated_contacts.push(*contact_id); + } + } + } + + // (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. Advance a + // direction's cursor only when BOTH its fetch succeeded AND + // every ingest reached disk — a mid-sweep fetch error or a + // persist failure leaves that cursor intact so the next sweep + // re-fetches and retries (no burying, no skip past an + // unpersisted request). The compare-and-advance itself (a + // concurrent `unignore_sender` rewind must not be clobbered by + // this sweep's stale max) lives in the state layer. + if received_persist_ok { + managed.advance_high_water_received(hw_received, max_received); + } + if sent_ok && sent_persist_ok { + managed.advance_high_water_sent(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()); + // `to_binary_bytes()` (not `as_bytes()`, which matches only + // `Value::Bytes`) so the parse is robust to whatever byte-ish variant + // the document query yields (`Bytes`/`Bytes32`/`Array`/base64 + // `Text`) — and stays symmetric with the send-side bookkeeping, which + // also uses `to_binary_bytes()`. A variant mismatch here would silently + // drop a field (the bug class this path already shipped once). + let encrypted_public_key = props + .get("encryptedPublicKey") + .and_then(|v: &Value| v.to_binary_bytes().ok()); + // 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.to_binary_bytes().ok()); + // Optional DIP-15 `encryptedAccountLabel` — the contact's label for the + // account they shared. Read so the receive-side surfacing (decrypt + + // "Their account" row) has something to decrypt; without this the sweep + // silently drops the label and it never reaches the recipient. + let encrypted_account_label = props + .get("encryptedAccountLabel") + .and_then(|v: &Value| v.to_binary_bytes().ok()); - let contact_request = ContactRequest::new( + 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, - identity_id, - sender_key_index, - recipient_key_index, - account_reference, - encrypted_public_key, + recipient_id, + ski, + rki, + ar, + epk, doc.created_at_core_block_height().unwrap_or(0), doc.created_at().unwrap_or(0), ); - - managed.add_incoming_contact_request(contact_request.clone(), &self.persister); - all_requests.push(contact_request); + request.auto_accept_proof = auto_accept_proof; + request.encrypted_account_label = encrypted_account_label; + Some(request) + } + _ => { + tracing::warn!( + sender = %sender_id, + recipient = %recipient_id, + "Skipping contact request document: missing required field" + ); + None } } - - Ok(all_requests) } -} -// --------------------------------------------------------------------------- -// Accept an incoming contact request -// --------------------------------------------------------------------------- + /// 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) + } -impl IdentityWallet { - /// Accept an incoming contact request using an externally-supplied - /// signer. + /// 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. /// - /// Routes through - /// [`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( - &self, - request: &ContactRequest, - signer: &S, - ) -> Result - where - S: Signer + Send + Sync, - { - let our_identity_id = request.recipient_id; - let sender_id = request.sender_id; + /// 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, + }; - // 1. Verify the incoming request is known. - { + // 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 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))?; - if !managed.incoming_contact_requests.contains_key(&sender_id) { - return Err(PlatformWalletError::ContactRequestNotFound(sender_id)); + let Some(info) = wm.get_wallet_info(&self.wallet_id) else { + return; + }; + let Some(managed) = info.identity_manager.managed_identity(identity_id) else { + return; + }; + let mut already = managed + .dashpay() + .pending_contact_crypto + .iter() + .filter(|e| matches!(e.op, PendingContactCryptoOp::AutoAccept)) + .count(); + let mut picked = Vec::new(); + for (sender, request) in managed.dashpay().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; + } + // Signerless pre-check only (established? structurally valid + // proof? not a proof a prior drain already rejected this + // launch?) — the real ECDSA verify is in the drain. + if managed.should_enqueue_auto_accept(sender, request) { + picked.push(*sender); + already += 1; + } } + picked + }; + if to_enqueue.is_empty() { + return; } - // 2. Capture the encrypted xpub + key indices BEFORE sending - // the reciprocal request (same ordering as the legacy - // `accept_contact_request`). - let contact_encrypted_xpub = request.encrypted_public_key.clone(); - 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?; + let enqueued_at_ms = crate::util::now_ms(); + 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(); - // 4. Best-effort external-account registration. Failures are - // logged but do not abort. - if let Err(e) = self - .register_external_contact_account( - &our_identity_id, - &sender_id, - &contact_encrypted_xpub, - our_decryption_key_index, - contact_encryption_key_index, - ) - .await { + 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 { + tracing::warn!( + owner = %identity_id, + "auto-accept enqueue for a non-resident identity; dropping" + ); + return; + }; + for entry in &entries { + upsert_pending_contact_crypto( + managed.dashpay_pending_contact_crypto_mut(), + entry.clone(), + ); + } + } + let changeset = PlatformWalletChangeSet { + pending_contact_crypto_added: entries, + ..Default::default() + }; + if let Err(e) = self.persister.store(changeset) { tracing::warn!( - our_identity = %our_identity_id, - contact = %sender_id, - error = %e, - "Failed to register external contact account after accept (external signer) — \ - re-run register_external_contact_account to retry" + owner = %identity_id, error = %e, + "failed to persist auto-accept enqueue; will re-enqueue next sweep" ); } + } - // 5. Retrieve the auto-established contact. - 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))?; + /// 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; - managed - .established_contacts - .get(&sender_id) - .cloned() - .ok_or(PlatformWalletError::ContactRequestNotFound(sender_id)) - } -} + let Some(managed) = info.identity_manager.managed_identity(identity_id) else { + return Vec::new(); + }; -// --------------------------------------------------------------------------- -// Sent contact requests query -// --------------------------------------------------------------------------- + let mut out = Vec::new(); + for (contact_id, contact) in managed.dashpay().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 + } -impl IdentityWallet { - /// Fetch sent contact requests for a specific identity from Platform. + /// Build the two DashPay accounts for one established contact, + /// applying the transient/permanent failure policy. /// - /// Queries the DashPay contract for `contactRequest` documents where - /// `$ownerId == identity_id`, ordered by `$createdAt`. - /// - /// # Arguments + /// 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). /// - /// * `identity_id` - The identity whose sent requests to fetch. + /// 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. /// - /// # Returns + /// Watch-only / seedless wallets (no `identity_index`) are skipped and + /// logged — the watch-only ECDH path (host-side signing hook) lands + /// later. /// - /// A list of [`ContactRequest`] structs representing the sent requests. - pub async fn sent_contact_requests( + /// 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, - ) -> Result, PlatformWalletError> { - let sent_docs = self - .sdk - .fetch_sent_contact_requests(*identity_id, None) - .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to fetch sent contact requests: {e}" - )) - })?; + candidate: AccountBuildCandidate, + ) { + let contact_id = candidate.contact_id; - let mut requests = Vec::new(); + // 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; + } - for (_doc_id, maybe_doc) in sent_docs.iter() { - let doc = match maybe_doc { - Some(d) => d, - None => continue, - }; + // 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" + ); + } - let sender_id = doc.owner_id(); + /// 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 = crate::util::now_ms(); - let props = doc.properties(); + 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, + }, + ]; - let to_user_id = match props - .get("toUserId") - .and_then(|v: &Value| v.to_identifier().ok()) - { - Some(v) => v, - None => continue, - }; - let sender_key_index = match props - .get("senderKeyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - { - Some(v) => v, - None => continue, - }; - let recipient_key_index = match props - .get("recipientKeyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - { - Some(v) => v, - None => continue, - }; - let account_reference = match props - .get("accountReference") - .and_then(|v: &Value| v.to_integer::().ok()) - { - Some(v) => v, - None => continue, + // In-memory upsert onto the owner identity's queue, under the write + // lock (released before persisting). All entries share this owner. + { + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; }; - let encrypted_public_key = match props - .get("encryptedPublicKey") - .and_then(|v: &Value| v.as_bytes()) - .cloned() - { - Some(v) => v, - None => continue, + let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { + tracing::warn!( + identity = %identity_id, contact = %candidate.contact_id, + "deferred contact-crypto enqueue for a non-resident identity; dropping" + ); + return; }; + for entry in &entries { + upsert_pending_contact_crypto( + managed.dashpay_pending_contact_crypto_mut(), + entry.clone(), + ); + } + } - let mut contact_request = ContactRequest::new( - sender_id, - to_user_id, - sender_key_index, - recipient_key_index, - account_reference, - encrypted_public_key, - doc.created_at_core_block_height().unwrap_or(0), - doc.created_at().unwrap_or(0), + // 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" ); + } + } - // Attach optional encrypted account label if present. - contact_request.encrypted_account_label = props - .get("encryptedAccountLabel") - .and_then(|v: &Value| v.as_bytes()) - .cloned(); + /// 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| { + info.identity_manager + .managed_identities() + .map(|m| count_account_build_ops(&m.dashpay().pending_contact_crypto)) + .sum::() + }) + .unwrap_or(0) + } - // Attach optional auto-accept proof if present. - contact_request.auto_accept_proof = props - .get("autoAcceptProof") - .and_then(|v: &Value| v.as_bytes()) - .cloned(); + /// 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}; - requests.push(contact_request); + // Snapshot every resident identity's queue into one flat owned Vec + // (each entry self-identifies by `owner_identity_id`), 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.identity_manager + .managed_identities() + .flat_map(|m| m.dashpay().pending_contact_crypto.iter().cloned()) + .collect() + }) + .unwrap_or_default() + }; + if entries.is_empty() { + return 0; } - // Sort by creation time ascending. - requests.sort_by_key(|r| r.created_at); + 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" + ), + } + } + 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!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + "drain: owner not wallet-owned; leaving queued" + ); + continue; + }; - Ok(requests) - } -} + // ECDH path, built in Rust (path provenance stays here). + let path = match IdentityWallet::::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; + } + }; -// --------------------------------------------------------------------------- -// Reject contact request -// --------------------------------------------------------------------------- + // 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; + } + } + }; -impl IdentityWallet { - /// Reject a contact request by hiding the contact. - /// - /// This marks the contact as hidden in the local identity manager so that - /// the UI no longer surfaces it. A full DashPay implementation would also - /// create or update a `contactInfo` document on Platform with - /// `display_hidden: true`; that part requires SDK support for document - /// creation on arbitrary contracts which is not yet available here. - /// - /// # Arguments - /// - /// * `identity_id` - Our identity. - /// * `contact_identity_id` - The identity whose request we reject. - pub async fn reject_contact_request( - &self, - identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> Result<(), PlatformWalletError> { - let mut 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))?; + // 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!( + owner = %entry.owner_identity_id, contact = %entry.contact_id, + "drain: our identity vanished mid-drain; leaving queued" + ); + continue; + }; + 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!( + 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; + } - // Remove from incoming requests (if present). - if managed - .incoming_contact_requests - .remove(contact_identity_id) - .is_none() - { - return Err(PlatformWalletError::ContactRequestNotFound( - *contact_identity_id, - )); + // 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.clone(), + ) + .await + { + Ok(registration) => { + // Stamp the rotation self-heal marker and clear + // any stale broken-channel flag — only when the + // account was actually (re)built from this + // entry's payload. An `AlreadyExisted` no-op may + // have hit a pre-rotation row (the account key + // ignores `account_reference`); stamping it as + // current would suppress the sweep's teardown + + // rebuild forever (same reasoning as the accept + // path). + if registration == ExternalAccountRegistration::Built { + self.note_external_account_registered( + &entry.owner_identity_id, + &entry.contact_id, + encrypted_public_key, + ) + .await; + } + // Surface the contact's account label from the same + // ECDH shared key (best-effort, cosmetic — never + // fails the drain). + self.store_contact_account_label( + &entry.owner_identity_id, + &entry.contact_id, + &shared, + ) + .await; + 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). + } + } } - // 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. + if cleared.is_empty() { + return 0; + } - tracing::info!( - identity = %identity_id, - rejected_contact = %contact_identity_id, - "Contact request rejected (hidden locally)" - ); + // The drain ran over a lock-free SNAPSHOT: a concurrent rotation sweep + // may have `upsert`ed a fresh payload for one of these keys mid-drain. + // Removal must be value-aware — collect the full snapshot entries the + // drain actually processed so a payload changed under us survives to + // the next drain (the queue holds at most one entry per key, so the + // lookup is unambiguous). + let cleared_snapshots: Vec = cleared + .iter() + .filter_map(|k| entries.iter().find(|e| e.key() == *k).cloned()) + .collect(); - Ok(()) + // Remove the completed entries from the in-memory queue + persist the + // removal so they don't replay after a restart. Only remove a live + // entry still value-equal to the snapshot's — a mid-drain upsert + // (changed payload) is left queued for the next drain rather than + // clobbered by this stale snapshot. + let removed: Vec = { + let mut wm = self.wallet_manager.write().await; + match wm.get_wallet_info_mut(&self.wallet_id) { + Some(info) => { + // Route each drained snapshot entry back to its owner's queue + // and apply the value-aware retain there. Owner is on every + // entry (and in the equality key), so this preserves the + // concurrent-upsert safety per identity. An identity removed + // between the snapshot and here is skipped: its queue died + // with it, and `apply` ignores the cleared delta anyway. + let mut removed = Vec::new(); + for snap in &cleared_snapshots { + if let Some(managed) = info + .identity_manager + .managed_identity_mut(&snap.owner_identity_id) + { + removed.extend(retain_drained_by_snapshot( + managed.dashpay_pending_contact_crypto_mut(), + std::slice::from_ref(snap), + )); + } + } + removed + } + None => Vec::new(), + } + }; + if removed.is_empty() { + return 0; + } + let removed_count = removed.len(); + let changeset = crate::changeset::PlatformWalletChangeSet { + pending_contact_crypto_cleared: removed, + ..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)" + ); + } + + removed_count + } + + /// 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, across every resident identity + // (both buckets — an AutoAccept op can legitimately sit on an + // out-of-wallet identity, since the enqueue gate isn't wallet-scoped). + let entries: Vec = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .map(|info| { + info.identity_manager + .managed_identities() + .flat_map(|m| m.dashpay().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(); + // Permanent verify failures to mark so the sync sweep's enqueue gate + // does not re-queue the same bad proof every pass. `(owner, sender, + // proof)` — transient failures (provider unavailable / reciprocal-send + // failure) are NOT pushed here so they stay retryable. + let mut verify_failed: Vec<(Identifier, Identifier, 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.dashpay() + .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 + verify_failed.push((owner, sender, proof.to_vec())); + 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 + verify_failed.push((owner, sender, proof.to_vec())); + 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 + verify_failed.push((owner, sender, proof.to_vec())); + 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 + verify_failed.push((owner, sender, proof.to_vec())); + continue; + } + + // 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"), + } + } + + if !cleared.is_empty() { + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + // Remove the cleared entries from their owners' queues. Each + // cleared key names its owner, and only that owner's queue can + // hold it, so retain each affected owner's queue against the + // full cleared set. A plain key-`retain` is sufficient here + // (unlike the value-aware `retain_drained_by_snapshot` the main + // drain uses): `AutoAccept` is a payload-less unit variant, so a + // concurrent mid-drain upsert can't change an entry's value — + // key-equality is total. + let mut cleared_owners: Vec = + cleared.iter().map(|k| k.owner_identity_id).collect(); + cleared_owners.sort(); + cleared_owners.dedup(); + for owner in &cleared_owners { + if let Some(managed) = info.identity_manager.managed_identity_mut(owner) { + managed + .dashpay_pending_contact_crypto_mut() + .retain(|e| !cleared.iter().any(|k| *k == e.key())); + } + } + // Record permanent verify failures so the next sweep's + // enqueue gate skips the same bad proof (in-memory only — + // retried once per launch; the request stays manually + // acceptable). + for (owner, sender, proof) in &verify_failed { + if let Some(managed) = info.identity_manager.managed_identity_mut(owner) { + managed.mark_auto_accept_verify_failed(sender, proof); + } + } + } + } + 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. + /// + /// The QR's `du` is `owner_identity_id`'s DPNS name (a scanner resolves it + /// back to the owner's identity). `username` is the locally-cached name when + /// available; pass an empty string to resolve it on-chain instead — imported + /// or restored identities carry the name on-chain but not in the local cache, + /// which is exactly when this matters. Errors if no name can be found. + pub async fn build_auto_accept_qr( + &self, + owner_identity_id: &Identifier, + 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, + }; + // Prefer the caller-supplied (cached) name; fall back to an on-chain + // lookup when it is empty so the QR works regardless of local caching. + let resolved_name; + let username = if username.is_empty() { + let names = self + .sdk + .get_dpns_usernames_by_identity(*owner_identity_id, None) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "auto-accept QR: failed to resolve a DPNS name on-chain for \ + {owner_identity_id}: {e}" + )) + })?; + // Any of the identity's names resolves back to it, so the choice is + // cosmetic; pick the lexicographically smallest for a deterministic + // QR that stays stable across rebuilds. The app's "main name" + // preference isn't visible on this on-chain path — the cached-name + // branch above honors it whenever a local name is present. + resolved_name = names.into_iter().map(|n| n.label).min().ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "auto-accept QR requires a DPNS username; none is registered for this identity" + .to_string(), + ) + })?; + resolved_name.as_str() + } else { + username + }; + 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_contact_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); + } + } + + /// Record that the contact's outbound `DashpayExternalAccount` is now + /// built from the contact's current `incoming_request.account_reference`, + /// and clear any stale `payment_channel_broken` flag — the two success + /// side-effects of a completed [`register_external_contact_account`]. + /// + /// - Stamping `external_account_reference` lets the sweep's rotation + /// self-heal skip a healthy account (marker matches the tracked + /// reference) and detect a stale one after a restart (F2). + /// - Clearing `payment_channel_broken` heals a channel that a prior + /// permanent-fault marked broken but that a successful re-register (via + /// the drain or a user re-accept) proves is usable again — otherwise the + /// UI reports "broken" and blocks sending forever, since the sweep skips + /// broken contacts and never re-registers them (F12). + /// + /// Idempotent (skips the persist when nothing changed). Takes its own + /// write guard; the caller must hold no wallet-manager guard. + /// + /// `built_from_ciphertext` is the `encryptedPublicKey` blob the account + /// was actually registered from. Registration and this stamp run under + /// SEPARATE guards (the drain awaits the ECDH provider lock-free in + /// between), so a rotation sweep can advance `incoming_request` after + /// the payload was snapshotted but before this stamp. Stamping the LIVE + /// reference in that window would mark an account built from the + /// rotated-away xpub as current — `external_account_needs_rebuild` then + /// never fires and `send_payment` silently derives addresses the + /// contact no longer watches. Comparing the live ciphertext against the + /// one we built from detects the race; on mismatch the marker is left + /// stale (or `None`) so the sweep's teardown + rebuild picks up the + /// fresh request. + pub(crate) async fn note_external_account_registered( + &self, + identity_id: &Identifier, + contact_id: &Identifier, + built_from_ciphertext: &[u8], + ) { + 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_contact_mut(contact_id) else { + return; + }; + if contact.incoming_request.encrypted_public_key != built_from_ciphertext { + tracing::warn!( + owner = %identity_id, contact = %contact_id, + "external account registered from a superseded payload (rotation raced \ + the registration); leaving the self-heal marker stale so the sweep rebuilds" + ); + return; + } + let current_reference = contact.incoming_request.account_reference; + let already_current = contact.external_account_reference == Some(current_reference); + if already_current && !contact.payment_channel_broken { + return; + } + // Persist BEFORE committing to memory: if the store fails, the + // in-memory marker must stay stale so the `already_current` guard + // above and the sweep's `external_account_needs_rebuild` predicate + // keep retriggering until a persist succeeds — otherwise memory runs + // ahead of disk and nothing in-process ever retries (only a restart + // reloading the stale marker would heal it). + let mut updated = contact.clone(); + updated.external_account_reference = Some(current_reference); + updated.payment_channel_broken = false; + + let mut cs = crate::changeset::ContactChangeSet::default(); + cs.established.insert( + crate::changeset::SentContactRequestKey { + owner_id: *identity_id, + recipient_id: *contact_id, + }, + updated.clone(), + ); + if let Err(e) = self.persister.store(cs.into()) { + tracing::error!( + "Failed to persist external-account-registered changeset: {}", + e + ); + return; + } + *contact = updated; + } + + /// Decrypt the contact's incoming `encryptedAccountLabel` with the ECDH + /// shared key and store the printable plaintext on the established + /// contact's `contact_account_label`. Best-effort and cosmetic: a missing + /// label, a decrypt/UTF-8 failure, or non-printable garbage leaves or sets + /// `None` — it never breaks the payment channel or fails the caller. + /// + /// The label is derived strictly from the **incoming** request (the label + /// the contact chose for the account they shared); the outgoing request + /// carries a label *we* sent and is never a source. AES-CBC has no + /// integrity, so a corrupt or non-conforming-sender ciphertext can unpad + /// into valid-UTF-8 garbage — empty / whitespace-only / control-char + /// results are coerced to `None` so the UI shows nothing rather than + /// garbage. + /// + /// Written to the in-memory `established` contact and flushed through an + /// `established` changeset entry. Unlike the broken-channel flag, the label + /// is **not** durably persisted by the SQLite backend: it is intentionally + /// re-derived from the incoming request on the next contact-info sweep so it + /// never goes stale against rotated key material (the field resets to `None` + /// on every (re-)establish/rotation) — a cold restart restores it empty and + /// the next sweep repopulates it. Self-contained locking: takes its own + /// write guard, and the decrypt is synchronous, so nothing awaits or + /// re-locks under it. The caller must hold no wallet-manager guard. + pub(crate) async fn store_contact_account_label( + &self, + identity_id: &Identifier, + contact_id: &Identifier, + shared_key: &[u8; 32], + ) { + 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_contact_mut(contact_id) else { + return; + }; + + // The contact's label rides their incoming request only. + let ciphertext = match &contact.incoming_request.encrypted_account_label { + Some(ct) => ct.clone(), + None => return, + }; + + let decrypted = match platform_encryption::decrypt_account_label(shared_key, &ciphertext) { + Ok(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() || trimmed.chars().any(char::is_control) { + None + } else { + Some(trimmed.to_string()) + } + } + Err(e) => { + tracing::debug!( + owner = %identity_id, contact = %contact_id, error = %e, + "Could not decrypt the contact's account label; leaving it unset" + ); + return; + } + }; + + // Idempotent — skip the changeset write when nothing changed. + if contact.contact_account_label == decrypted { + return; + } + contact.contact_account_label = decrypted; + let snapshot = contact.clone(); + + 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 account-label 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 +// --------------------------------------------------------------------------- + +impl DashPayView<'_, B> { + /// Accept an incoming contact request using an externally-supplied + /// signer. + /// + /// Routes through + /// [`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( + &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, and detect whether an + // on-platform reciprocal already exists for this pair. + let (already_established, already_reciprocated) = { + 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))?; + // 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 + .dashpay() + .established_contacts() + .contains_key(&sender_id); + let sent_exists = managed + .dashpay() + .sent_contact_requests() + .contains_key(&sender_id); + if !established + && !sent_exists + && !managed + .dashpay() + .incoming_contact_requests() + .contains_key(&sender_id) + { + return Err(PlatformWalletError::ContactRequestNotFound(sender_id)); + } + (established, established || sent_exists) + }; + + // 2. Capture the encrypted xpub + key indices BEFORE sending + // the reciprocal request (same ordering as the legacy + // `accept_contact_request`). + let contact_encrypted_xpub = request.encrypted_public_key.clone(); + let our_decryption_key_index = request.recipient_key_index; + let contact_encryption_key_index = request.sender_key_index; + + // 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" + ); + // Establish the contact locally from the accepted incoming request + // when it is not yet established. In the `sent_exists && !established` + // adopt path (our own reciprocal already sent, the counterparty's + // request not yet swept into `incoming_contact_requests`) nothing + // else inserts an `EstablishedContact` — `register_contact_account` + // and `register_external_contact_account` only touch account + // collections — so the step-5 `established_contacts.get` would return + // `None` and the accept would spuriously fail with + // `ContactRequestNotFound` despite the account registrations + // succeeding. Ingesting the request here collapses the pending sent + // entry + the incoming request into an established contact (the same + // path the normal flow and the sync sweep use), so the final lookup + // is guaranteed non-`None`. Idempotent: `add_incoming_contact_request` + // preserves metadata on an already-established pair. + if !already_established { + let mut wm = self.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity_mut(&our_identity_id)) + .ok_or(PlatformWalletError::IdentityNotFound(our_identity_id))?; + managed + .add_incoming_contact_request(request.clone(), &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "accept-adopt: incoming request not persisted: {e}" + )) + })?; + } + // 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. 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 + .accept_register_external_validated( + &our_identity_id, + &sender_id, + &contact_encrypted_xpub, + our_decryption_key_index, + contact_encryption_key_index, + crypto, + ) + .await + { + tracing::warn!( + our_identity = %our_identity_id, + contact = %sender_id, + error = %e, + "Failed to register external contact account after accept (external signer) — \ + re-run sync to retry" + ); + } + + // 5. Retrieve the auto-established contact. + 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))?; + + managed + .dashpay() + .established_contacts() + .get(&sender_id) + .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 = IdentityWallet::::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. + let registration = self + .register_external_contact_account( + our_identity_id, + &contact_identity, + contact_encrypted_xpub, + shared.clone(), + ) + .await + .map_err(RegisterExternalError::into_inner)?; + + // Stamp the rotation self-heal marker and clear any stale + // broken-channel flag (a user re-accept of a healed channel must stop + // reporting broken — the sweep never re-registers broken contacts). + // ONLY when the account was actually (re)built from this request's + // payload: an `AlreadyExisted` no-op may have hit a row built from a + // PRE-rotation xpub (the account key ignores `account_reference`), and + // stamping it as current would make `external_account_needs_rebuild` + // skip the teardown + rebuild forever — `send_payment` would keep + // deriving from an xpub the contact no longer watches. Left unstamped, + // the sweep detects the stale marker and rebuilds; its build path + // stamps + clears broken then. + if registration == ExternalAccountRegistration::Built { + self.note_external_account_registered( + our_identity_id, + contact_id, + contact_encrypted_xpub, + ) + .await; + } + + // Surface the contact's account label from the same ECDH shared key + // (best-effort, cosmetic — a label failure never fails the accept). + self.store_contact_account_label(our_identity_id, contact_id, &shared) + .await; + + Ok(()) + } + + /// 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 + } +} + +// --------------------------------------------------------------------------- +// Sent contact requests query +// --------------------------------------------------------------------------- + +impl DashPayView<'_, B> { + /// Fetch sent contact requests for a specific identity from Platform. + /// + /// Queries the DashPay contract for `contactRequest` documents where + /// `$ownerId == identity_id`, ordered by `$createdAt`. + /// + /// # Arguments + /// + /// * `identity_id` - The identity whose sent requests to fetch. + /// + /// # Returns + /// + /// A list of [`ContactRequest`] structs representing the sent requests. + pub async fn sent_contact_requests( + &self, + identity_id: &Identifier, + ) -> Result, PlatformWalletError> { + let sent_docs = self + .sdk + .fetch_sent_contact_requests(*identity_id, None) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch sent contact requests: {e}" + )) + })?; + + let mut requests = Vec::new(); + + for (_doc_id, maybe_doc) in sent_docs.iter() { + let doc = match maybe_doc { + Some(d) => d, + None => continue, + }; + + let sender_id = doc.owner_id(); + + let props = doc.properties(); + + let to_user_id = match props + .get("toUserId") + .and_then(|v: &Value| v.to_identifier().ok()) + { + Some(v) => v, + None => continue, + }; + let sender_key_index = match props + .get("senderKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + { + Some(v) => v, + None => continue, + }; + let recipient_key_index = match props + .get("recipientKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + { + Some(v) => v, + None => continue, + }; + let account_reference = match props + .get("accountReference") + .and_then(|v: &Value| v.to_integer::().ok()) + { + Some(v) => v, + None => continue, + }; + let encrypted_public_key = match props + .get("encryptedPublicKey") + .and_then(|v: &Value| v.as_bytes()) + .cloned() + { + Some(v) => v, + None => continue, + }; + + let mut contact_request = ContactRequest::new( + sender_id, + to_user_id, + sender_key_index, + recipient_key_index, + account_reference, + encrypted_public_key, + doc.created_at_core_block_height().unwrap_or(0), + doc.created_at().unwrap_or(0), + ); + + // Attach optional encrypted account label if present. + contact_request.encrypted_account_label = props + .get("encryptedAccountLabel") + .and_then(|v: &Value| v.as_bytes()) + .cloned(); + + // Attach optional auto-accept proof if present. + contact_request.auto_accept_proof = props + .get("autoAcceptProof") + .and_then(|v: &Value| v.as_bytes()) + .cloned(); + + requests.push(contact_request); + } + + // Sort by creation time ascending. + requests.sort_by_key(|r| r.created_at); + + Ok(requests) + } +} + +// --------------------------------------------------------------------------- +// Ignore / un-ignore a contact sender (per-sender mute, local-only) +// --------------------------------------------------------------------------- + +impl DashPayView<'_, B> { + /// 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. + /// + /// 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 sender to ignore. + pub async fn ignore_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))?; + + // 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))?; + + // `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, + 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::{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") }; + + // `0` is a real cursor value distinct from `None` — pin that a + // future "treat 0 as unset" refactor would regress. + assert_eq!(query_lower_bound(Some(0)), Some(0)); + } +} + +#[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(), + } + } + + 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) + .expect("setup persists"); + managed + .add_sent_contact_request(test_request(our, contact, 0), &p) + .expect("setup persists"); + assert_eq!(managed.dashpay().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 = + DashPayView::::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_contact_mut(&contact_id) + .unwrap() + .payment_channel_broken = true; + + let candidates = + DashPayView::::collect_account_build_candidates(&info, &our_id); + assert!( + candidates.is_empty(), + "a permanently-broken contact must not be retried by the sweep" + ); + } + + /// **F2 rotation self-heal predicate.** After a restart the persisted, + /// tombstone-less account-registration row rebuilds the contact's OLD + /// (rotated-away) external xpub while the established contact already + /// tracks the NEW incoming reference. `external_account_needs_rebuild` + /// must flag such a registered-but-stale account for teardown + rebuild — + /// including the `None` marker case (a cold restore that did not carry the + /// marker), which conservatively forces one rebuild. A registered account + /// whose marker MATCHES the tracked reference is healthy (no churn), a + /// missing account is not this predicate's job, and a broken channel is + /// left alone. + #[test] + fn external_account_needs_rebuild_detects_stale_registered_account() { + let contact_id = Identifier::from([2u8; 32]); + let outgoing = test_request(1, 2, 0); + let mut incoming = test_request(2, 1, 0); + incoming.account_reference = 200; // the CURRENT (post-rotation) reference + let mut contact = EstablishedContact::new(contact_id, outgoing, incoming); + + // Registered account still built from the OLD reference (100) — stale. + contact.external_account_reference = Some(100); + assert!( + external_account_needs_rebuild(&contact, true), + "a registered account whose marker != the tracked reference is stale" + ); + + // Cold restore that did not carry the marker (`None`) — force a rebuild. + contact.external_account_reference = None; + assert!( + external_account_needs_rebuild(&contact, true), + "a None marker after restart must force one rebuild" + ); + + // Healthy: marker matches the tracked reference — no rebuild (no churn). + contact.external_account_reference = Some(200); + assert!( + !external_account_needs_rebuild(&contact, true), + "a registered account built from the current reference is healthy" + ); + + // No registered account: not this predicate's job (ordinary candidate). + contact.external_account_reference = None; + assert!( + !external_account_needs_rebuild(&contact, false), + "a missing external account is not a stale-rebuild case" + ); + + // Broken channel is left alone even if the marker mismatches. + contact.external_account_reference = Some(100); + contact.payment_channel_broken = true; + assert!( + !external_account_needs_rebuild(&contact, true), + "a broken channel is never re-registered 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() + .dashpay() + .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() + .dashpay() + .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) + .expect("setup persists"); + 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). + let managed = info.identity_manager.managed_identity_mut(&our_id).unwrap(); + let wiped: Vec<_> = managed + .dashpay() + .ignored_senders() + .iter() + .copied() + .collect(); + for sender in &wiped { + managed.apply_unignored_sender(sender); + } + 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)); + } + + /// **Sent-side restore-from-seed rotation (the frozen-outgoing bug).** + /// A rotation re-send leaves our OWN old + bumped sent docs on-chain; + /// `fetch_sent_contact_requests` returns them `$createdAt`-ASC. Ingesting + /// raw would establish/track against the OLDEST (stale) outgoing + /// reference, so the next rotation re-derives the same reference and + /// collides on the unique index. `newest_sent_per_recipient` must + /// collapse to the single newest doc per recipient so restore tracks the + /// freshest outgoing reference. + #[test] + fn newest_sent_per_recipient_collapses_rotated_recipient_to_latest_doc() { + let our = 1u8; + let recipient = 2u8; + // Our own sent docs to one recipient: old ref=100 @t=100, rotated + // ref=101 @t=200. `test_request_at(sender, recipient, ..)` — here we + // are the sender. + let old_doc = test_request_at(our, recipient, 100, 100); + let rotated_doc = test_request_at(our, recipient, 101, 200); + // A second recipient to prove per-recipient keying. + let other = test_request_at(our, 3, 100, 150); + + // Feed old-before-new (the $createdAt-ASC order the fetch yields). + let collapsed = + newest_sent_per_recipient([old_doc.clone(), other.clone(), rotated_doc.clone()]); + + assert_eq!(collapsed.len(), 2, "one entry per distinct recipient"); + let recipient_id = Identifier::from([recipient; 32]); + assert_eq!( + collapsed.get(&recipient_id).map(|r| r.account_reference), + Some(101), + "the newest (rotated) sent doc must win, not the stale oldest" + ); + assert_eq!( + collapsed + .get(&Identifier::from([3u8; 32])) + .map(|r| r.account_reference), + Some(100), + "the unrelated recipient is unaffected" + ); + + // Fixpoint: re-collapsing yields the same. + let again = newest_sent_per_recipient(collapsed.values().cloned()); + assert_eq!( + again.get(&recipient_id).map(|r| r.account_reference), + Some(101) + ); + } + + /// **Receive-side label ingest (the sweep-parser drop bug).** + /// The recurring sweep parses received `contactRequest` docs via + /// `parse_contact_request_doc`. It must carry the optional DIP-15 + /// `encryptedAccountLabel` onto the `ContactRequest` — otherwise the + /// label the sender attached (and which lands on-chain) is silently + /// dropped on ingest, so the receive-side surfacing has nothing to + /// decrypt. Pins that the field survives the parse (red before the + /// parser read it, green after). + #[test] + fn parse_contact_request_doc_carries_encrypted_account_label() { + use dpp::document::{Document, DocumentV0}; + use dpp::platform_value::Value; + use std::collections::BTreeMap; + + let sender = Identifier::from([2u8; 32]); + let recipient = Identifier::from([1u8; 32]); + + let label_ct = vec![0x2au8; 48]; + let mut properties = BTreeMap::new(); + properties.insert("senderKeyIndex".to_string(), Value::U32(0)); + properties.insert("recipientKeyIndex".to_string(), Value::U32(0)); + properties.insert("accountReference".to_string(), Value::U32(5)); + properties.insert( + "encryptedPublicKey".to_string(), + Value::Bytes(vec![1u8; 96]), + ); + properties.insert( + "encryptedAccountLabel".to_string(), + Value::Bytes(label_ct.clone()), + ); + + let doc = Document::V0(DocumentV0 { + id: Identifier::from([9u8; 32]), + owner_id: sender, + properties, + revision: None, + created_at: Some(123), + 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: Some(456), + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + let parsed = + DashPayView::::parse_contact_request_doc(&doc, sender, recipient) + .expect("a complete contactRequest doc must parse"); + assert_eq!( + parsed.encrypted_account_label, + Some(label_ct), + "the sweep parser must carry encryptedAccountLabel onto the request, \ + else the receive-side label surfacing has nothing to decrypt" + ); + } + + /// **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.dashpay().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()) + .expect("setup persists"); + 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) + .expect("rotation persists in test"), + "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) + .expect("rotation persists in test"), + "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() + .dashpay() + .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:?}" + ); + } + + /// **Own-key selector: a disabled first ENCRYPTION key is skipped in + /// favour of the enabled replacement.** After a disable-and-replace key + /// rotation the lowest-id ENCRYPTION key is disabled; selecting it + /// would hard-fail the pre-send validator on every new outgoing contact + /// request ("Sender key N is disabled") even though an enabled + /// replacement exists — while the contactInfo path (which already + /// filtered disabled keys) would use the replacement, splitting the two + /// surfaces' notion of the ECDH root. Was red against the send path's + /// unfiltered inline selection. + #[test] + fn own_key_selector_skips_disabled_first_encryption_key() { + let identity = identity_with_keys(vec![ + key(0, KeyType::ECDSA_SECP256K1, Purpose::AUTHENTICATION), + disabled_key(1, KeyType::ECDSA_SECP256K1, Purpose::ENCRYPTION), + key(2, KeyType::ECDSA_SECP256K1, Purpose::ENCRYPTION), + ]); + + let selected = select_own_encryption_key(&identity) + .expect("must skip the disabled key and select the enabled replacement"); + assert_eq!( + selected.id(), + 2, + "the disabled lowest-id ENCRYPTION key (id 1) must not be the ECDH root" + ); + } + + /// Own-key selector: enabled-only, so an identity whose ONLY + /// ENCRYPTION key is disabled errors instead of deriving ECDH from a + /// revoked key. + #[test] + fn own_key_selector_errors_when_only_encryption_key_is_disabled() { + let identity = identity_with_keys(vec![ + key(0, KeyType::ECDSA_SECP256K1, Purpose::AUTHENTICATION), + disabled_key(1, KeyType::ECDSA_SECP256K1, Purpose::ENCRYPTION), + ]); + + let err = select_own_encryption_key(&identity).unwrap_err(); + assert!( + matches!(err, PlatformWalletError::InvalidIdentityData(_)), + "a sole disabled ENCRYPTION 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 DIP-15 friendship ECDH key must stay inside `Zeroizing` all the way + /// back to platform-wallet so it is scrubbed on drop rather than left in a + /// bare `[u8; 32]`. Binding the result to `Zeroizing<[u8; 32]>` fails to + /// compile against a plain-array trait return; the value must still match + /// the resident `derive_shared_key_ecdh` at the same path. + #[tokio::test] + async fn ecdh_shared_secret_returns_zeroizing_matching_resident_derivation() { + use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + let seed = Mnemonic::from_phrase(PHRASE, Language::English) + .expect("valid mnemonic") + .to_seed(""); + let network = Network::Testnet; + + let path = + identity_auth_derivation_path_for_type(network, KeyDerivationType::ECDSA, 0u32, 2u32) + .expect("auth path"); + + let peer = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[0x42u8; 32]).expect("peer secret"), + ); + + let provider = SeedCryptoProvider::from_seed(seed, network); + let shared: zeroize::Zeroizing<[u8; 32]> = provider + .ecdh_shared_secret(&path, &peer) + .await + .expect("ecdh shared secret"); + + let wallet = key_wallet::wallet::Wallet::from_seed_bytes( + seed, + network, + key_wallet::wallet::initialization::WalletAccountCreationOptions::None, + ) + .expect("wallet"); + let xprv = wallet + .derive_extended_private_key(&path) + .expect("resident xprv"); + let expected = platform_encryption::derive_shared_key_ecdh(&xprv.private_key, &peer); + + assert_eq!( + *shared, expected, + "the Zeroizing ECDH key must equal the resident derivation at the same path" + ); + } + + /// 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); + } +} + +#[cfg(test)] +mod stamp_race_tests { + //! The rotation self-heal stamp must be payload-bound, not live-state + //! bound: registration (drain / accept) and the stamp run under separate + //! guards, so a rotation sweep can advance `incoming_request` in between. + //! Stamping the live reference onto an account built from the superseded + //! payload would silence `external_account_needs_rebuild` forever. + + use super::*; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::EstablishedContact; + use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + use std::collections::BTreeMap; + use std::sync::Arc; + + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + fn bare_identity(id: [u8; 32]) -> Identity { + Identity::V0(IdentityV0 { + id: Identifier::from(id), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }) + } + + /// The stamp must be a no-op when the live incoming request's ciphertext + /// no longer matches the payload the account was registered from (a + /// rotation raced the registration), and must stamp normally when they + /// match. Was red against the live-reference stamp. + #[tokio::test] + async fn stamp_skips_when_registration_raced_a_rotation() { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let persister = Arc::new(NoPlatformPersistence); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(crate::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::None, + Some(0), + ) + .await + .expect("wallet creation"); + let wallet_id = wallet.wallet_id(); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + // Live state: the contact ROTATED — incoming_request now carries the + // fresh ciphertext (7s) under reference 7; no marker yet. + let fresh_cipher = vec![7u8; 96]; + let stale_cipher = vec![9u8; 96]; + { + 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 outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = + ContactRequest::new(contact, owner, 0, 0, 7, fresh_cipher.clone(), 100, 0); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + } + + let marker = |iw: &IdentityWallet| { + let iw = iw.clone(); + async move { + let wm = iw.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .and_then(|info| info.identity_manager.managed_identity(&owner)) + .and_then(|m| m.dashpay().established_contacts().get(&contact).cloned()) + .expect("established contact") + .external_account_reference + } + }; + + // Drain finished registering from the STALE (pre-rotation) payload: + // the stamp must detect the mismatch and leave the marker unset so + // the sweep's teardown + rebuild path picks up the fresh request. + iw.dashpay() + .note_external_account_registered(&owner, &contact, &stale_cipher) + .await; + assert_eq!( + marker(iw).await, + None, + "a stamp from a superseded payload must NOT mark the account current" + ); + + // Registration from the LIVE payload stamps the tracked reference. + iw.dashpay() + .note_external_account_registered(&owner, &contact, &fresh_cipher) + .await; + assert_eq!( + marker(iw).await, + Some(7), + "a stamp from the live payload records the tracked reference" + ); } } 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..324793e78cc 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,16 +8,117 @@ 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), +} + +/// Outcome of a successful [`register_external_contact_account`] call. +/// +/// The distinction matters to the rotation self-heal: only a [`Built`] +/// outcome proves the account was (re)constructed from the payload the +/// caller holds, so only [`Built`] may stamp the contact's +/// `external_account_reference` marker. An [`AlreadyExisted`] row is keyed +/// on `(index, user, friend)` — independent of `account_reference` — and +/// may predate a rotation (stale xpub); stamping it as current would stop +/// `external_account_needs_rebuild` from ever tearing it down. +/// +/// [`Built`]: ExternalAccountRegistration::Built +/// [`AlreadyExisted`]: ExternalAccountRegistration::AlreadyExisted +/// [`register_external_contact_account`]: IdentityWallet::register_external_contact_account +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExternalAccountRegistration { + /// The account was decrypted and (re)built from the supplied encrypted + /// xpub by this call. + Built, + /// An account row already existed for the `(index, user, friend)` key; + /// nothing was decrypted or replaced. + AlreadyExisted, +} + +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 // --------------------------------------------------------------------------- -impl IdentityWallet { +impl DashPayView<'_, B> { // TODO: We don't want to clone all contacts on get - it's terrible. /// Get all established contacts across every identity managed by this wallet. /// @@ -37,11 +137,11 @@ impl IdentityWallet { .identity_manager .out_of_wallet_identities .values() - .flat_map(|managed| managed.established_contacts.values().cloned()) + .flat_map(|managed| managed.dashpay().established_contacts().values().cloned()) .collect(); for inner in info.identity_manager.wallet_identities.values() { for managed in inner.values() { - out.extend(managed.established_contacts.values().cloned()); + out.extend(managed.dashpay().established_contacts().values().cloned()); } } out @@ -52,44 +152,7 @@ impl IdentityWallet { // Contact xpub and payment address derivation (DIP-14 / DIP-15) // --------------------------------------------------------------------------- -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. - /// +impl DashPayView<'_, B> { /// Register a DashPay contact account in the wallet's `ManagedWalletInfo`. /// /// Creates a `DashpayReceivingFunds` managed account with address pools @@ -102,6 +165,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 +177,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 +213,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 +258,12 @@ impl IdentityWallet { )) })?; + tracing::info!( + our_identity = %our_identity_id, + contact = %contact_identity_id, + "Registered DashpayReceivingFunds account for receiving payments from contact" + ); + Ok(()) } @@ -258,47 +370,13 @@ 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, - ) - } } // --------------------------------------------------------------------------- // External contact account registration (sending) // --------------------------------------------------------------------------- -impl IdentityWallet { +impl DashPayView<'_, B> { /// Register a watch-only `DashpayExternalAccount` for sending payments /// to a contact. Uses the contact's decrypted xpub from their /// `contactRequest.encrypted_public_key`. @@ -313,28 +391,46 @@ 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. + /// On success returns [`ExternalAccountRegistration`] so the caller can + /// tell a real (re)build from an already-existed no-op — only the former + /// may stamp the rotation self-heal marker (see the enum docs). 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: zeroize::Zeroizing<[u8; 32]>, + ) -> Result { + 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, @@ -347,112 +443,50 @@ impl IdentityWallet { .dashpay_external_accounts .contains_key(&key) { - return Ok(()); + return Ok(ExternalAccountRegistration::AlreadyExisted); } } - // --- 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 +513,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 +554,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!( @@ -512,6 +566,6 @@ impl IdentityWallet { "Registered DashpayExternalAccount for sending payments to contact" ); - Ok(()) + Ok(ExternalAccountRegistration::Built) } } 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/dashpay_view.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dashpay_view.rs new file mode 100644 index 00000000000..bd6de4bde93 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dashpay_view.rs @@ -0,0 +1,51 @@ +//! Zero-cost DashPay namespace over [`IdentityWallet`]. +//! +//! [`DashPayView`] is a borrowing view — NOT a second facade. The +//! previous owned `DashPayWallet` was merged into `IdentityWallet` +//! because two owned handles over the same state meant two FFI handles, +//! two clones per op, and split impls for ops that straddle the +//! identity/DashPay line. The view keeps that single-handle design and +//! adds only call-site namespacing: every DashPay-contract operation +//! (contact requests, contacts, contactInfo, payments, profiles) is +//! defined on `DashPayView`, reached as `wallet.identity().dashpay()`. +//! +//! The view [`Deref`]s to `IdentityWallet`, so DashPay ops keep direct +//! access to the shared plumbing they straddle into (signer derivation, +//! DPNS resolution, the SDK handle, the wallet manager). + +use std::ops::Deref; + +use crate::broadcaster::{SpvBroadcaster, TransactionBroadcaster}; + +use super::identity_handle::IdentityWallet; + +/// Borrowing namespace for the DashPay-contract operations of an +/// [`IdentityWallet`]. Obtain via [`IdentityWallet::dashpay`]. +pub struct DashPayView<'a, B: TransactionBroadcaster + ?Sized = SpvBroadcaster>( + &'a IdentityWallet, +); + +// Manual Clone/Copy: a derive would bound `B: Clone`/`B: Copy`, but the +// view only holds a shared reference. +impl Clone for DashPayView<'_, B> { + fn clone(&self) -> Self { + *self + } +} +impl Copy for DashPayView<'_, B> {} + +impl Deref for DashPayView<'_, B> { + type Target = IdentityWallet; + + fn deref(&self) -> &Self::Target { + self.0 + } +} + +impl IdentityWallet { + /// The DashPay-contract operations of this wallet handle: contact + /// requests, contacts, contactInfo, payments, and profiles. + pub fn dashpay(&self) -> DashPayView<'_, B> { + DashPayView(self) + } +} 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..2731e5ed159 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,84 @@ 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 = if reproduces { + // The candidate scalar reproduced the on-chain key, so this + // wallet owns it: emit the DIP-9 coordinates. The scalar is + // verified here and dropped — never carried out. The client + // re-derives the key on demand from the Keychain seed at this + // breadcrumb path when it needs to sign. + Some((wallet_id, identity_index, *key_id)) + } 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 + }; + crate::changeset::KeyWithBreadcrumb { + key: on_chain_key.clone(), + breadcrumb, + } + }) + .collect() +} + // --------------------------------------------------------------------------- // Identity discovery (gap-limit scan) // --------------------------------------------------------------------------- @@ -135,6 +213,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 +301,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 +377,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 +420,22 @@ 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. A failed persist here + // would silently leave the identity watch-only after + // restart, so surface it (matching `add_identity` above). + managed + .add_keys(key_decisions, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity keys not persisted during discovery: {e}" + )) + })?; } - // `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 +514,262 @@ 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 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_eq!(by_id[&1].breadcrumb, None, "foreign key left watch-only"); + } + + /// 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: the wallet only ever derives the + /// compressed pubkey form, so an uncompressed externally-registered key + /// is simply not wallet-derivable and stays watch-only by graceful + /// non-match — not because the protocol forbids such keys.) + #[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" + ); + } + + /// Decide key ownership from the candidate's compressed PUBLIC key alone, + /// reproducing `IdentityPublicKey::validate_private_key_bytes` without the + /// secret scalar: an `ECDSA_SECP256K1` key matches when the on-chain data + /// equals the 33-byte compressed pubkey; an `ECDSA_HASH160` key matches + /// when `ripemd160_sha256` of that pubkey equals the on-chain 20-byte + /// hash. Any other key type (BLS/EdDSA, or an uncompressed ECDSA key the + /// wallet never derives) never matches. + fn pubkey_reproduces(on_chain_key: &IdentityPublicKey, candidate_pubkey: &[u8; 33]) -> bool { + use dpp::util::hash::ripemd160_sha256; + match on_chain_key.key_type() { + KeyType::ECDSA_SECP256K1 => { + on_chain_key.data().as_slice() == candidate_pubkey.as_slice() + } + KeyType::ECDSA_HASH160 => { + ripemd160_sha256(candidate_pubkey.as_slice()).as_slice() + == on_chain_key.data().as_slice() + } + _ => false, + } + } + + /// The ownership decision derived from the candidate's PUBLIC key is + /// byte-for-byte identical to the one `validate_private_key_bytes` derives + /// from the secret scalar — for a matching `ECDSA_SECP256K1` key, a + /// matching `ECDSA_HASH160` key, and a foreign (non-reproducible) key. + /// This pins the equivalence that lets discovery verify ownership without + /// ever materializing the scalar. + #[test] + fn pubkey_verify_matches_scalar_verify_for_every_key() { + use dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; + use dpp::util::hash::ripemd160_sha256; + + let master = test_master(); + let network = Network::Testnet; + let identity_index = 0u32; + + // key_id 1 derives the pubkey behind the on-chain HASH160 key; key_id 9'/9 + // is an unrelated slot standing in for a FOREIGN key the wallet can't own. + let kp1 = + derive_ecdsa_identity_auth_keypair_from_master(&master, network, 0, 1).expect("derive"); + let kp_foreign = + derive_ecdsa_identity_auth_keypair_from_master(&master, network, 9, 9).expect("derive"); + let kp0 = + derive_ecdsa_identity_auth_keypair_from_master(&master, network, 0, 0).expect("derive"); + + let hash160_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: dpp::platform_value::BinaryData::new(ripemd160_sha256(&kp1.public_key).to_vec()), + disabled_at: None, + }); + + // (on-chain key, the (0, key_id) slot whose candidate is checked, expected). + let cases: Vec<(IdentityPublicKey, u32, bool)> = vec![ + (ecdsa_auth_key(0, kp0.public_key.to_vec()), 0, true), + (hash160_key, 1, true), + (ecdsa_auth_key(2, kp_foreign.public_key.to_vec()), 2, false), + ]; + + for (on_chain_key, key_id, expected) in &cases { + let candidate = derive_ecdsa_identity_auth_keypair_from_master( + &master, + network, + identity_index, + *key_id, + ) + .expect("derive candidate"); + + let scalar_decision = on_chain_key + .validate_private_key_bytes(&candidate.private_key, network) + .unwrap_or(false); + let pubkey_decision = pubkey_reproduces(on_chain_key, &candidate.public_key); + + assert_eq!( + scalar_decision, pubkey_decision, + "pubkey-verify diverged from scalar-verify for key {key_id}" + ); + assert_eq!( + pubkey_decision, *expected, + "unexpected ownership decision for key {key_id}" + ); + } + } +} 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..883e4bae99b 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,13 @@ pub struct IdentityWallet { /// `SpvBroadcaster`-pinned, while this one picks the broadcaster /// used by `send_payment` (static dispatch per call). pub(crate) broadcaster: Arc, + /// Concrete helper over the SDK's DashPay write operations + /// (contact-request broadcast, document put), wrapping `sdk`. It + /// erases the SDK's generic write signatures (`send_contact_request` + /// is generic over seven type params; the document put rides the + /// signer-generic `PutDocument` trait) behind two by-value methods + /// so the call sites stay simple. + pub(crate) sdk_writer: Arc, } // Manual `Debug`: the derive would require `B: Debug`, which is not part @@ -337,6 +344,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), } } } @@ -453,60 +461,6 @@ impl IdentityWallet { pub fn wallet_id(&self) -> &WalletId { &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..9d199067dad 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,16 @@ 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). A + // failed persist would silently leave the identity watch-only + // after restart, so surface it rather than swallow. + managed + .add_keys(key_decisions, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity keys not persisted during load: {e}" + )) + })?; } } 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..fb3615934b0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -1,19 +1,23 @@ //! Network-facing handle — a single façade over the shared //! `WalletManager` + the Platform SDK. //! -//! `IdentityWallet` covers both the identity lifecycle (register, -//! top-up, transfer, withdraw, update, discovery, DPNS, loading) and -//! the DashPay-contract operations that live on the same identity -//! (contact requests, contacts, profile, payments, account labels). +//! `IdentityWallet` covers the identity lifecycle (register, +//! top-up, transfer, withdraw, update, discovery, DPNS, loading); the +//! DashPay-contract operations that live on the same identity (contact +//! requests, contacts, profile, payments, account labels) are +//! namespaced behind the zero-cost borrowing view +//! [`DashPayView`] reached via `IdentityWallet::dashpay()`. //! -//! Historically DashPay lived on a separate `DashPayWallet` facade, -//! but both views operated on the same underlying state (a single -//! `ManagedIdentity` carries both identity fields and DashPay fields). -//! The two facades were merged to cut handle-juggling at the FFI -//! boundary and so DashPay ops can reuse the same signer / asset-lock -//! plumbing the identity lifecycle already owns. `B` picks the -//! transaction broadcaster used by DashPay `send_payment`; it defaults -//! to `SpvBroadcaster` so most call sites don't need to name it. +//! Historically DashPay lived on a separate owned `DashPayWallet` +//! facade, but both views operated on the same underlying state (a +//! single `ManagedIdentity` carries both identity fields and DashPay +//! fields). The two facades were merged to cut handle-juggling at the +//! FFI boundary and so DashPay ops can reuse the same signer / +//! asset-lock plumbing the identity lifecycle already owns; the +//! borrowing view restores the call-site namespace without +//! reintroducing a second handle. `B` picks the transaction +//! broadcaster used by DashPay `send_payment`; it defaults to +//! `SpvBroadcaster` so most call sites don't need to name it. // Core handle + identity-lifecycle operations. mod contract; @@ -31,19 +35,36 @@ mod transfer_to_addresses; mod update; mod withdrawal; -// DashPay-contract operations (same `IdentityWallet` impl blocks). -mod account_labels; +// DashPay-contract operations, namespaced behind `IdentityWallet::dashpay()`. +mod contact_info; mod contact_requests; mod contacts; -mod dashpay_sync; +mod dashpay_view; +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 dashpay_view::DashPayView; pub use discovery::IdentityDiscoveryOptions; pub use dpns::{ContestContender, ContestVoteState, ContestWinner}; pub use identity_handle::{ @@ -57,3 +78,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 ac19102794f..0d77468dae3 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,499 @@ 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. +impl DashPayView<'_, B> { + /// 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 { + /// 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 recorded = record_received_payment_totals( + info, + &self.persister, + totals + .into_iter() + .map(|((owner, contact, txid), amount_duffs)| (owner, contact, txid, amount_duffs)), + "reconcile", + ); + Ok(recorded) + } + + /// Re-scan L1 for incoming DashPay payments that landed on a contact's + /// receival address **before** that address was being watched (DIP-15 §8.7 + /// + §12.6). + /// + /// Receival accounts are built lazily — after restore-from-seed, on a second + /// device, or in the offline-accept→pay window the account appears only once + /// the contact is established, by which point SPV has already scanned past + /// the contact's funding height. Those addresses then enter the compact + /// filter match set **forward-only**, so a payment in an already-scanned + /// block is silently missed. + /// + /// This lowers the wallet's SPV `synced_height` to the minimum + /// `$coreHeightCreatedAt` across established receival contacts that haven't + /// been rescanned yet — the filter manager (`dash-spv`) then re-downloads + /// nothing it already has, re-matches the now-larger script set, and + /// re-requests the matching blocks. Each contact is recorded in + /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState) so the recurring sweep does + /// not re-lower the height every pass (which would reset the in-flight + /// backfill and keep it from ever completing). The guard is in-memory, so a + /// relaunch — where `synced_height` is restored at its high-water — safely + /// re-triggers an interrupted backfill. + /// + /// `synced_height` may regress here: that is safe because it is the + /// filter-scan checkpoint, decoupled from the monotonic + /// `last_processed_height`, and every persisted sync cursor is monotonic-max + /// guarded, so a transient rewind cannot corrupt state or persist a lower + /// cursor. Only the **receival** account matters — `DashpayExternalAccount` + /// is outbound and never receives. Returns the floor the height was lowered + /// to, or `None`. + pub async fn reconcile_dashpay_rescan(&self) -> Result, PlatformWalletError> { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&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, - ); + let synced_height = info.core_wallet.synced_height(); + // 0 means "scan from genesis / not yet started" — already a full + // historical scan, nothing to backfill toward. + if synced_height == 0 { + return Ok(None); + } + + // (owner, contact) pairs that have a receival account — we can only + // watch a contact's incoming addresses once its receival account exists. + let receival_pairs: Vec<(Identifier, Identifier)> = info + .core_wallet + .accounts + .dashpay_receival_accounts + .keys() + .map(|k| { + ( + Identifier::from(k.user_identity_id), + Identifier::from(k.friend_identity_id), + ) + }) + .collect(); + + // Candidates: established receival contacts not yet rescanned this + // lifetime whose funding height is below our scan tip. The floor is the + // minimum funding height — one rewind covers them all (deeper-funded + // contacts are in the watch set, so the backfill matches them too). The + // funding height is `min(outgoing, incoming)` of the pair: the channel + // is payable only once both requests exist, so the earlier of the two is + // the conservative-correct lower bound. + let mut floor: Option = None; + let mut to_mark: Vec<(Identifier, Identifier)> = Vec::new(); + for (owner, contact) in receival_pairs { + let Some(managed) = info.identity_manager.managed_identity(&owner) else { + continue; + }; + if managed.dashpay().rescan_triggered.contains(&contact) { + continue; + } + let Some(established) = managed.dashpay().established_contacts().get(&contact) else { + continue; + }; + let funding = established + .outgoing_request + .core_height_created_at + .min(established.incoming_request.core_height_created_at); + // Contacts funded below the tip need a backfill — their addresses + // weren't watched when those blocks were first scanned. Contacts + // funded at or after the tip are already covered by the ongoing + // forward scan (their addresses are watched from establishment). + // EITHER way the contact is now handled, so mark it: once the + // forward pointer later climbs past a still-forward-covered + // contact's funding height, the recurring sweep must NOT then + // rewind to it and redundantly re-scan an already-scanned range. + if funding < synced_height { + floor = Some(floor.map_or(funding, |cur| cur.min(funding))); + } + to_mark.push((owner, contact)); + } + + if to_mark.is_empty() { + return Ok(None); + } + + // Lower the filter-scan checkpoint only when a contact is funded below + // the tip (otherwise every handled contact is forward-covered and we + // record the guard without rewinding). The engine clamps `floor` to its + // own header/birth floor, so no double-clamp here. + if let Some(floor) = floor { + info.core_wallet.update_synced_height(floor); + } + let triggered = to_mark.len(); + for (owner, contact) in to_mark { + if let Some(managed) = info.identity_manager.managed_identity_mut(&owner) { + managed.dashpay_rescan_triggered_mut().insert(contact); + } + } + if let Some(floor) = floor { + tracing::info!( + wallet_id = %hex::encode(self.wallet_id), + floor, + contacts = triggered, + "DashPay rescan: lowered SPV synced_height to backfill historical contact payments" + ); + } + Ok(floor) + } + + /// 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; + }; + + let mut totals: BTreeMap<(Identifier, Identifier), u64> = BTreeMap::new(); + for (address, value) in candidates { + if let Some(m) = + DashPayView::::match_in_collection(info, &address) + { + *totals + .entry((m.user_identity_id, m.friend_identity_id)) + .or_default() += value; + } + } + + record_received_payment_totals( + info, + persister, + totals + .into_iter() + .map(|((owner, contact), amount_duffs)| (owner, contact, txid.clone(), amount_duffs)), + "live", + ); +} + +/// Record a `Received` [`PaymentEntry`] for each `(owner, contact, txid, +/// amount_duffs)` total, applying the shared txid-dedup guard so an existing +/// entry for a txid (including the owner's own `Sent` record when both +/// identities live in one wallet) is never overwritten. +/// +/// Shared by the live-detection path ([`record_incoming_dashpay_payments`], +/// per-tx totals) and the reconcile sweep +/// ([`IdentityWallet::reconcile_incoming_payments`], per-account totals), so +/// the dedup guard and `PaymentEntry` construction cannot drift between them. +/// A failed persist is logged and skipped — the reconcile sweep re-derives it +/// from UTXOs next pass. `context` labels the log line for the calling path. +/// Returns the number of newly recorded entries. +fn record_received_payment_totals( + info: &mut PlatformWalletInfo, + persister: &crate::wallet::persister::WalletPersister, + totals: impl IntoIterator, + context: &'static str, +) -> usize { + 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, + context, + "Recording 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, + ), + persister, + ) { + tracing::warn!(error = %e, context, "Failed to persist incoming payment; will retry next sweep"); } + recorded += 1; + } + recorded +} + +/// 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}; - Ok(m) + 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; } } @@ -64,7 +503,7 @@ impl IdentityWallet { // Send payment to contact // --------------------------------------------------------------------------- -impl IdentityWallet { +impl DashPayView<'_, B> { /// Send a Core payment to a DashPay contact. /// /// Derives the next payment address from the contact's `DashpayExternalAccount` @@ -85,24 +524,41 @@ 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`). + /// * `provider` - [`ContactCryptoProvider`] used to drain any + /// deferred contact-crypto build for this contact before the send. The + /// original sender's external-account build is enqueued by the signerless + /// sweep and completed only by a later `drain_pending_contact_crypto`; + /// draining here (with a signer present) builds the account on demand so + /// the very first send after establishing a contact succeeds instead of + /// failing the external-account lookup below. /// /// # 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, + provider: &C, ) -> Result< ( dashcore::Txid, crate::wallet::identity::types::dashpay::payment::PaymentEntry, ), PlatformWalletError, - > { + > + where + S: key_wallet::signer::Signer, + C: crate::wallet::identity::network::contact_requests::ContactCryptoProvider + Sync, + { use key_wallet::account::account_collection::DashpayAccountKey; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; @@ -110,6 +566,15 @@ impl IdentityWallet { let account_index: u32 = 0; + // Complete any deferred contact-crypto build before resolving the + // external account. The signerless sweep enqueues the original + // sender's `RegisterExternal` op and never builds it inline; with a + // signer present here we drain first so the account exists for the + // lookup below. Idempotent and a cheap no-op when the queue is empty. + // Run BEFORE acquiring the wallet-manager write guard — the drain + // re-acquires that (non-reentrant) lock internally. + self.drain_pending_contact_crypto(provider).await; + let (payment_address, tx) = { let mut wm = self.wallet_manager.write().await; @@ -195,8 +660,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 @@ -234,18 +703,2700 @@ impl IdentityWallet { ); { let mut wm = self.wallet_manager.write().await; - 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, - ); - } - } + // A missing wallet info or unmanaged `from_identity_id` is a real + // state error, not a benign no-op: the tx is already broadcast + // on-chain but the local Sent entry + memo has no on-chain + // recovery, so swallowing the lookup miss here would lose the + // user's payment record with no signal. Fail loud (same rationale + // as the persist-failure propagation just below) so the UI can + // report the partial outcome (sent, but not recorded). + 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(from_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*from_identity_id))?; + managed + .record_dashpay_payment(txid.to_string(), entry.clone(), &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "payment broadcast but not recorded locally: {e}" + )) + })?; } 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 {} + + /// Seed-backed [`key_wallet::signer::Signer`] for the send-path tests. + /// Derives every key from the test seed, so it is a faithful stand-in for + /// the Keychain signer — but the send-payment tests fail before any input + /// is signed (no external account / no spendable UTXOs), so in practice + /// only the type bound matters here. + struct SeedSigner { + wallet: key_wallet::wallet::Wallet, + } + + impl SeedSigner { + fn new(seed: [u8; 64], network: Network) -> Self { + Self { + wallet: key_wallet::wallet::Wallet::from_seed_bytes( + seed, + network, + WalletAccountCreationOptions::None, + ) + .expect("seed wallet"), + } + } + } + + #[async_trait::async_trait] + impl key_wallet::signer::Signer for SeedSigner { + type Error = String; + + fn supported_methods(&self) -> &[key_wallet::signer::SignerMethod] { + &[key_wallet::signer::SignerMethod::Digest] + } + + async fn sign_ecdsa( + &self, + path: &key_wallet::bip32::DerivationPath, + sighash: [u8; 32], + ) -> Result< + ( + dashcore::secp256k1::ecdsa::Signature, + dashcore::secp256k1::PublicKey, + ), + String, + > { + let xprv = self + .wallet + .derive_extended_private_key(path) + .map_err(|e| e.to_string())?; + let secp = dashcore::secp256k1::Secp256k1::new(); + let msg = dashcore::secp256k1::Message::from_digest(sighash); + let sig = secp.sign_ecdsa(&msg, &xprv.private_key); + let pk = dashcore::secp256k1::PublicKey::from_secret_key(&secp, &xprv.private_key); + Ok((sig, pk)) + } + + async fn public_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + let xprv = self + .wallet + .derive_extended_private_key(path) + .map_err(|e| e.to_string())?; + let secp = dashcore::secp256k1::Secp256k1::new(); + Ok(dashcore::secp256k1::PublicKey::from_secret_key( + &secp, + &xprv.private_key, + )) + } + } + + #[async_trait::async_trait] + impl key_wallet::signer::ExtendedPubKeySigner for SeedSigner { + async fn extended_public_key( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + self.wallet + .derive_extended_public_key(path) + .map_err(|e| e.to_string()) + } + } + + /// 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() + .dashpay() + .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() + .dashpay() + .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.dashpay() + .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 + .dashpay() + .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 + .dashpay() + .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.dashpay() + .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 + .dashpay() + .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" + ); + } + + /// The shared totals-consume helper both incoming paths route through + /// (`record_incoming_dashpay_payments` live + `reconcile_incoming_payments` + /// sweep) must apply the txid-dedup guard and construct `Received` entries: + /// a total for a txid that already has an entry is skipped (never + /// clobbered), a total for a fresh txid is recorded, and the returned count + /// is exactly the number newly recorded. + #[tokio::test] + async fn record_received_payment_totals_dedups_by_txid() { + use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentEntry}; + + 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 wp = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + + let existing_txid = "aa".repeat(32); + let fresh_txid = "bb".repeat(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, &wp) + .expect("add managed identity"); + + // Pre-record a Sent entry under existing_txid (the both-identities-in- + // one-wallet case that must survive an incoming total for the same tx). + let preexisting = PaymentEntry::new_sent(contact, 123, None); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed identity") + .record_dashpay_payment(existing_txid.clone(), preexisting.clone(), &wp) + .expect("record preexisting"); + + let recorded = super::record_received_payment_totals( + info, + &wp, + vec![ + (owner, contact, existing_txid.clone(), 500_000), + (owner, contact, fresh_txid.clone(), 700_000), + ], + "test", + ); + assert_eq!(recorded, 1, "only the fresh txid should be newly recorded"); + + let managed = info + .identity_manager + .managed_identity(&owner) + .expect("managed identity"); + assert_eq!( + managed.dashpay().payments.get(&existing_txid), + Some(&preexisting), + "the existing txid entry must be left untouched (no clobber)" + ); + let fresh = managed + .dashpay() + .payments + .get(&fresh_txid) + .expect("fresh entry recorded"); + assert_eq!(fresh.direction, PaymentDirection::Received); + assert_eq!(fresh.amount_duffs, 700_000); + } + + /// 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) + .expect("setup persists"); + } + + // 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.dashpay().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" + ); + } + + /// DIP-15 §12.6: when a contact's receival account is registered after SPV + /// has already scanned past the contact's funding height, the rescan + /// reconcile lowers `synced_height` to `min(outgoing, incoming)` funding so + /// the filter manager backfills the missed range — then a per-contact guard + /// makes it single-shot per lifetime, so the recurring sweep does NOT + /// re-lower the height and reset the in-flight backfill (which would keep it + /// from ever completing). + #[tokio::test] + async fn rescan_lowers_synced_height_to_funding_floor_then_is_idempotent() { + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + 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 _); + + iw.dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register receival 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, &p) + .expect("add owner"); + // outgoing funded at 200, incoming at 100 -> floor 100. + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 200, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], 100, 0); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + // Simulate a forward sync to height 1000. + info.core_wallet.update_synced_height(1000); + } + + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan"), + Some(100), + "first pass lowers to min(outgoing, incoming) funding height" + ); + { + let wm = iw.wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .unwrap() + .core_wallet + .synced_height(), + 100, + "synced_height lowered to the floor" + ); + } + + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan 2"), + None, + "already-rescanned contact must not re-trigger (no backfill thrash)" + ); + { + let wm = iw.wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .unwrap() + .core_wallet + .synced_height(), + 100, + "height stays at the floor after the no-op pass" + ); + } + } + + /// Register a receival account for `(owner, contact)` and insert an + /// established contact funded at `out_height`/`in_height`. The owner managed + /// identity is added on first use. + async fn establish_receival_contact( + manager: &Arc>, + persister: &Arc, + wallet_id: WalletId, + owner: Identifier, + contact: Identifier, + out_height: u32, + in_height: u32, + ) { + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(persister) as _); + iw.dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register receival account"); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + if info.identity_manager.managed_identity(&owner).is_none() { + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + } + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], out_height, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], in_height, 0); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + } + + async fn set_synced_height( + manager: &Arc>, + wallet_id: WalletId, + height: u32, + ) { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let mut wm = wallet.identity().wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .core_wallet + .update_synced_height(height); + } + + async fn synced_height( + manager: &Arc>, + wallet_id: WalletId, + ) -> u32 { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let wm = wallet.identity().wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .core_wallet + .synced_height() + } + + /// A contact established while the wallet was still catching up (funded at or + /// after the current tip) is covered by the ongoing forward scan, so the + /// rescan leaves `synced_height` alone — but it must MARK the contact so + /// that, once the forward pointer later climbs past the contact's funding + /// height, the recurring sweep does NOT redundantly rewind to an + /// already-scanned range. + #[tokio::test] + async fn rescan_does_not_redundantly_rewind_a_forward_covered_contact() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + // Funded at 500, but we have only synced to 450 — below the funding + // height, so a forward scan will cover it. + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 500, 500).await; + set_synced_height(&manager, wallet_id, 450).await; + + let iw_wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan"), + None, + "funded above the tip -> no backfill" + ); + assert_eq!( + synced_height(&manager, wallet_id).await, + 450, + "height unchanged" + ); + + // Forward sync climbs past the funding height. The contact was marked, + // so the next sweep must not re-lower to 500. + set_synced_height(&manager, wallet_id, 600).await; + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan 2"), + None, + "forward-covered contact must not be redundantly rewound" + ); + assert_eq!( + synced_height(&manager, wallet_id).await, + 600, + "no redundant rewind" + ); + } + + /// Multiple contacts: the floor is the MINIMUM funding height across all + /// not-yet-rescanned receival contacts (one rewind covers them all), and a + /// later-discovered, older-funded contact re-lowers exactly once before the + /// per-contact guard quiesces (drip-feed must not thrash). + #[tokio::test] + async fn rescan_uses_min_funding_across_contacts_and_drip_feed_settles() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let c_a = Identifier::from([0xA1; 32]); + let c_b = Identifier::from([0xB2; 32]); + let c_c = Identifier::from([0xC3; 32]); + + // Two contacts present at once (funded 300 and 100); tip at 1000. + establish_receival_contact(&manager, &persister, wallet_id, owner, c_a, 300, 300).await; + establish_receival_contact(&manager, &persister, wallet_id, owner, c_b, 100, 100).await; + set_synced_height(&manager, wallet_id, 1000).await; + + let iw_wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan"), + Some(100), + "floor is the minimum funding height across all candidates" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 100); + + // Both are marked -> a second pass is a no-op. + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan 2"), + None, + "all candidates marked -> no re-trigger" + ); + + // A newly discovered, older-funded contact re-lowers exactly once... + establish_receival_contact(&manager, &persister, wallet_id, owner, c_c, 50, 50).await; + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan 3"), + Some(50), + "a new older contact re-lowers to its funding height" + ); + // ...then settles. + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan 4"), + None, + "drip-feed settles -> no further rewind" + ); + } + + /// `synced_height == 0` means "scan from genesis / not started" — already a + /// full historical scan, so the rescan is a no-op (the masking path the spec + /// warns about). + #[tokio::test] + async fn rescan_is_a_noop_when_synced_height_is_zero() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 0).await; + + let iw_wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan"), + None, + "synced_height 0 -> no rescan" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 0); + } + + /// 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 + .dashpay() + .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.dashpay() + .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]); + let registration = iw + .dashpay() + .register_external_contact_account( + &owner_id, + &contact, + &encrypted, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register external with a signer-derived shared key"); + assert_eq!( + registration, + crate::wallet::identity::network::contacts::ExternalAccountRegistration::Built, + "a fresh registration must report Built (AlreadyExisted must not stamp the marker)" + ); + + 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)" + ); + } + + /// Build a wallet with one owner identity (`[0x11;32]`) and one established + /// contact (`[0x22;32]`) whose incoming / outgoing requests carry the given + /// already-encrypted account-label ciphertexts. Returns the manager + the + /// owner/contact ids; the caller fetches the wallet to drive the helper. + async fn wallet_with_labeled_contact( + incoming_label: Option>, + outgoing_label: Option>, + ) -> ( + Arc>, + WalletId, + Identifier, + Identifier, + ) { + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0x11; 32]); + let contact = Identifier::from([0x22; 32]); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + 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, &p) + .expect("add owner"); + let mut outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 200, 0); + outgoing.encrypted_account_label = outgoing_label; + let mut incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], 100, 0); + incoming.encrypted_account_label = incoming_label; + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + drop(wm); + + (manager, wallet_id, owner, contact) + } + + /// Read back the stored `contact_account_label` for the test contact. + async fn stored_label( + manager: &PlatformWalletManager, + wallet_id: &WalletId, + owner: &Identifier, + contact: &Identifier, + ) -> Option { + let wallet = manager.get_wallet(wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let wm = iw.wallet_manager.read().await; + let label = wm + .get_wallet_info(wallet_id) + .and_then(|info| info.identity_manager.managed_identity(owner)) + .and_then(|m| m.dashpay().established_contacts().get(contact)) + .and_then(|c| c.contact_account_label.clone()); + label + } + + /// DIP-15 §8.5 receive-side surfacing: the contact's `encryptedAccountLabel` + /// on their INCOMING request is decrypted with the ECDH shared key and + /// stored as `contact_account_label`. Re-running the helper is idempotent. + /// (Before the helper existed, the field stayed `None` — red→green.) + #[tokio::test] + async fn store_contact_account_label_surfaces_incoming_label() { + let shared = [0x55u8; 32]; + let iv = [0x11u8; 16]; + let ct = platform_encryption::encrypt_account_label(&shared, &iv, "Main wallet"); + let (manager, wallet_id, owner, contact) = + wallet_with_labeled_contact(Some(ct), None).await; + + let drive = || async { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .dashpay() + .store_contact_account_label(&owner, &contact, &shared) + .await; + }; + drive().await; + assert_eq!( + stored_label(&manager, &wallet_id, &owner, &contact).await, + Some("Main wallet".to_string()), + "the contact's incoming account label must be decrypted and surfaced" + ); + // Idempotent: a second pass yields the same value, no panic. + drive().await; + assert_eq!( + stored_label(&manager, &wallet_id, &owner, &contact).await, + Some("Main wallet".to_string()), + ); + } + + /// The label is direction-specific: the surfaced value comes from the + /// contact's INCOMING request, never our OUTGOING one (which carries a + /// label *we* chose). Pins that an outgoing label can't win. + #[tokio::test] + async fn store_contact_account_label_uses_incoming_not_outgoing() { + let shared = [0x55u8; 32]; + let iv = [0x11u8; 16]; + let theirs = platform_encryption::encrypt_account_label(&shared, &iv, "Their account"); + let ours = platform_encryption::encrypt_account_label(&shared, &iv, "My own account"); + let (manager, wallet_id, owner, contact) = + wallet_with_labeled_contact(Some(theirs), Some(ours)).await; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .dashpay() + .store_contact_account_label(&owner, &contact, &shared) + .await; + + assert_eq!( + stored_label(&manager, &wallet_id, &owner, &contact).await, + Some("Their account".to_string()), + "the surfaced label must come from the contact's INCOMING request" + ); + } + + /// No label on the incoming request → nothing surfaced. + #[tokio::test] + async fn store_contact_account_label_no_label_is_none() { + let shared = [0x55u8; 32]; + let (manager, wallet_id, owner, contact) = wallet_with_labeled_contact(None, None).await; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .dashpay() + .store_contact_account_label(&owner, &contact, &shared) + .await; + + assert_eq!( + stored_label(&manager, &wallet_id, &owner, &contact).await, + None, + ); + } + + /// Cosmetic-failure policy: an undecryptable ciphertext leaves the label + /// unset — never breaks the channel, never surfaces garbage. + #[tokio::test] + async fn store_contact_account_label_undecryptable_is_none() { + let shared = [0x55u8; 32]; + // 50 bytes = 16-byte IV + 34-byte body (not block-aligned) → decrypt errors. + let (manager, wallet_id, owner, contact) = + wallet_with_labeled_contact(Some(vec![0u8; 50]), None).await; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .dashpay() + .store_contact_account_label(&owner, &contact, &shared) + .await; + + assert_eq!( + stored_label(&manager, &wallet_id, &owner, &contact).await, + None, + "an undecryptable label must be left unset, not surfaced as garbage" + ); + } + + /// AES-CBC has no integrity, so a corrupt / non-conforming-sender ciphertext + /// can *decrypt* to valid UTF-8 that contains control characters. Unlike the + /// undecryptable case above (the `Err` arm), this exercises the `Ok`-with- + /// garbage **sanitize** branch: a printable-looking-but-control-laden label + /// must be coerced to `None` so the UI shows nothing rather than garbage. + #[tokio::test] + async fn store_contact_account_label_control_chars_coerced_to_none() { + let shared = [0x55u8; 32]; + let iv = [0x11u8; 16]; + // Decrypts cleanly to a string carrying a control character (bell). + let ct = platform_encryption::encrypt_account_label(&shared, &iv, "bad\u{07}label"); + let (manager, wallet_id, owner, contact) = + wallet_with_labeled_contact(Some(ct), None).await; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .dashpay() + .store_contact_account_label(&owner, &contact, &shared) + .await; + + assert_eq!( + stored_label(&manager, &wallet_id, &owner, &contact).await, + None, + "a label decrypting to control characters must be suppressed, not surfaced" + ); + } + + /// 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.dashpay() + .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("") + }; + + // Register the owner, then enqueue a RegisterReceiving op (as the + // seedless sweep would) onto its per-identity queue. + { + 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"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("owner resident") + .dashpay_pending_contact_crypto_mut() + .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.dashpay().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.identity_manager + .managed_identity(&owner) + .expect("owner resident") + .dashpay() + .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" + ); + } + + /// `pending_contact_crypto_count` (the "waiting to finish setup" banner + /// source) MUST sum across BOTH identity buckets. An `AutoAccept` op can + /// legitimately sit on an out-of-wallet identity, so a count walking only the + /// wallet-owned bucket would silently under-count it — the R1 regression the + /// per-identity move risks. Calls the real async method (not an inlined copy), + /// so a bucket-dropping regression fails HERE. + #[tokio::test] + async fn pending_contact_crypto_count_method_spans_both_identity_buckets() { + use crate::changeset::{PendingContactCrypto, PendingContactCryptoOp}; + + 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 owned = Identifier::from([0x41; 32]); + let watched = Identifier::from([0x42; 32]); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + // Owned identity with a RegisterReceiving op. + info.identity_manager + .add_identity( + bare_identity([0x41; 32]), + 0, + wallet_id, + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add owned"); + info.identity_manager + .managed_identity_mut(&owned) + .expect("owned resident") + .dashpay_pending_contact_crypto_mut() + .push(PendingContactCrypto { + owner_identity_id: owned, + contact_id: Identifier::from([0x01; 32]), + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: 0, + }); + // OUT-OF-WALLET identity with an AutoAccept op — the case an + // owned-bucket-only count would silently miss. + info.identity_manager + .add_out_of_wallet_identity( + bare_identity([0x42; 32]), + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add out-of-wallet"); + info.identity_manager + .managed_identity_mut(&watched) + .expect("watched resident") + .dashpay_pending_contact_crypto_mut() + .push(PendingContactCrypto { + owner_identity_id: watched, + contact_id: Identifier::from([0x02; 32]), + op: PendingContactCryptoOp::AutoAccept, + enqueued_at_ms: 0, + }); + } + + assert_eq!( + iw.dashpay().pending_contact_crypto_count().await, + 2, + "count must aggregate across both identity buckets, including the \ + out-of-wallet AutoAccept" + ); + } + + /// The drain snapshot MUST reach BOTH identity buckets. A `RegisterReceiving` + /// op on an OUT-OF-WALLET identity (the provider-only drain completes it with + /// no signer; its arm doesn't gate on the HD index) must be processed and + /// cleared — if the drain snapshotted only the wallet-owned bucket, this entry + /// would be silently skipped (`drained == 0`). Pins the drain half of R1. + #[tokio::test] + async fn drain_processes_out_of_wallet_identity_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 watched = Identifier::from([0x42; 32]); + let contact = Identifier::from([0x22; 32]); + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid mnemonic") + .to_seed(""); + + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_out_of_wallet_identity( + bare_identity([0x42; 32]), + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add out-of-wallet"); + info.identity_manager + .managed_identity_mut(&watched) + .expect("watched resident") + .dashpay_pending_contact_crypto_mut() + .push(PendingContactCrypto { + owner_identity_id: watched, + contact_id: contact, + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: 0, + }); + } + + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let drained = iw.dashpay().drain_pending_contact_crypto(&provider).await; + assert_eq!( + drained, 1, + "the drain must snapshot the out-of-wallet bucket and process its entry" + ); + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert!( + info.identity_manager + .managed_identity(&watched) + .expect("watched resident") + .dashpay() + .pending_contact_crypto + .is_empty(), + "the out-of-wallet identity's queue must be cleared after the drain" + ); + } + + /// 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 resident but OUT-OF-WALLET (no HD index), so the + // RegisterExternal drain bails before reaching the fetch, leaving the + // entry queued. + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_out_of_wallet_identity( + bare_identity([0x11; 32]), + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add out-of-wallet owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("owner resident") + .dashpay_pending_contact_crypto_mut() + .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, crate::error::PlatformWalletError> + { + Ok(zeroize::Zeroizing::new([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 + .dashpay() + .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.identity_manager + .managed_identity(&owner) + .expect("owner resident") + .dashpay() + .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 + .dashpay() + .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.dashpay().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.identity_manager + .managed_identity(&owner) + .expect("owner resident") + .dashpay() + .pending_contact_crypto + .iter() + .any(|e| e.op.kind() == PendingContactCryptoKind::ContactInfoDecrypt), + "the seedless sweep must enqueue a ContactInfoDecrypt op for the owner" + ); + } + + // ----------------------------------------------------------------------- + // send_payment drains the deferred contact-crypto queue first + // ----------------------------------------------------------------------- + // + // The original sender's external-account build is enqueued by the + // signerless sweep and completed by `drain_pending_contact_crypto`. The + // drain runs at the start of `send_payment` (with the signer-backed + // provider) so the external account is built before the send resolves it — + // otherwise the first `send_payment` after establishing a contact fails the + // external-account lookup even though the build is queued and ready. + // + // Coverage is split across two tests because the mock SDK's + // `expect_fetch::` is single-shot and the `RegisterExternal` + // drain branch fetches the contact identity through it — a second fetch in + // a run returns a stale identity whose key yields a different ECDH secret, + // so the in-process drain of a `RegisterExternal` op cannot be made to + // build the external account deterministically under the mock. + // * `send_payment_runs_pending_contact_crypto_drain` pins the mechanism: + // `send_payment` runs the drain before the external lookup (verified + // with a `RegisterReceiving` op, which the faithful `SeedCryptoProvider` + // completes with no fetch). + // * `send_payment_passes_external_lookup_once_account_built` pins the + // complementary half: once the external account exists, the lookup + // passes and the send proceeds past it. + // The full drain-then-send of a queued `RegisterExternal` is exercised + // end-to-end by the live DashPay e2e flow. + + /// `send_payment` drains the deferred contact-crypto queue before it + /// resolves the external account. A `RegisterExternal` op can't be built + /// from under the single-shot mock fetch (see the module comment above), so + /// the drain's invocation is pinned with a `RegisterReceiving` op the + /// `SeedCryptoProvider` completes with no fetch: after a (still-failing) + /// `send_payment`, that queued op is drained. Without the send-path drain + /// the op stays queued. + #[tokio::test] + async fn send_payment_runs_pending_contact_crypto_drain() { + use crate::changeset::{PendingContactCrypto, PendingContactCryptoOp}; + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + use key_wallet::account::account_collection::DashpayAccountKey; + + 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]); + // The contact whose RegisterReceiving op the drain should complete. + let queued_contact = Identifier::from([0x33; 32]); + // The (different) contact we try to pay — it has no external account, + // so the send fails AFTER the drain has run. + let pay_contact = Identifier::from([0x22; 32]); + + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid mnemonic") + .to_seed(""); + + { + 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 RegisterReceiving op the provider-only drain can complete. + info.identity_manager + .managed_identity_mut(&owner) + .expect("owner resident") + .dashpay_pending_contact_crypto_mut() + .push(PendingContactCrypto { + owner_identity_id: owner, + contact_id: queued_contact, + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: 0, + }); + } + + // A signer + provider derived from the same test seed — the production + // posture (Keychain signer present on the send path). + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let signer = SeedSigner::new(seed, Network::Testnet); + + // The send fails (no external account for `pay_contact`), but the drain + // it runs first must have completed the queued RegisterReceiving op. + let result = iw + .dashpay() + .send_payment(&owner, &pay_contact, 10_000, None, &signer, &provider) + .await; + assert!( + result.is_err(), + "the send must still fail for an unbuilt external account" + ); + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let receiving_key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: queued_contact.to_buffer(), + }; + assert!( + info.core_wallet + .accounts + .dashpay_receival_accounts + .contains_key(&receiving_key), + "send_payment must drain the pending contact-crypto queue before \ + failing — the queued RegisterReceiving op stays unbuilt without the drain" + ); + assert!( + !info + .identity_manager + .managed_identity(&owner) + .expect("owner resident") + .dashpay() + .pending_contact_crypto + .iter() + .any(|e| e.contact_id == queued_contact), + "the drained RegisterReceiving entry must be cleared from the queue" + ); + } + + /// Once the external account IS built, `send_payment` gets PAST the + /// external-account lookup: it no longer returns the + /// "No DashpayExternalAccount found" error and instead fails later, at + /// funding/transaction build (the seedless test wallet has no UTXOs). Pins + /// the precondition the drain clears. + #[tokio::test] + async fn send_payment_passes_external_lookup_once_account_built() { + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + + 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"); + } + + // Build the external account via the faithful precomputed-shared-key + // path (what the drain ultimately calls) so the send's lookup finds it. + 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); + let contact = bare_identity([0x22; 32]); + iw.dashpay() + .register_external_contact_account( + &owner_id, + &contact, + &encrypted, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register external account"); + + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid mnemonic") + .to_seed(""); + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let signer = SeedSigner::new(seed, Network::Testnet); + + let err = iw + .dashpay() + .send_payment(&owner_id, &contact_id, 10_000, None, &signer, &provider) + .await + .expect_err("seedless test wallet has no UTXOs, so the build must fail"); + + // The point: the external-account lookup PASSED. The error is a later + // funding/build failure, NOT the missing-account precondition. A + // `TransactionBuild` / funding error is the expected post-lookup + // failure for a wallet with no spendable UTXOs; only an + // `InvalidIdentityData` carrying the missing-account message would mean + // the lookup still failed. + if let PlatformWalletError::InvalidIdentityData(msg) = &err { + assert!( + !msg.contains("No DashpayExternalAccount found"), + "send_payment must get PAST the external-account lookup once \ + the account is built, got: {msg}" + ); + } + } +} 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..047d4581731 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,56 +9,28 @@ 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 // --------------------------------------------------------------------------- -impl IdentityWallet { +impl DashPayView<'_, B> { /// Fetch DashPay profile documents from Platform for all managed /// identities and cache them on [`ManagedIdentity`]. /// - /// Returns the number of profiles that were successfully synced. + /// Fetches in `In`-chunks (one query per ≤`CONTACT_PROFILE_IN_CAP` ids, not + /// N+1) with per-chunk failure isolation, and persists only when the + /// fetched profile differs from the cached one — a `None` result clears the + /// cache only when a profile is currently stored (both arms guarded so a + /// no-change sweep writes nothing). Returns the number of identities whose + /// cached profile changed. pub async fn sync_profiles(&self) -> Result { // 1. Collect all managed identity IDs under a short read lock. let identity_ids: Vec = { @@ -79,143 +49,53 @@ 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; - - // 3. For each identity fetch the profile document, then cache it. - for identity_id in &identity_ids { + // 3. Fetch (no guard held) in `In`-chunks. A chunk failure logs and + // continues so the other chunks still land; an id present in the + // chunk but absent from the result is confirmed-absent (`None`). + let mut fetched: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for chunk in identity_ids.chunks(CONTACT_PROFILE_IN_CAP) { match self - .fetch_profile_document(&dashpay_contract, identity_id) + .fetch_contact_profiles_chunk(&dashpay_contract, chunk) .await { - Ok(Some(profile)) => { - let mut wm = self.wallet_manager.write().await; - if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { - if let Some(managed) = - info.identity_manager.managed_identity_mut(identity_id) - { - managed.set_dashpay_profile(Some(profile), &self.persister); - profiles_synced += 1; - } - } - } - Ok(None) => { - // No profile on Platform — clear local cache only when one - // is currently stored, to avoid needless writes. - let mut wm = self.wallet_manager.write().await; - if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { - if let Some(managed) = - info.identity_manager.managed_identity_mut(identity_id) - { - if managed.dashpay_profile.is_some() { - managed.set_dashpay_profile(None, &self.persister); - } - } + Ok(found) => { + for id in chunk { + fetched.insert(*id, found.get(id).cloned().flatten()); } } Err(e) => { tracing::warn!( - identity = %identity_id, error = %e, - "Failed to fetch DashPay profile" + "Failed to fetch an own-profile chunk; will retry next sweep" ); } } } - Ok(profiles_synced) - } - - /// Fetch a single `profile` document from the DashPay contract for - /// `identity_id` and convert it into a [`DashPayProfile`]. - /// - /// Returns `Ok(None)` when no profile document exists on Platform. - async fn fetch_profile_document( - &self, - dashpay_contract: &Arc, - identity_id: &Identifier, - ) -> Result, PlatformWalletError> { - use dash_sdk::drive::query::WhereClause; - use dash_sdk::drive::query::WhereOperator; - use dash_sdk::platform::FetchMany; - use dpp::document::Document; - use dpp::platform_value::platform_value; - - // Build query: profile documents WHERE $ownerId = identity_id. - let query = 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::Equal, - value: platform_value!(identity_id), - }], - group_by: vec![], - having: vec![], - order_by_clauses: vec![], - limit: 1, - start: None, - }; - - let docs = Document::fetch_many(&self.sdk, query) - .await - .map_err(PlatformWalletError::Sdk)?; - - // Take the first result (profile is unique per $ownerId). - let doc = match docs.into_values().next() { - Some(Some(d)) => d, - _ => return Ok(None), + // 4. Under the write guard: persist-on-change only (mirror the + // None-arm's is_some guard for both directions). + let mut changed = 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 (identity_id, profile) in fetched { + let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) else { + continue; + }; + if managed.dashpay().profile == profile { + continue; + } + managed.set_dashpay_profile(profile, &self.persister); + changed += 1; + } - 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(changed) } } @@ -223,7 +103,7 @@ impl IdentityWallet { // Profile create / update — external-signer variants // --------------------------------------------------------------------------- -impl IdentityWallet { +impl DashPayView<'_, B> { /// Create a DashPay profile document using an externally-supplied /// signer. /// @@ -246,23 +126,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 +222,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,47 +267,20 @@ 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) = { - use dash_sdk::drive::query::WhereClause; - use dash_sdk::drive::query::WhereOperator; + // 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::platform::FetchMany; - use dpp::platform_value::platform_value; - - let query = 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::Equal, - value: platform_value!(identity_id), - }], - group_by: vec![], - having: vec![], - order_by_clauses: vec![], - limit: 1, - start: None, - }; + + let query = single_profile_query(&dashpay_contract, identity_id); let docs = Document::fetch_many(&self.sdk, query) .await @@ -451,7 +290,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 +300,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 +383,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 +407,608 @@ 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; + +/// 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 DashPayView<'_, B> { + /// 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 = crate::util::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 + .dashpay() + .established_contacts() + .keys() + .copied() + .collect(); + targets.extend( + managed + .dashpay() + .incoming_contact_requests() + .keys() + .copied(), + ); + let to_fetch: Vec = targets + .into_iter() + .filter(|id| !own.contains(id)) + .filter(|id| { + should_fetch_profile(managed.dashpay().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; + }; + for (contact_id, profile) in owner_results { + if apply_fetched_profile( + managed.dashpay_contact_profiles_mut(), + contact_id, + profile, + now_ms, + ) { + written += 1; + } + } + // Persist one changeset per owner. Every owner reaching here had + // ≥1 profile (re)fetched this sweep, so `checked_at_ms` advanced + // for at least one contact — persist unconditionally, not only on + // content change, so the refresh-cache timestamps are durable. A + // cold start otherwise reverts each timestamp to the last + // content-changing sweep and re-fetches every still-fresh profile. + // No meaningful write amplification: the store is paired with the + // network fetch that just ran, and fetches are gated to once per + // `CONTACT_PROFILE_REFRESH_MS` per contact. A failed store + // self-heals on the next sweep. + 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 single-owner `profile` fetch query WHERE `$ownerId = identity_id` +/// (`limit 1` — profile is unique per owner). Used by the external-signer +/// update's read-modify-write seed to fetch the current document's +/// id/revision/properties. +fn single_profile_query( + dashpay_contract: &Arc, + identity_id: &Identifier, +) -> dash_sdk::platform::DocumentQuery { + use dash_sdk::drive::query::{WhereClause, WhereOperator}; + use dpp::platform_value::platform_value; + + 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::Equal, + value: platform_value!(identity_id), + }], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: 1, + start: None, + } +} + +/// 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/registration.rs b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs index e0fac3a34f6..da8bc401342 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -301,11 +301,17 @@ impl IdentityWallet { managed.wallet_id = Some(wallet_id); for (key_id, pub_key) in public_keys { let key_index = key_id; - managed.add_key( - pub_key, - Some((wallet_id, identity_index, key_index)), - &self.persister, - ); + managed + .add_key( + pub_key, + Some((wallet_id, identity_index, key_index)), + &self.persister, + ) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after registration: {e}" + )) + })?; } } } 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..eefad5dcd29 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs @@ -0,0 +1,282 @@ +//! Concrete helper over the SDK's DashPay write/broadcast surface. +//! +//! The two operations `IdentityWallet` performs over the SDK — +//! [`Sdk::send_contact_request`](dash_sdk::Sdk::send_contact_request) +//! and a document put — cannot be expressed as plain method calls at +//! the call sites: `send_contact_request` is generic over **seven** +//! type parameters (the signer plus three ECDH/xpub closure pairs) and +//! the document put rides on the signer-generic +//! [`PutDocument`](dash_sdk::platform::transition::put_document::PutDocument) +//! trait. [`SdkWriter`] erases those generics behind two concrete +//! methods that take by-value, already-derived inputs plus a borrowed +//! `&dyn Signer`. +//! +//! `IdentityWallet` keeps doing all of the derivation (key-index +//! resolution, ECDH-key derivation, xpub derivation, avatar hashing, +//! document construction); this helper receives the already-derived +//! primitives and performs the final SDK call. + +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 helper methods below can take a borrowed signer +// 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 helper 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 helper only assembles the SDK `EcdhProvider` + xpub +/// closure and dispatches. +/// +/// The ECDH is performed by the caller (client-side), so the helper 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) lets the caller source it from +/// either the resident seed or the Keychain signer without the helper +/// 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 into + /// the helper. Held in [`zeroize::Zeroizing`] so it is scrubbed on + /// drop — it is only dereferenced when handed to the SDK's `ClientSide` closure. + pub shared_secret: zeroize::Zeroizing<[u8; 32]>, + /// The recipient encryption public key the `shared_secret` was + /// derived against. The helper'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 helper 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), +} + +/// Concrete DashPay write helper backed by a live [`Sdk`](dash_sdk::Sdk). +/// +/// Held as a field on [`IdentityWallet`](super::IdentityWallet), +/// forwarding to the real SDK. Its two methods erase the SDK's +/// generic write signatures (`send_contact_request`'s seven type +/// params, the signer-generic `PutDocument`) behind concrete, by-value +/// inputs so the call sites in `contact_requests.rs` / `profile.rs` / +/// `contact_info.rs` stay simple. +/// +/// The methods' returned futures are `Send`: the write paths this +/// helper 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 +/// helper.) +#[derive(Clone)] +pub(crate) struct SdkWriter { + sdk: Arc, +} + +impl std::fmt::Debug for SdkWriter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SdkWriter").finish() + } +} + +impl SdkWriter { + /// Wrap an SDK handle. + pub(crate) fn new(sdk: Arc) -> Self { + Self { sdk } + } + + /// Build the ECDH provider + xpub closure from the pre-derived + /// inputs and broadcast the contact-request document. + pub(crate) 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. + // Aliased so the annotation stays under the type-complexity lint. + type UnusedSdkSideEcdh = fn( + &IdentityPublicKey, + u32, + ) -> std::future::Ready< + Result, + >; + let ecdh_provider: EcdhProvider = 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(PlatformWalletError::Sdk) + } + + /// Broadcast a pre-built DashPay document and wait for the + /// confirmation proof. + pub(crate) 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..c3345e261d5 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs @@ -0,0 +1,216 @@ +//! 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/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 1cee6ab9fac..6ad873bb1aa 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -229,7 +229,13 @@ impl IdentityWallet { for key in added_keys_for_local_apply { let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); - managed.add_key(key, breadcrumb, &self.persister); + managed + .add_key(key, breadcrumb, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + })?; } if !disabled_ids_for_local_apply.is_empty() { 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..73fa4d24d90 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,32 +9,159 @@ 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.dashpay + .sent_contact_requests + .get(recipient) + .map(|r| r.account_reference) + .or_else(|| { + self.dashpay + .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 — established or pending-sent — + /// with the SAME outgoing `accountReference` (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. + /// + /// **Sent-side rotation supersede.** When we re-send to an + /// already-established contact with a *different* outgoing + /// `accountReference` (a re-key: we rotated our own receiving xpub and + /// broadcast a superseding request), the established contact's + /// `outgoing_request` is advanced in place — the mirror of + /// [`Self::apply_rotated_incoming_request`] on the receive side. Without + /// this the tracked outgoing reference stays frozen at the first send, + /// so the next rotation re-derives (un-mask → bump) from the stale + /// version and collides with the already-broadcast reference on the + /// contract's `($ownerId, toUserId, accountReference)` unique index, + /// permanently breaking rotation after one use. User metadata is + /// preserved (only `outgoing_request` moves). pub fn add_sent_contact_request( &mut self, request: ContactRequest, persister: &WalletPersister, - ) { + ) -> Result<(), crate::changeset::PersistenceError> { let owner_id = self.id(); let recipient_id = request.recipient_id; + + // Sent-side guard / rotation supersede: already established. + if let Some(existing) = self.dashpay.established_contacts.get(&recipient_id) { + // Same outgoing reference → a re-ingest of a doc we already + // track carries no new information; re-establishing would wipe + // user metadata, so it's a no-op. + if existing.outgoing_request.account_reference == request.account_reference { + return Ok(()); + } + // Different outgoing reference → a rotation re-send. Advance + // `outgoing_request` so the next rotation reads the fresh + // version, preserving all user metadata. Persist BEFORE + // committing to memory (same order as `set_contact_metadata`): + // if the store fails, memory must stay on the old reference so + // the retry sweep doesn't hit the same-reference no-op guard + // above and silently lose the rotation from disk for the + // process lifetime. + let mut updated = existing.clone(); + updated.outgoing_request = request; + let mut cs = ContactChangeSet::default(); + cs.established.insert( + SentContactRequestKey { + owner_id, + recipient_id, + }, + updated.clone(), + ); + persister.store(cs.into())?; + self.dashpay + .established_contacts + .insert(recipient_id, updated); + return Ok(()); + } + // Already tracked as a pending sent request. Same outgoing + // reference → no-op (no phantom row, no redundant changeset + // write). Different reference → the pending mirror of the + // established-branch rotation supersede above: we rotated and + // broadcast a superseding request to a recipient who hasn't + // reciprocated yet. Without the supersede the pending map stays + // frozen at the first send's reference, so the NEXT rotation + // re-derives (un-mask → bump) from the stale version and + // reproduces an already-broadcast reference — rejected forever + // by the contract's `($ownerId, toUserId, accountReference)` + // unique index. Persist BEFORE committing to memory, same as + // the established branch and for the same retry reason. + if let Some(existing) = self.dashpay.sent_contact_requests.get(&recipient_id) { + if existing.account_reference == request.account_reference { + return Ok(()); + } + let mut cs = ContactChangeSet::default(); + cs.sent_requests.insert( + SentContactRequestKey { + owner_id, + recipient_id, + }, + ContactRequestEntry { + request: request.clone(), + }, + ); + persister.store(cs.into())?; + self.dashpay + .sent_contact_requests + .insert(recipient_id, request); + return Ok(()); + } + let mut cs = ContactChangeSet::default(); // Check if there's already an incoming request from this recipient - if let Some(incoming_request) = self.incoming_contact_requests.remove(&recipient_id) { + if let Some(incoming_request) = self.dashpay.incoming_contact_requests.remove(&recipient_id) + { // 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.dashpay.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, @@ -42,7 +169,9 @@ impl ManagedIdentity { }, contact.clone(), ); - self.established_contacts.insert(recipient_id, contact); + self.dashpay + .established_contacts + .insert(recipient_id, contact); } else { // No matching incoming request, just add as sent cs.sent_requests.insert( @@ -54,11 +183,178 @@ impl ManagedIdentity { request: request.clone(), }, ); - self.sent_contact_requests.insert(recipient_id, request); + self.dashpay + .sent_contact_requests + .insert(recipient_id, request); + } + persister.store(cs.into())?; + Ok(()) + } + + /// 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(); + let removed = self + .dashpay + .incoming_contact_requests + .remove(sender_id) + .is_some(); + self.dashpay.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.) + // + // Guarded on an ACTUAL removal — the same `removed.is_some()` + // discipline as `remove_incoming_contact_request` / + // `remove_sent_contact_request`. Ignoring a sender who has no + // pending incoming entry (e.g. an already-established contact, or + // one that raced auto-establish) must not emit a tombstone: the + // contacts table is one row per pair, so an unconditional + // tombstone would DELETE the established row — outgoing/incoming + // blobs plus the user's alias/note/hidden/accepted-accounts — + // while memory keeps the contact established. + if removed { + 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.dashpay.ignored_senders.contains(sender_id) + } + + /// Marker key for the auto-accept verify-failed set: + /// `SHA256(sender_id ‖ proof_bytes)`. Keying on the proof bytes (not the + /// sender alone) means a DIFFERENT proof from the same sender is not + /// suppressed by a prior bad one. + pub fn auto_accept_verify_failed_key(sender_id: &Identifier, proof: &[u8]) -> [u8; 32] { + use dashcore::hashes::{sha256, Hash, HashEngine}; + let mut engine = sha256::Hash::engine(); + engine.input(&sender_id.to_buffer()); + engine.input(proof); + sha256::Hash::from_engine(engine).to_byte_array() + } + + /// Hard cap on [`Self::auto_accept_verify_failed`] (32 KiB of keys at + /// most). Every entry is attacker-funded (one distinct malformed + /// `contactRequest` document per key, each costing platform credits to + /// publish), so the set stays tiny in any legitimate wallet; the cap only + /// bounds memory against a griefer willing to keep paying. Evicting an + /// arbitrary entry over cap is safe — an evicted proof merely re-verifies + /// (and re-fails) on the next sweep, which the per-launch-retry design + /// already treats as harmless. + pub const AUTO_ACCEPT_VERIFY_FAILED_CAP: usize = 1024; + + /// Record that `proof` (from `sender_id`) failed cryptographic verification + /// permanently, so the sync sweep's enqueue gate does not re-queue it. Only + /// PERMANENT failures should be marked; transient ones must stay retryable. + /// In-memory only — cleared on relaunch (retry once per launch). Bounded by + /// [`Self::AUTO_ACCEPT_VERIFY_FAILED_CAP`]: over cap, an arbitrary existing + /// entry is evicted to make room (see the cap docs for why that is safe). + pub fn mark_auto_accept_verify_failed(&mut self, sender_id: &Identifier, proof: &[u8]) { + while self.dashpay.auto_accept_verify_failed.len() >= Self::AUTO_ACCEPT_VERIFY_FAILED_CAP { + self.dashpay.auto_accept_verify_failed.pop_first(); + } + self.dashpay + .auto_accept_verify_failed + .insert(Self::auto_accept_verify_failed_key(sender_id, proof)); + } + + /// Whether `proof` (from `sender_id`) has already failed verification this + /// launch — the enqueue gate consults this before re-queuing an + /// `AutoAccept` op. + pub fn is_auto_accept_verify_failed(&self, sender_id: &Identifier, proof: &[u8]) -> bool { + self.dashpay + .auto_accept_verify_failed + .contains(&Self::auto_accept_verify_failed_key(sender_id, proof)) + } + + /// Whether an inbound `request` from `sender_id` should be queued for the + /// next signer-present auto-accept drain. The signerless sweep can only do + /// cheap local checks here — the cryptographic verify runs in the drain: + /// + /// 1. Not already an established contact. + /// 2. Carries a structurally-valid `autoAcceptProof` (DIP-15 size band + + /// the ECDSA key-type lead byte). The lower bound is the exact ECDSA + /// proof length — `key_type(1) + timestamp(4) + sig_size(1) + + /// signature(64) = 70` — so a shorter `0x00`-led byte run can't burn a + /// drain round-trip before being discarded as malformed. + /// 3. Not a proof a prior drain already rejected cryptographically this + /// launch (see [`Self::is_auto_accept_verify_failed`]). Keyed by + /// `(sender, proof)`, so a DIFFERENT proof from the same sender still + /// enqueues — only the exact bad blob is suppressed. Without this an + /// attacker-published garbage proof would re-enqueue every sweep, + /// keeping the "waiting to finish setup" banner permanently tripped. + pub fn should_enqueue_auto_accept( + &self, + sender_id: &Identifier, + request: &ContactRequest, + ) -> bool { + if self.dashpay.established_contacts.contains_key(sender_id) { + return false; + } + let Some(proof) = request.auto_accept_proof.as_deref() else { + return false; + }; + if !((70..=102).contains(&proof.len()) && proof[0] == 0x00) { + return false; } - if let Err(e) = persister.store(cs.into()) { - tracing::error!("Failed to persist changeset: {}", e); + !self.is_auto_accept_verify_failed(sender_id, proof) + } + + /// 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.dashpay.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.dashpay.high_water_received_ms = None; + + let mut cs = ContactChangeSet::default(); + cs.unignored.insert((owner_id, *sender_id)); + cs } /// Remove a sent contact request. @@ -68,7 +364,7 @@ impl ManagedIdentity { &mut self, recipient_id: &Identifier, ) -> (Option, ContactChangeSet) { - let removed = self.sent_contact_requests.remove(recipient_id); + let removed = self.dashpay.sent_contact_requests.remove(recipient_id); let mut cs = ContactChangeSet::default(); if removed.is_some() { cs.removed_sent.insert(SentContactRequestKey { @@ -88,18 +384,28 @@ impl ManagedIdentity { &mut self, request: ContactRequest, persister: &WalletPersister, - ) { + ) -> Result<(), crate::changeset::PersistenceError> { let owner_id = self.id(); let sender_id = request.sender_id; let mut cs = ContactChangeSet::default(); // Check if there's already a sent request to this sender - if let Some(outgoing_request) = self.sent_contact_requests.remove(&sender_id) { + if let Some(outgoing_request) = self.dashpay.sent_contact_requests.remove(&sender_id) { // 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.dashpay.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, @@ -107,7 +413,7 @@ impl ManagedIdentity { }, contact.clone(), ); - self.established_contacts.insert(sender_id, contact); + self.dashpay.established_contacts.insert(sender_id, contact); } else { // No matching sent request, just add as incoming cs.incoming_requests.insert( @@ -119,11 +425,173 @@ impl ManagedIdentity { request: request.clone(), }, ); - self.incoming_contact_requests.insert(sender_id, request); + self.dashpay + .incoming_contact_requests + .insert(sender_id, request); } - if let Err(e) = persister.store(cs.into()) { - tracing::error!("Failed to persist changeset: {}", e); + persister.store(cs.into())?; + Ok(()) + } + + /// 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. + /// `Ok(false)` = no-op (contact isn't established); `Ok(true)` = applied + /// (or unchanged, skipping the persist). `Err` = the persist failed; the + /// in-memory contact is left UNCHANGED (we persist before committing to + /// memory), so a retry is not defeated by the unchanged-equality + /// short-circuit above. The caller MUST surface it so the drain leaves the + /// `ContactInfoDecrypt` queue entry in place for that retry. + pub fn set_contact_metadata( + &mut self, + contact_id: &Identifier, + metadata: ContactInfoPrivateData, + persister: &WalletPersister, + ) -> Result { + let owner_id = self.id(); + let Some(contact) = self.dashpay.established_contacts.get_mut(contact_id) else { + return Ok(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 Ok(true); } + // Persist BEFORE committing to memory: build the changeset from a copy + // carrying the new metadata, store it, and only apply to the live + // contact once the store succeeds. On a store failure memory stays + // equal to the persisted state, so the unchanged-equality short-circuit + // above does NOT swallow the next retry — the drain's ContactInfoDecrypt + // entry re-runs and re-persists cleanly. + let mut updated = contact.clone(); + updated.alias = metadata.alias_name; + updated.note = metadata.note; + updated.is_hidden = metadata.display_hidden; + + let mut cs = ContactChangeSet::default(); + cs.established.insert( + SentContactRequestKey { + owner_id, + recipient_id: *contact_id, + }, + updated.clone(), + ); + persister.store(cs.into())?; + *contact = updated; + Ok(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, + ) -> Result { + 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 + .dashpay + .established_contacts + .get(&sender_id) + .map(|c| c.incoming_request == request) + .or_else(|| { + self.dashpay + .incoming_contact_requests + .get(&sender_id) + .map(|r| *r == request) + }) + .unwrap_or(false); + if already_applied { + return Ok(false); + } + + let mut cs = ContactChangeSet::default(); + + let rekeyed_established = + if let Some(contact) = self.dashpay.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; + // The label belongs to the incoming request being replaced — + // drop it so the rebuilt external account re-derives it from + // the new request rather than showing the old label against + // fresh key material. + contact.contact_account_label = None; + // The stale external account (built from the old reference) + // is torn down by the caller — reset the marker so the build + // sweep re-registers from the new xpub and re-stamps it. + contact.external_account_reference = None; + cs.established.insert( + SentContactRequestKey { + owner_id, + recipient_id: sender_id, + }, + contact.clone(), + ); + true + } else if let Some(slot) = self.dashpay.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 Ok(false); + }; + + persister.store(cs.into())?; + Ok(rekeyed_established) } /// Remove an incoming contact request. @@ -133,7 +601,7 @@ impl ManagedIdentity { &mut self, sender_id: &Identifier, ) -> (Option, ContactChangeSet) { - let removed = self.incoming_contact_requests.remove(sender_id); + let removed = self.dashpay.incoming_contact_requests.remove(sender_id); let mut cs = ContactChangeSet::default(); if removed.is_some() { cs.removed_incoming.insert(ReceivedContactRequestKey { @@ -155,17 +623,22 @@ impl ManagedIdentity { sender_id: &Identifier, ) -> (Option, ContactChangeSet) { // Check both exist before removing either (prevents data loss). - if !self.incoming_contact_requests.contains_key(sender_id) - || !self.sent_contact_requests.contains_key(sender_id) + if !self + .dashpay + .incoming_contact_requests + .contains_key(sender_id) + || !self.dashpay.sent_contact_requests.contains_key(sender_id) { return (None, ContactChangeSet::default()); } // Both `remove` calls are guaranteed `Some` by the pre-check above. let incoming_request = self + .dashpay .incoming_contact_requests .remove(sender_id) .expect("incoming request presence checked above"); let outgoing_request = self + .dashpay .sent_contact_requests .remove(sender_id) .expect("sent request presence checked above"); @@ -174,7 +647,8 @@ impl ManagedIdentity { let contact = EstablishedContact::new(*sender_id, outgoing_request, incoming_request); // Add to established contacts - self.established_contacts + self.dashpay + .established_contacts .insert(*sender_id, contact.clone()); // Per the ContactChangeSet auto-establishment contract, `established` @@ -194,7 +668,74 @@ impl ManagedIdentity { } } -// --- Apply (restore from changeset) --- +// --- High-water sync cursors (compare-and-advance) --- + +/// 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). +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 + } +} + +impl ManagedIdentity { + /// Compare-and-advance the received-direction sync cursor to + /// `max_fetched` (never below its current value), ONLY if the cursor + /// still holds `snapshot` — the value read at sweep start. A mid-sweep + /// [`Self::unignore_sender`] rewind (reset to `None`) must not be + /// clobbered by a stale sweep max, or the un-ignored sender stays + /// invisible until a cold restart. + /// + /// Caller contract (unchanged from when this lived at the sweep call + /// site): invoke only when the paginate exhausted without error AND + /// every ingest reached disk — fetch/persist-success gating stays at + /// the call site. + pub fn advance_high_water_received(&mut self, snapshot: Option, max_fetched: Option) { + self.dashpay.high_water_received_ms = + advance_if_unchanged(self.dashpay.high_water_received_ms, snapshot, max_fetched); + } + + /// Sent-direction counterpart of [`Self::advance_high_water_received`]; + /// same compare-and-advance semantics and caller contract. + pub fn advance_high_water_sent(&mut self, snapshot: Option, max_fetched: Option) { + self.dashpay.high_water_sent_ms = + advance_if_unchanged(self.dashpay.high_water_sent_ms, snapshot, max_fetched); + } +} + +// --- Apply (restore from changeset / cold load) --- +// +// These methods reproduce persisted or already-decided state and skip the +// business invariants (auto-establish, ignore tombstones, persist-on-mutate) +// ON PURPOSE: the establishment / ignore decisions were made before the data +// was persisted, and replay must reproduce state, not re-decide it. They are +// for the changeset-apply path (`wallet/apply.rs`), the cold-load restore +// paths (FFI loader, storage test helpers), and test fixtures — live +// mutations go through the invariant-holding methods above. impl ManagedIdentity { /// Promote a contact to established during apply, also dropping any @@ -202,17 +743,56 @@ impl ManagedIdentity { /// auto-establishment contract: when `established` is populated for /// `(owner, contact)`, the apply path MUST drop the matching /// entries from both `sent_contact_requests` and - /// `incoming_contact_requests`. - /// - /// This is the only contact-side apply helper that earns its name — - /// the trivial sent / incoming insert and remove paths are inlined - /// at the call site in `wallet/apply.rs` (single map operation, no - /// invariant to protect). - pub(crate) fn apply_established_contact(&mut self, contact: EstablishedContact) { + /// `incoming_contact_requests`. (On cold-load paths that emit at most + /// one of {established, sent, incoming} per contact into fresh maps, + /// the removes are no-ops.) + pub fn apply_established_contact(&mut self, contact: EstablishedContact) { let contact_id = contact.contact_identity_id; - self.sent_contact_requests.remove(&contact_id); - self.incoming_contact_requests.remove(&contact_id); - self.established_contacts.insert(contact_id, contact); + self.dashpay.sent_contact_requests.remove(&contact_id); + self.dashpay.incoming_contact_requests.remove(&contact_id); + self.dashpay + .established_contacts + .insert(contact_id, contact); + } + + /// Reproduce a persisted sent contact request, keyed by its + /// `recipient_id` (last write wins). + pub fn apply_sent_contact_request(&mut self, request: ContactRequest) { + self.dashpay + .sent_contact_requests + .insert(request.recipient_id, request); + } + + /// Reproduce a persisted incoming contact request, keyed by its + /// `sender_id` (last write wins). + pub fn apply_incoming_contact_request(&mut self, request: ContactRequest) { + self.dashpay + .incoming_contact_requests + .insert(request.sender_id, request); + } + + /// Reproduce a persisted sent-request tombstone. + pub(crate) fn apply_removed_sent(&mut self, recipient_id: &Identifier) { + self.dashpay.sent_contact_requests.remove(recipient_id); + } + + /// Reproduce a persisted incoming-request tombstone. + pub(crate) fn apply_removed_incoming(&mut self, sender_id: &Identifier) { + self.dashpay.incoming_contact_requests.remove(sender_id); + } + + /// Reproduce a persisted ignore marker. Does NOT drop pending incoming + /// requests or emit a tombstone changeset — that already happened in + /// [`Self::ignore_sender`] before the marker was persisted. + pub fn apply_ignored_sender(&mut self, sender_id: Identifier) { + self.dashpay.ignored_senders.insert(sender_id); + } + + /// Reproduce a persisted un-ignore. Does NOT rewind the receive cursor — + /// the live [`Self::unignore_sender`] already did, and on a cold load the + /// cursor starts at `None` anyway. + pub(crate) fn apply_unignored_sender(&mut self, sender_id: &Identifier) { + self.dashpay.ignored_senders.remove(sender_id); } } @@ -228,6 +808,32 @@ mod tests { WalletPersister::new([0u8; 32], Arc::new(NoPlatformPersistence)) } + /// A persister whose every `store` fails — pins that a mutator surfaces the + /// failure instead of swallowing it. + fn failing_persister() -> WalletPersister { + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::wallet::platform_wallet::WalletId; + struct Failing; + impl PlatformWalletPersistence for Failing { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Err(PersistenceError::backend("store armed to fail")) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + WalletPersister::new([0u8; 32], Arc::new(Failing)) + } + fn create_test_identity(id_bytes: [u8; 32]) -> ManagedIdentity { let identity_v0 = IdentityV0 { id: Identifier::from(id_bytes), @@ -264,13 +870,100 @@ mod tests { let request = create_contact_request(sender_id, recipient_id, 1234567890); - managed.add_sent_contact_request(request.clone(), &p); + managed + .add_sent_contact_request(request.clone(), &p) + .expect("setup persists"); // Should be in sent requests - assert_eq!(managed.sent_contact_requests.len(), 1); - assert!(managed.sent_contact_requests.contains_key(&recipient_id)); - assert_eq!(managed.incoming_contact_requests.len(), 0); - assert_eq!(managed.established_contacts.len(), 0); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 1); + assert!(managed + .dashpay + .sent_contact_requests + .contains_key(&recipient_id)); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 0); + assert_eq!(managed.dashpay.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) + .expect("setup persists"); + assert_eq!(managed.dashpay.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" + ); + } + + /// **Ignore of a sender with NO pending incoming entry must not emit a + /// `removed_incoming` tombstone.** The contacts table is one row per + /// pair, so a tombstone for an ESTABLISHED contact (ignore tapped after + /// auto-establish, or on an established pair directly) would DELETE the + /// established row — both request blobs plus the user's + /// alias/note/hidden/accepted-accounts — while memory keeps the contact + /// established. Mirrors the `removed.is_some()` guard already used by + /// `remove_incoming_contact_request`. Was red against the unconditional + /// emission. + #[test] + fn ignore_sender_without_pending_incoming_emits_no_tombstone() { + let mut managed = create_test_identity([1u8; 32]); + let owner_id = managed.id(); + let contact_id = Identifier::from([2u8; 32]); + let p = noop_persister(); + + // Establish the pair (incoming + sent → auto-establish), leaving NO + // pending incoming entry. + managed + .add_incoming_contact_request(create_contact_request(contact_id, owner_id, 1), &p) + .expect("setup persists"); + managed + .add_sent_contact_request(create_contact_request(owner_id, contact_id, 2), &p) + .expect("setup persists"); + assert_eq!(managed.dashpay.established_contacts.len(), 1); + assert!(managed.dashpay.incoming_contact_requests.is_empty()); + + let cs = managed.ignore_sender(&contact_id); + + // The suppression is recorded... + assert!( + cs.ignored.contains(&(owner_id, contact_id)), + "ignore must record the per-sender suppression" + ); + // ...but NO row tombstone is emitted — nothing pending was removed, + // and an unconditional tombstone would destroy the established row. + assert!( + cs.removed_incoming.is_empty(), + "no pending incoming entry was removed, so no tombstone may be emitted" + ); + // The established contact survives in memory untouched. + assert_eq!(managed.dashpay.established_contacts.len(), 1); } #[test] @@ -282,13 +975,18 @@ mod tests { let request = create_contact_request(sender_id, recipient_id, 1234567890); - managed.add_incoming_contact_request(request.clone(), &p); + managed + .add_incoming_contact_request(request.clone(), &p) + .expect("setup persists"); // Should be in incoming requests - assert_eq!(managed.incoming_contact_requests.len(), 1); - assert!(managed.incoming_contact_requests.contains_key(&sender_id)); - assert_eq!(managed.sent_contact_requests.len(), 0); - assert_eq!(managed.established_contacts.len(), 0); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 1); + assert!(managed + .dashpay + .incoming_contact_requests + .contains_key(&sender_id)); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 0); + assert_eq!(managed.dashpay.established_contacts.len(), 0); } #[test] @@ -300,20 +998,236 @@ mod tests { // Add sent request first let outgoing = create_contact_request(our_id, contact_id, 1234567890); - managed.add_sent_contact_request(outgoing, &p); + managed + .add_sent_contact_request(outgoing, &p) + .expect("setup persists"); - assert_eq!(managed.sent_contact_requests.len(), 1); - assert_eq!(managed.established_contacts.len(), 0); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 1); + assert_eq!(managed.dashpay.established_contacts.len(), 0); // Add incoming request - should auto-establish let incoming = create_contact_request(contact_id, our_id, 1234567891); - managed.add_incoming_contact_request(incoming, &p); + managed + .add_incoming_contact_request(incoming, &p) + .expect("setup persists"); // Requests should be moved to established contacts - assert_eq!(managed.sent_contact_requests.len(), 0); - assert_eq!(managed.incoming_contact_requests.len(), 0); - assert_eq!(managed.established_contacts.len(), 1); - assert!(managed.established_contacts.contains_key(&contact_id)); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 0); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 0); + assert_eq!(managed.dashpay.established_contacts.len(), 1); + assert!(managed + .dashpay + .established_contacts + .contains_key(&contact_id)); + } + + /// DIP-15 §8.5 receive-side label: a contact's account label is derived + /// from their incoming request, so a rotation (new incoming request → + /// new key material, stale external account torn down + rebuilt) MUST + /// drop the old label — otherwise the rebuilt channel would show the old + /// contact's label. Pins that the in-place rotation path clears + /// `contact_account_label` where it clears `payment_channel_broken` (the + /// constructor-based reset is unreachable for an already-established + /// contact, so this is the load-bearing reset). + #[test] + fn rotation_resets_contact_account_label() { + 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(); + + // An established contact carrying a stale label and a broken channel. + let outgoing = create_contact_request(our_id, contact_id, 1000); + let incoming = create_contact_request(contact_id, our_id, 1001); + let mut established = EstablishedContact::new(contact_id, outgoing, incoming); + established.contact_account_label = Some("Old label".to_string()); + established.payment_channel_broken = true; + managed + .dashpay + .established_contacts + .insert(contact_id, established); + + // A superseding incoming request rotates the relationship. + let rotated = create_contact_request(contact_id, our_id, 2000); + let rekeyed = managed + .apply_rotated_incoming_request(rotated, &p) + .expect("rotation persists in test"); + + assert!( + rekeyed, + "a superseding incoming request must re-key the established contact" + ); + let contact = managed + .dashpay + .established_contacts + .get(&contact_id) + .expect("still established after rotation"); + assert_eq!( + contact.contact_account_label, None, + "rotation must drop the stale label so it re-derives from the new request" + ); + assert!( + !contact.payment_channel_broken, + "rotation clears the broken flag (existing behavior, regression guard)" + ); + } + + /// A failed metadata persist must SURFACE as `Err`, not return `Ok(true)` — + /// else the alias/note is lost on restart AND the drain clears the only + /// retry hook. The pre-fix `set_contact_metadata` logged + returned `true`. + #[test] + fn set_contact_metadata_surfaces_persist_failure() { + let mut managed = create_test_identity([1u8; 32]); + let our_id = Identifier::from([1u8; 32]); + let contact_id = Identifier::from([2u8; 32]); + let outgoing = create_contact_request(our_id, contact_id, 1000); + let incoming = create_contact_request(contact_id, our_id, 1001); + managed.dashpay.established_contacts.insert( + contact_id, + EstablishedContact::new(contact_id, outgoing, incoming), + ); + + let result = managed.set_contact_metadata( + &contact_id, + ContactInfoPrivateData { + alias_name: Some("New alias".to_string()), + note: None, + display_hidden: false, + accepted_accounts: Vec::new(), + }, + &failing_persister(), + ); + assert!( + result.is_err(), + "a failed metadata persist must surface, not report success" + ); + } + + /// A rotation supersede whose persist fails must leave the in-memory + /// `outgoing_request` on the OLD reference — else the retry hits the + /// same-reference no-op guard and the rotation is silently lost from disk + /// for the process lifetime (only a restart re-fetching from platform + /// would heal it). Mirror of the `set_contact_metadata` persist-order + /// tests below. + #[test] + fn rotation_supersede_failed_persist_leaves_memory_on_old_reference() { + let mut managed = create_test_identity([1u8; 32]); + let our_id = Identifier::from([1u8; 32]); + let contact_id = Identifier::from([2u8; 32]); + let outgoing = create_contact_request(our_id, contact_id, 1000); + let incoming = create_contact_request(contact_id, our_id, 1001); + managed.dashpay.established_contacts.insert( + contact_id, + EstablishedContact::new(contact_id, outgoing.clone(), incoming), + ); + + // A superseding re-send with a bumped account_reference. + let mut rotated = create_contact_request(our_id, contact_id, 1002); + rotated.account_reference = outgoing.account_reference + 1; + + let result = managed.add_sent_contact_request(rotated.clone(), &failing_persister()); + assert!(result.is_err(), "a failed supersede persist must surface"); + assert_eq!( + managed.dashpay.established_contacts[&contact_id] + .outgoing_request + .account_reference, + outgoing.account_reference, + "memory must stay on the old reference so the retry re-persists" + ); + + // The retry against a working persister must take the supersede path + // (not the same-reference no-op) and commit the rotation. + managed + .add_sent_contact_request(rotated.clone(), &noop_persister()) + .expect("retry persists"); + assert_eq!( + managed.dashpay.established_contacts[&contact_id] + .outgoing_request + .account_reference, + rotated.account_reference, + "the retried rotation must land in memory" + ); + } + + /// A retry after a failed metadata persist must actually re-store — it must + /// NOT be swallowed by the unchanged-equality short-circuit. Pre-fix, + /// `set_contact_metadata` mutated memory BEFORE persisting, so a failed + /// store left memory == metadata; the next retry hit the equality + /// short-circuit and returned `Ok(true)` WITHOUT persisting, permanently + /// losing the alias/note. + #[test] + fn set_contact_metadata_retry_after_failed_persist_actually_persists() { + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::wallet::platform_wallet::WalletId; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Counting(Arc); + impl PlatformWalletPersistence for Counting { + fn store( + &self, + _w: WalletId, + _cs: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn flush(&self, _w: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + let mut managed = create_test_identity([1u8; 32]); + let our_id = Identifier::from([1u8; 32]); + let contact_id = Identifier::from([2u8; 32]); + let outgoing = create_contact_request(our_id, contact_id, 1000); + let incoming = create_contact_request(contact_id, our_id, 1001); + managed.dashpay.established_contacts.insert( + contact_id, + EstablishedContact::new(contact_id, outgoing, incoming), + ); + let meta = ContactInfoPrivateData { + alias_name: Some("New alias".to_string()), + note: None, + display_hidden: false, + accepted_accounts: Vec::new(), + }; + + // First attempt fails to persist → surfaces as Err. + let r1 = managed.set_contact_metadata(&contact_id, meta.clone(), &failing_persister()); + assert!( + r1.is_err(), + "first attempt must surface the persist failure" + ); + + // Retry with the SAME metadata against a working persister. The fix + // (persist-before-commit) leaves memory unchanged on the failed + // attempt, so this retry must NOT short-circuit — it must re-store. + let count = Arc::new(AtomicUsize::new(0)); + let working = WalletPersister::new([0u8; 32], Arc::new(Counting(count.clone()))); + let r2 = managed.set_contact_metadata(&contact_id, meta, &working); + assert!(r2.is_ok(), "retry must succeed"); + assert_eq!( + count.load(Ordering::SeqCst), + 1, + "retry after a failed persist must actually re-store, not be swallowed \ + by the unchanged-equality short-circuit" + ); + assert_eq!( + managed + .dashpay + .established_contacts + .get(&contact_id) + .unwrap() + .alias + .as_deref(), + Some("New alias"), + "memory must reflect the persisted alias after a successful retry" + ); } #[test] @@ -325,20 +1239,27 @@ mod tests { // Add incoming request first let incoming = create_contact_request(contact_id, our_id, 1234567890); - managed.add_incoming_contact_request(incoming, &p); + managed + .add_incoming_contact_request(incoming, &p) + .expect("setup persists"); - assert_eq!(managed.incoming_contact_requests.len(), 1); - assert_eq!(managed.established_contacts.len(), 0); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 1); + assert_eq!(managed.dashpay.established_contacts.len(), 0); // Add sent request - should auto-establish let outgoing = create_contact_request(our_id, contact_id, 1234567891); - managed.add_sent_contact_request(outgoing, &p); + managed + .add_sent_contact_request(outgoing, &p) + .expect("setup persists"); // Requests should be moved to established contacts - assert_eq!(managed.sent_contact_requests.len(), 0); - assert_eq!(managed.incoming_contact_requests.len(), 0); - assert_eq!(managed.established_contacts.len(), 1); - assert!(managed.established_contacts.contains_key(&contact_id)); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 0); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 0); + assert_eq!(managed.dashpay.established_contacts.len(), 1); + assert!(managed + .dashpay + .established_contacts + .contains_key(&contact_id)); } #[test] @@ -349,9 +1270,11 @@ mod tests { let p = noop_persister(); let request = create_contact_request(sender_id, recipient_id, 1234567890); - managed.add_sent_contact_request(request.clone(), &p); + managed + .add_sent_contact_request(request.clone(), &p) + .expect("setup persists"); - assert_eq!(managed.sent_contact_requests.len(), 1); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 1); // Remove the request let (removed, cs) = managed.remove_sent_contact_request(&recipient_id); @@ -361,7 +1284,7 @@ mod tests { owner_id: managed.id(), recipient_id })); - assert_eq!(managed.sent_contact_requests.len(), 0); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 0); } #[test] @@ -382,9 +1305,11 @@ mod tests { let p = noop_persister(); let request = create_contact_request(sender_id, recipient_id, 1234567890); - managed.add_incoming_contact_request(request.clone(), &p); + managed + .add_incoming_contact_request(request.clone(), &p) + .expect("setup persists"); - assert_eq!(managed.incoming_contact_requests.len(), 1); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 1); // Remove the request let (removed, cs) = managed.remove_incoming_contact_request(&sender_id); @@ -394,7 +1319,7 @@ mod tests { owner_id: managed.id(), sender_id })); - assert_eq!(managed.incoming_contact_requests.len(), 0); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 0); } #[test] @@ -417,8 +1342,12 @@ mod tests { let outgoing = create_contact_request(our_id, contact_id, 1234567890); let incoming = create_contact_request(contact_id, our_id, 1234567891); - managed.sent_contact_requests.insert(contact_id, outgoing); managed + .dashpay + .sent_contact_requests + .insert(contact_id, outgoing); + managed + .dashpay .incoming_contact_requests .insert(contact_id, incoming); @@ -438,10 +1367,13 @@ mod tests { assert!(cs.removed_incoming.is_empty()); // Verify requests were removed and contact established - assert_eq!(managed.sent_contact_requests.len(), 0); - assert_eq!(managed.incoming_contact_requests.len(), 0); - assert_eq!(managed.established_contacts.len(), 1); - assert!(managed.established_contacts.contains_key(&contact_id)); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 0); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 0); + assert_eq!(managed.dashpay.established_contacts.len(), 1); + assert!(managed + .dashpay + .established_contacts + .contains_key(&contact_id)); } #[test] @@ -452,7 +1384,10 @@ mod tests { // Only add outgoing request let outgoing = create_contact_request(our_id, contact_id, 1234567890); - managed.sent_contact_requests.insert(contact_id, outgoing); + managed + .dashpay + .sent_contact_requests + .insert(contact_id, outgoing); // Accept should fail - no incoming request let (result, cs) = managed.accept_incoming_request(&contact_id); @@ -469,6 +1404,7 @@ mod tests { // Only add incoming request let incoming = create_contact_request(contact_id, our_id, 1234567891); managed + .dashpay .incoming_contact_requests .insert(contact_id, incoming); @@ -478,6 +1414,342 @@ 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) + .expect("setup persists"); + assert_eq!(managed.dashpay.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) + .expect("setup persists"); + assert_eq!( + managed.dashpay.sent_contact_requests.len(), + 1, + "re-ingesting an already-tracked sent request must not duplicate it" + ); + assert_eq!(managed.dashpay.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) + .expect("setup persists"); + managed + .add_sent_contact_request(create_contact_request(our_id, contact_id, 2), &p) + .expect("setup persists"); + assert_eq!(managed.dashpay.established_contacts.len(), 1); + let established = managed + .dashpay + .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) + .expect("setup persists"); + + let established = managed + .dashpay + .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); + } + + /// Sent-side rotation supersede: re-sending to an already-established + /// contact with a DIFFERENT outgoing `accountReference` must advance + /// the established contact's `outgoing_request` in place (mirroring the + /// receive-side `apply_rotated_incoming_request`) while preserving user + /// metadata. Without this the tracked outgoing reference freezes at the + /// first send and the next rotation re-derives the same reference, + /// colliding on the contract's unique index. Two consecutive rotation + /// re-sends must therefore be tracked with DISTINCT references, and the + /// tracked reference must match the newest re-send. + #[test] + fn add_sent_contact_request_rotation_supersedes_outgoing_reference() { + 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 user metadata. + managed + .add_incoming_contact_request(create_contact_request(contact_id, our_id, 1), &p) + .expect("setup persists"); + let mut first_send = create_contact_request(our_id, contact_id, 2); + first_send.account_reference = 100; // R0 + managed + .add_sent_contact_request(first_send, &p) + .expect("setup persists"); + assert_eq!(managed.dashpay.established_contacts.len(), 1); + let est = managed + .dashpay + .established_contacts + .get_mut(&contact_id) + .unwrap(); + est.set_alias("Carol".to_string()); + assert_eq!(est.outgoing_request.account_reference, 100); + + // Rotation #1: re-send with a bumped reference R1. + let mut rotation1 = create_contact_request(our_id, contact_id, 3); + rotation1.account_reference = 101; // R1 + managed + .add_sent_contact_request(rotation1, &p) + .expect("rotation #1 persists"); + assert_eq!( + managed + .dashpay + .established_contacts + .get(&contact_id) + .unwrap() + .outgoing_request + .account_reference, + 101, + "rotation #1 must advance the tracked outgoing reference (not freeze at R0)" + ); + + // Rotation #2: re-send with another bumped reference R2. + let mut rotation2 = create_contact_request(our_id, contact_id, 4); + rotation2.account_reference = 102; // R2 + managed + .add_sent_contact_request(rotation2, &p) + .expect("rotation #2 persists"); + let est = managed + .dashpay + .established_contacts + .get(&contact_id) + .unwrap(); + assert_eq!( + est.outgoing_request.account_reference, 102, + "rotation #2 must advance to the newest reference — distinct across sends" + ); + // User metadata survives the rotations. + assert_eq!(est.alias, Some("Carol".to_string())); + // Re-ingesting the SAME (newest) reference is a metadata-preserving + // no-op (the same-reference guard). + let mut resend_same = create_contact_request(our_id, contact_id, 5); + resend_same.account_reference = 102; + managed + .add_sent_contact_request(resend_same, &p) + .expect("same-reference re-ingest is a no-op"); + let est = managed + .dashpay + .established_contacts + .get(&contact_id) + .unwrap(); + assert_eq!(est.outgoing_request.account_reference, 102); + assert_eq!(est.alias, Some("Carol".to_string())); + } + + /// Pending-branch rotation supersede: re-sending to a recipient who + /// has NOT yet reciprocated (still in the pending sent map) with a + /// DIFFERENT outgoing `accountReference` must advance the pending + /// entry — the pending mirror of + /// [`add_sent_contact_request_rotation_supersedes_outgoing_reference`]. + /// Without it the pending map (and `prior_sent_account_reference`) + /// stays frozen at the first send, so the next rotation re-derives the + /// already-broadcast reference and collides on the contract's unique + /// index. Was red against the `contains_key` no-op guard. + #[test] + fn add_sent_contact_request_rotation_supersedes_pending_reference() { + 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(); + + // First send to a recipient with no incoming request → pending. + let mut first_send = create_contact_request(our_id, contact_id, 1); + first_send.account_reference = 100; // R0 + managed + .add_sent_contact_request(first_send, &p) + .expect("setup persists"); + assert!(managed.dashpay.established_contacts.is_empty()); + assert_eq!(managed.prior_sent_account_reference(&contact_id), Some(100)); + + // Rotation re-send while STILL pending: bumped reference R1. + let mut rotation = create_contact_request(our_id, contact_id, 2); + rotation.account_reference = 101; // R1 + managed + .add_sent_contact_request(rotation, &p) + .expect("pending rotation persists"); + assert_eq!( + managed.prior_sent_account_reference(&contact_id), + Some(101), + "pending rotation must advance the tracked reference (not freeze at R0)" + ); + // Still pending — the supersede must not fabricate an establishment. + assert!(managed.dashpay.established_contacts.is_empty()); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 1); + + // Same-reference re-ingest (the sweep re-reading the newest doc) + // stays a no-op. + let mut resend_same = create_contact_request(our_id, contact_id, 3); + resend_same.account_reference = 101; + managed + .add_sent_contact_request(resend_same, &p) + .expect("same-reference re-ingest is a no-op"); + assert_eq!(managed.prior_sent_account_reference(&contact_id), Some(101)); + + // The recipient finally reciprocates: establishment must capture + // the NEWEST outgoing reference, not the original. + managed + .add_incoming_contact_request(create_contact_request(contact_id, our_id, 4), &p) + .expect("reciprocal persists"); + let est = managed + .dashpay + .established_contacts + .get(&contact_id) + .expect("reciprocal establishes"); + assert_eq!( + est.outgoing_request.account_reference, 101, + "establishment must adopt the superseded (newest) outgoing reference" + ); + } + + /// 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) + .expect("setup persists"); + managed + .add_sent_contact_request(create_contact_request(our_id, contact_id, 2), &p) + .expect("setup persists"); + let est = managed + .dashpay + .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 + .dashpay + .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) + .expect("setup persists"); + + let est = managed + .dashpay + .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) + .expect("setup persists"); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 1); + + let cs = managed.ignore_sender(&sender_id); + + // Incoming dropped, sender recorded as ignored. + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 0); + assert!(managed.dashpay.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.dashpay.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.dashpay.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.dashpay.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.dashpay.high_water_received_ms, Some(999)); + } + #[test] fn test_multiple_contact_requests() { let mut managed = create_test_identity([1u8; 32]); @@ -489,29 +1761,261 @@ mod tests { // Add multiple sent requests managed - .add_sent_contact_request(create_contact_request(our_id, contact1_id, 1234567890), &p); + .add_sent_contact_request(create_contact_request(our_id, contact1_id, 1234567890), &p) + .expect("setup persists"); managed - .add_sent_contact_request(create_contact_request(our_id, contact2_id, 1234567891), &p); + .add_sent_contact_request(create_contact_request(our_id, contact2_id, 1234567891), &p) + .expect("setup persists"); // Add incoming request that doesn't match sent - managed.add_incoming_contact_request( - create_contact_request(contact3_id, our_id, 1234567892), - &p, - ); + managed + .add_incoming_contact_request( + create_contact_request(contact3_id, our_id, 1234567892), + &p, + ) + .expect("setup persists"); - assert_eq!(managed.sent_contact_requests.len(), 2); - assert_eq!(managed.incoming_contact_requests.len(), 1); - assert_eq!(managed.established_contacts.len(), 0); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 2); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 1); + assert_eq!(managed.dashpay.established_contacts.len(), 0); // Add incoming from contact1 - should establish - managed.add_incoming_contact_request( - create_contact_request(contact1_id, our_id, 1234567893), - &p, + managed + .add_incoming_contact_request( + create_contact_request(contact1_id, our_id, 1234567893), + &p, + ) + .expect("setup persists"); + + assert_eq!(managed.dashpay.sent_contact_requests.len(), 1); // Only contact2 left + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 1); // Only contact3 left + assert_eq!(managed.dashpay.established_contacts.len(), 1); // contact1 established + assert!(managed + .dashpay + .established_contacts + .contains_key(&contact1_id)); + } + + /// A structurally-valid but cryptographically-garbage `autoAcceptProof` + /// on an attacker-published contact request must be enqueued at most once: + /// after the drain marks it verify-failed, the next sweep's enqueue gate + /// must NOT re-pick it — otherwise the "waiting to finish setup" banner is + /// permanently re-tripped. A DIFFERENT proof from the same sender is not + /// suppressed by the prior bad one. + #[test] + fn verify_failed_auto_accept_proof_is_not_re_enqueued() { + let mut managed = create_test_identity([1u8; 32]); + let our_id = Identifier::from([1u8; 32]); + let sender_id = Identifier::from([2u8; 32]); + + // Minimal structurally-valid proof: 70 bytes, ECDSA lead byte 0x00. + let mk_request = |proof: Vec| { + let mut r = create_contact_request(sender_id, our_id, 1234567890); + r.auto_accept_proof = Some(proof); + r + }; + let garbage = mk_request({ + let mut p = vec![0x11u8; 70]; + p[0] = 0x00; + p + }); + + // First sweep: structurally valid + unmarked → enqueue. + assert!( + managed.should_enqueue_auto_accept(&sender_id, &garbage), + "a structurally-valid, unmarked proof must enqueue" + ); + + // The drain fails cryptographic verification and marks it. + let proof_bytes = garbage.auto_accept_proof.clone().unwrap(); + managed.mark_auto_accept_verify_failed(&sender_id, &proof_bytes); + + // Next sweep: the same bad proof must NOT be re-enqueued. + assert!( + !managed.should_enqueue_auto_accept(&sender_id, &garbage), + "a proof a prior drain rejected must not be re-enqueued" ); - assert_eq!(managed.sent_contact_requests.len(), 1); // Only contact2 left - assert_eq!(managed.incoming_contact_requests.len(), 1); // Only contact3 left - assert_eq!(managed.established_contacts.len(), 1); // contact1 established - assert!(managed.established_contacts.contains_key(&contact1_id)); + // A DIFFERENT proof from the SAME sender still enqueues — the marker + // is keyed by (sender, proof), not the sender alone. + let different = mk_request({ + let mut p = vec![0x22u8; 70]; + p[0] = 0x00; + p + }); + assert!( + managed.should_enqueue_auto_accept(&sender_id, &different), + "a different proof from the same sender must still enqueue" + ); + } + + /// Advancing never moves a cursor backward (guards out-of-order / + /// stale-max sweeps and restore over-shoot), and a zero-doc sweep leaves + /// it unchanged. Exercises the public methods so the pinned behavior is + /// the one callers actually get. + #[test] + fn advance_never_goes_backward_and_zero_doc_is_noop() { + let mut managed = create_test_identity([1u8; 32]); + + // First sweep from empty: adopt the max fetched. + managed.advance_high_water_received(None, Some(100)); + assert_eq!(managed.dashpay().high_water_received_ms(), Some(100)); + // Forward progress. + managed.advance_high_water_received(Some(100), Some(200)); + assert_eq!(managed.dashpay().high_water_received_ms(), Some(200)); + // A lower max (re-fetch within the overlap, or out-of-order) must NOT + // pull the cursor backward. + managed.advance_high_water_received(Some(200), Some(50)); + assert_eq!(managed.dashpay().high_water_received_ms(), Some(200)); + // A zero-doc sweep leaves the cursor exactly where it was. + managed.advance_high_water_received(Some(200), None); + assert_eq!(managed.dashpay().high_water_received_ms(), Some(200)); + + // `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. + let mut fresh = create_test_identity([2u8; 32]); + fresh.advance_high_water_sent(None, Some(0)); + assert_eq!(fresh.dashpay().high_water_sent_ms(), Some(0)); + fresh.advance_high_water_sent(Some(0), None); + assert_eq!(fresh.dashpay().high_water_sent_ms(), Some(0)); + // A zero-doc first sweep leaves the cursor unset. + let mut untouched = create_test_identity([3u8; 32]); + untouched.advance_high_water_sent(None, None); + assert_eq!(untouched.dashpay().high_water_sent_ms(), None); + } + + /// 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_respects_a_concurrent_reset() { + let p = noop_persister(); + + // Unchanged since snapshot -> normal advance. + let mut managed = create_test_identity([1u8; 32]); + managed.advance_high_water_received(None, Some(100)); + managed.advance_high_water_received(Some(100), Some(200)); + assert_eq!(managed.dashpay().high_water_received_ms(), Some(200)); + + // THE RACE: cursor was Some(100) at snapshot time; an un-ignore + // rewound it to None mid-sweep; this sweep's max Some(200) is stale + // (its fetch excluded the sender) -> keep the None so the next sweep + // does a full re-fetch. + let mut raced = create_test_identity([2u8; 32]); + let sender_id = Identifier::from([9u8; 32]); + raced.advance_high_water_received(None, Some(100)); + raced + .add_incoming_contact_request( + ContactRequest::new( + sender_id, + raced.id(), + 0, + 0, + 0, + vec![0u8; 96], + 100000, + 1234567890, + ), + &p, + ) + .expect("setup persists"); + let _ = raced.ignore_sender(&sender_id); + let _ = raced.unignore_sender(&sender_id); // rewinds cursor to None + assert_eq!(raced.dashpay().high_water_received_ms(), None); + raced.advance_high_water_received(Some(100), Some(200)); + assert_eq!( + raced.dashpay().high_water_received_ms(), + None, + "a stale sweep max must not clobber a concurrent rewind" + ); + + // Kill-the-mutant: ANY snapshot mismatch leaves the cursor untouched. + let mut drifted = create_test_identity([3u8; 32]); + drifted.advance_high_water_sent(None, Some(50)); + drifted.advance_high_water_sent(Some(100), Some(200)); + assert_eq!( + drifted.dashpay().high_water_sent_ms(), + Some(50), + "snapshot != current must be a no-op" + ); + } + + /// The `apply_*` replay family must reproduce persisted state WITHOUT + /// re-running business invariants: a reciprocal pair applied via + /// `apply_sent` + `apply_incoming` stays two pending requests (the + /// auto-establish decision was already made — or not — before persist), + /// while `apply_established_contact` drops both pending sides per the + /// changeset contract. + #[test] + fn apply_family_reproduces_state_without_re_deciding() { + let mut managed = create_test_identity([1u8; 32]); + let our_id = managed.id(); + let contact_id = Identifier::from([2u8; 32]); + + let sent = ContactRequest::new(our_id, contact_id, 0, 0, 0, vec![0u8; 96], 100, 1); + let incoming = ContactRequest::new(contact_id, our_id, 0, 0, 0, vec![0u8; 96], 100, 2); + + managed.apply_sent_contact_request(sent.clone()); + managed.apply_incoming_contact_request(incoming.clone()); + assert_eq!( + managed.dashpay().established_contacts().len(), + 0, + "replay must NOT auto-establish a reciprocal pair" + ); + assert!(managed + .dashpay() + .sent_contact_requests() + .contains_key(&contact_id)); + assert!(managed + .dashpay() + .incoming_contact_requests() + .contains_key(&contact_id)); + + // Tombstone replays remove exactly the keyed entry. + managed.apply_removed_sent(&contact_id); + assert!(managed.dashpay().sent_contact_requests().is_empty()); + managed.apply_removed_incoming(&contact_id); + assert!(managed.dashpay().incoming_contact_requests().is_empty()); + + // An established replay drops any matching pending sides. + managed.apply_sent_contact_request(sent.clone()); + managed.apply_incoming_contact_request(incoming.clone()); + managed.apply_established_contact(EstablishedContact::new(contact_id, sent, incoming)); + assert!(managed.dashpay().sent_contact_requests().is_empty()); + assert!(managed.dashpay().incoming_contact_requests().is_empty()); + assert!(managed + .dashpay() + .established_contacts() + .contains_key(&contact_id)); + } + + /// Per-element `apply_ignored_sender` over a fresh identity reproduces a + /// wholesale set assign — the equivalence the replay/restore paths (which + /// previously assigned or extended the whole `BTreeSet`) rely on. + #[test] + fn apply_ignored_sender_loop_equals_wholesale_assign() { + let persisted: std::collections::BTreeSet = [ + Identifier::from([3u8; 32]), + Identifier::from([4u8; 32]), + Identifier::from([5u8; 32]), + ] + .into(); + + let mut managed = create_test_identity([1u8; 32]); + for sender in &persisted { + managed.apply_ignored_sender(*sender); + } + assert_eq!(managed.dashpay().ignored_senders(), &persisted); + + // And the un-ignore replay removes without rewinding the cursor. + managed.advance_high_water_received(None, Some(100)); + managed.apply_unignored_sender(&Identifier::from([4u8; 32])); + assert_eq!(managed.dashpay().ignored_senders().len(), 2); + assert_eq!( + managed.dashpay().high_water_received_ms(), + Some(100), + "replay un-ignore must not rewind the live cursor" + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contacts.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contacts.rs index a71ccacfdd9..eaee6934cf0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contacts.rs @@ -8,7 +8,8 @@ use dpp::prelude::Identifier; impl ManagedIdentity { /// Add an established contact pub(crate) fn add_established_contact(&mut self, contact: EstablishedContact) { - self.established_contacts + self.dashpay + .established_contacts .insert(contact.contact_identity_id, contact); } @@ -17,7 +18,7 @@ impl ManagedIdentity { &mut self, contact_id: &Identifier, ) -> Option { - self.established_contacts.remove(contact_id) + self.dashpay.established_contacts.remove(contact_id) } /// Get an established contact by identity ID @@ -25,14 +26,21 @@ impl ManagedIdentity { &self, contact_id: &Identifier, ) -> Option<&EstablishedContact> { - self.established_contacts.get(contact_id) + self.dashpay.established_contacts.get(contact_id) } - /// Get a mutable established contact by identity ID - pub(crate) fn established_contact_mut( + /// Get a mutable established contact by identity ID. + /// + /// Escape hatch for per-contact sub-field mutation (channel-broken + /// flag, account labels, external-account registration). Callers that + /// mutate through it own the persistence step (a hand-built + /// [`ContactChangeSet`](crate::changeset::ContactChangeSet)) — map + /// *membership* still only changes through the invariant-holding + /// methods and their `apply_*` replay counterparts. + pub fn established_contact_mut( &mut self, contact_id: &Identifier, ) -> Option<&mut EstablishedContact> { - self.established_contacts.get_mut(contact_id) + self.dashpay.established_contacts.get_mut(contact_id) } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs new file mode 100644 index 00000000000..d9b9578a9d9 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -0,0 +1,170 @@ +//! Per-identity DashPay social state. +//! +//! [`DashPayState`] groups the DashPay-contract layer (contacts, +//! requests, profile, payments, deferred crypto) that a +//! [`ManagedIdentity`](super::ManagedIdentity) carries on top of its +//! identity-core fields. The struct itself is pure data; the mutation +//! methods that maintain its invariants stay on `ManagedIdentity` +//! (they need the identity id and the persistence snapshot). + +use crate::wallet::identity::{ + ContactProfileEntry, ContactRequest, DashPayProfile, EstablishedContact, PaymentEntry, +}; +use dpp::prelude::Identifier; +use std::collections::{BTreeMap, BTreeSet}; + +/// DashPay social state carried by one [`ManagedIdentity`](super::ManagedIdentity). +#[derive(Debug, Clone, Default)] +pub struct DashPayState { + /// Map of established contacts (bidirectional relationships) keyed by contact identity ID + pub(super) established_contacts: BTreeMap, + + /// Map of sent contact requests (outgoing, not yet reciprocated) keyed by recipient ID + pub(super) sent_contact_requests: BTreeMap, + + /// Map of incoming contact requests (not yet accepted) keyed by sender ID + pub(super) 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(super) ignored_senders: BTreeSet, + + /// DIP-15 auto-accept proofs that failed cryptographic verification (or + /// were expired / malformed) during a `drain_auto_accepts` pass, keyed by + /// `SHA256(sender_id ‖ proof_bytes)`. Consulted by the sync sweep's + /// auto-accept enqueue gate so a structurally-valid but cryptographically + /// bogus `autoAcceptProof` on an attacker-published contact request is not + /// re-enqueued every sweep (which would keep the "waiting to finish setup" + /// banner permanently tripped). + /// + /// Keying on `(sender, proof)` — not on the sender alone — means a + /// **different** proof from the same sender still enqueues (a genuine + /// re-issued proof is not blocked by a prior bad one). Only PERMANENT + /// failures (invalid signature / expired / malformed / bad index) are + /// recorded here; transient failures (provider unavailable, reciprocal-send + /// failure) stay retryable and are never marked. + /// + /// In-memory only (never persisted): a relaunch clears it, so a bad proof is + /// retried at most once per launch. The request stays manually acceptable + /// meanwhile, and the sender never establishes off a bogus proof — so + /// retrying once per launch is harmless and avoids a persisted tombstone for + /// attacker-controlled input. Capped at + /// `ManagedIdentity::AUTO_ACCEPT_VERIFY_FAILED_CAP` entries (arbitrary + /// eviction over cap) so a griefer paying credits for many distinct + /// malformed proofs can't grow it unboundedly for the process lifetime. + pub(super) auto_accept_verify_failed: BTreeSet<[u8; 32]>, + + /// 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(super) high_water_received_ms: Option, + /// High-water mark for the sent direction (`$ownerId == me`). + pub(super) high_water_sent_ms: Option, + + /// DashPay profile (display name, bio, avatar, public message) + /// published via the DashPay data contract. `None` until the + /// profile has been fetched or set. + pub profile: Option, + + /// DashPay payment history keyed by transaction id (hex string). + /// Each entry records a single Dash payment to or from a contact + /// identity, with direction, amount, memo, and status. + pub payments: BTreeMap, + + /// 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, + + /// Contacts for which a historical L1 rescan has already been triggered this + /// process lifetime (DIP-15 §12.6 coreHeight backfill). When the rescan + /// reconcile lowers the wallet's SPV `synced_height` to a contact's funding + /// height so the filter manager re-scans for payments that landed before the + /// receival address was watched, the contact is recorded here so the + /// recurring sweep does not re-lower the height every pass — which would + /// reset the in-flight backfill and prevent it from ever completing. + /// + /// In-memory only (never persisted): a relaunch clears it, and because + /// `synced_height` is restored at its monotonic high-water, an interrupted + /// backfill is re-triggered on the next launch — self-healing. The cost of + /// that reset is one historical re-match per launch while any contact is + /// funded below the tip; the compact filters are reused from disk (not + /// re-downloaded), so it is cheap. A persisted breadcrumb could make the + /// backfill durable across a crash if that ever becomes necessary. + pub rescan_triggered: BTreeSet, + + /// DashPay contact-crypto ops the unattended background sweep enqueued for + /// THIS identity but 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; each entry still carries its + /// `owner_identity_id` (== this identity) as the drain's routing key and the + /// SQLite key column. In-memory only for the live session: the queue is + /// persisted to the changeset (SQLite backend), but cold-load restore is + /// blocked upstream, so a re-imported wallet re-syncs from scratch and the + /// sweep re-enqueues what it needs. Deliberately NOT captured by + /// `IdentityEntry::from_managed` — persistence rides the flat changeset + /// delta, not a per-identity snapshot. + /// See [`PendingContactCrypto`](crate::changeset::PendingContactCrypto). + pub pending_contact_crypto: Vec, +} + +// Read access to the guarded relationship/cursor fields. Mutation goes +// through the invariant-holding methods on `ManagedIdentity` (or their +// `apply_*` replay counterparts) — the fields themselves are sealed to +// this module tree so no caller can insert a relationship without the +// auto-establish / tombstone / compare-and-advance rules running. +impl DashPayState { + /// Established contacts (bidirectional relationships) keyed by + /// contact identity id. + pub fn established_contacts(&self) -> &BTreeMap { + &self.established_contacts + } + + /// Sent contact requests (outgoing, not yet reciprocated) keyed by + /// recipient id. + pub fn sent_contact_requests(&self) -> &BTreeMap { + &self.sent_contact_requests + } + + /// Incoming contact requests (not yet accepted) keyed by sender id. + pub fn incoming_contact_requests(&self) -> &BTreeMap { + &self.incoming_contact_requests + } + + /// Senders this identity has chosen to ignore (per-sender mute). + pub fn ignored_senders(&self) -> &BTreeSet { + &self.ignored_senders + } + + /// Received-direction incremental-sync cursor (`$createdAt` ms of the + /// newest fetched `contactRequest`). `None` ⇒ never synced this session. + pub fn high_water_received_ms(&self) -> Option { + self.high_water_received_ms + } + + /// Sent-direction incremental-sync cursor. `None` ⇒ never synced this + /// session. + pub fn high_water_sent_ms(&self) -> Option { + self.high_water_sent_ms + } +} 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..324d6249053 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 @@ -81,15 +81,11 @@ impl ManagedIdentity { identity_index: Some(identity_index), last_updated_balance_block_time: None, last_synced_keys_block_time: None, - established_contacts: Default::default(), - sent_contact_requests: Default::default(), - incoming_contact_requests: Default::default(), status: Default::default(), dpns_names: Vec::new(), contested_dpns_names: Vec::new(), wallet_id: None, - dashpay_profile: None, - dashpay_payments: BTreeMap::new(), + dashpay: Default::default(), } } @@ -105,15 +101,11 @@ impl ManagedIdentity { identity_index: None, last_updated_balance_block_time: None, last_synced_keys_block_time: None, - established_contacts: Default::default(), - sent_contact_requests: Default::default(), - incoming_contact_requests: Default::default(), status: Default::default(), dpns_names: Vec::new(), contested_dpns_names: Vec::new(), wallet_id: None, - dashpay_profile: None, - dashpay_payments: BTreeMap::new(), + dashpay: Default::default(), } } @@ -132,7 +124,7 @@ impl ManagedIdentity { profile: Option, persister: &WalletPersister, ) { - self.dashpay_profile = profile; + self.dashpay.profile = profile; let cs = self.snapshot_changeset(); if let Err(e) = persister.store(cs.into()) { tracing::error!("Failed to persist changeset: {}", e); @@ -155,12 +147,46 @@ impl ManagedIdentity { tx_id: String, entry: crate::wallet::identity::PaymentEntry, persister: &WalletPersister, - ) { - self.dashpay_payments.insert(tx_id, entry); + ) -> Result<(), crate::changeset::PersistenceError> { + // Insert, snapshot, persist — but roll the change back if the store + // fails so in-memory state stays equal to the persisted state. + // Otherwise a caller's `contains_key` retry guard sees the + // un-persisted entry and skips the next sweep's re-attempt, permanently + // dropping a Sent entry + memo that has no on-chain recovery (the + // self-healing sweep callers re-derive Received from UTXOs next pass). + // A failed overwrite restores the previous entry rather than deleting + // it. Either way the persist result is returned, not swallowed: the + // user-initiated send path (`send_payment`) surfaces it in the UI. + let previous = self.dashpay.payments.insert(tx_id.clone(), entry); let cs = self.snapshot_changeset(); if let Err(e) = persister.store(cs.into()) { - tracing::error!("Failed to persist changeset: {}", e); + match previous { + Some(prev) => { + self.dashpay.payments.insert(tx_id, prev); + } + None => { + self.dashpay.payments.remove(&tx_id); + } + } + return Err(e); } + Ok(()) + } + + /// 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,51 +275,86 @@ 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); + ) -> Result<(), crate::changeset::PersistenceError> { + // Single-key form of [`Self::add_keys`] — one canonical + // key-layering + changeset path so the two can't drift. + self.add_keys( + vec![crate::changeset::KeyWithBreadcrumb { + key: public_key, + breadcrumb: derivation_breadcrumb, + }], + 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, + ) -> Result<(), crate::changeset::PersistenceError> { + use dpp::identity::accessors::IdentitySettersV0; + if keys.is_empty() { + return Ok(()); + } 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, + } in keys + { + let key_id = public_key.id(); + let public_key_hash = pubkey_hash_of(&public_key); + current.insert(key_id, public_key.clone()); + let (wallet_id, derivation_indices) = match breadcrumb { + Some((wallet_id, identity_index, key_index)) => ( + Some(wallet_id), + Some(crate::changeset::IdentityKeyDerivationIndices { + identity_index, + key_index, + }), + ), + 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, + }, + ); + } + self.identity.set_public_keys(current); let cs = crate::changeset::PlatformWalletChangeSet { identities: Some(self.snapshot_changeset()), identity_keys: Some(keys_cs), ..Default::default() }; - if let Err(e) = persister.store(cs) { - tracing::error!("Failed to persist changeset: {}", e); - } + // Surface the persist failure — these rows carry the per-key + // derivation breadcrumb + verified scalar that make an imported / + // restored identity signable. Swallowing a failed store here would + // leave the keys in memory but absent from the client store, so the + // identity comes back watch-only after restart with no signal. + persister.store(cs)?; + Ok(()) } /// Stamp `disabled_at` on the public keys named by `key_ids` and @@ -401,3 +462,290 @@ 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()) + } + } + + /// Persister whose every `store` fails — pins that a key-persist failure + /// is surfaced, not swallowed. + struct FailingPersister; + impl PlatformWalletPersistence for FailingPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Err(PersistenceError::backend("add_keys store armed to fail")) + } + 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), key 1 is watch-only (None). + managed + .add_keys( + vec![ + crate::changeset::KeyWithBreadcrumb { + key: key(0), + breadcrumb: Some((wallet_id, 7, 0)), + }, + crate::changeset::KeyWithBreadcrumb { + key: key(1), + breadcrumb: None, + }, + ], + &p, + ) + .expect("add_keys persists in test"); + + // 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)); + assert_eq!( + upserts[&(id, 1)].derivation_indices, + None, + "watch-only key carries no breadcrumb" + ); + assert_eq!(upserts[&(id, 1)].wallet_id, None); + } + + /// 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) + .expect("empty add_keys is a no-op Ok"); + assert!( + persister.stores.lock().unwrap().is_empty(), + "empty add_keys stores nothing" + ); + } + + /// A failed key-persist must SURFACE as `Err`, not be swallowed — else an + /// imported / restored identity comes back watch-only after restart with no + /// signal. The pre-fix `add_keys` logged the error and returned `()`. + #[test] + fn add_keys_surfaces_persist_failure() { + 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 p = WalletPersister::new(wallet_id, std::sync::Arc::new(FailingPersister) as _); + + let result = managed.add_keys( + vec![crate::changeset::KeyWithBreadcrumb { + key: key(0), + breadcrumb: Some((wallet_id, 7, 0)), + }], + &p, + ); + assert!( + result.is_err(), + "a failed key-persist must surface, not be swallowed" + ); + } + + /// A failed payment persist must NOT strand the entry in memory — else a + /// caller's `contains_key` retry guard skips the next sweep's re-attempt + /// and the Sent entry + memo (no on-chain recovery) is permanently lost. + /// Pre-fix, `record_dashpay_payment` inserted before persisting, so a + /// failed store left the entry in memory. + #[test] + fn record_dashpay_payment_rolls_back_fresh_on_persist_failure() { + 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 p = WalletPersister::new([0xAB; 32], std::sync::Arc::new(FailingPersister) as _); + let alice = Identifier::from([0xAA; 32]); + + let result = managed.record_dashpay_payment( + "tx1".into(), + PaymentEntry::new_sent(alice, 100, Some("rent".into())), + &p, + ); + assert!(result.is_err(), "a failed payment persist must surface"); + assert!( + !managed.dashpay.payments.contains_key("tx1"), + "a failed persist must not strand the entry in memory, else the \ + contains_key retry guard skips the re-attempt" + ); + } + + /// A failed *overwrite* (e.g. Pending→Confirmed) must restore the previous + /// entry, not leave the un-persisted new value (or delete the row). + #[test] + fn record_dashpay_payment_restores_previous_on_failed_overwrite() { + 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 p = WalletPersister::new([0xAB; 32], std::sync::Arc::new(FailingPersister) as _); + let alice = Identifier::from([0xAA; 32]); + + // Pre-existing (persisted) entry. + managed + .dashpay + .payments + .insert("tx1".into(), PaymentEntry::new_sent(alice, 100, None)); + + // Overwrite attempt fails to persist → previous must survive intact. + let result = managed.record_dashpay_payment( + "tx1".into(), + PaymentEntry::new_sent(alice, 999, Some("changed".into())), + &p, + ); + assert!(result.is_err()); + let kept = managed + .dashpay + .payments + .get("tx1") + .expect("previous survives"); + assert_eq!( + kept.amount_duffs, 100, + "a failed overwrite must restore the previous entry, not the un-persisted new value" + ); + } + + #[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..6f62c2dd616 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 @@ -5,9 +5,12 @@ mod contact_requests; mod contacts; +mod dashpay; mod identity_ops; mod sync; +pub use dashpay::DashPayState; + // `block_time` + `key_storage` moved to `crate::wallet::identity::types`. // Re-export so every `impl ManagedIdentity` block below keeps working // unchanged and external users can still reach them through the old @@ -17,10 +20,7 @@ pub use crate::wallet::identity::types::key_storage::{ self, DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData, }; -use crate::wallet::identity::{ContactRequest, DashPayProfile, EstablishedContact, PaymentEntry}; use dpp::identity::Identity; -use dpp::prelude::Identifier; -use std::collections::BTreeMap; /// A managed identity that combines an Identity with wallet-specific metadata. /// @@ -56,15 +56,6 @@ pub struct ManagedIdentity { /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, - /// Map of established contacts (bidirectional relationships) keyed by contact identity ID - pub established_contacts: BTreeMap, - - /// Map of sent contact requests (outgoing, not yet reciprocated) keyed by recipient ID - pub sent_contact_requests: BTreeMap, - - /// Map of incoming contact requests (not yet accepted) keyed by sender ID - pub incoming_contact_requests: BTreeMap, - /// Identity lifecycle status on Platform. pub status: IdentityStatus, @@ -93,22 +84,89 @@ pub struct ManagedIdentity { /// lives in the out-of-wallet bucket (observed only). pub wallet_id: Option<[u8; 32]>, - /// DashPay profile (display name, bio, avatar, public message) - /// published via the DashPay data contract. `None` until the - /// profile has been fetched or set. - pub dashpay_profile: Option, + /// DashPay social state layered on this identity: contacts, contact + /// requests, profile, payments, sync cursors, deferred crypto. See + /// [`DashPayState`] for the per-field contracts. + /// + /// Module-private on purpose: reads go through [`Self::dashpay`], + /// invariant-carrying mutations through the methods on this type, + /// and open-tier cache writes through the `*_mut` accessors. Keeping + /// the field sealed also rules out whole-value replacement + /// (`mem::take` / reassignment), which would silently wipe the + /// guarded relationship maps and sync cursors. + dashpay: DashPayState, +} + +impl ManagedIdentity { + /// Read access to this identity's DashPay social state. + pub fn dashpay(&self) -> &DashPayState { + &self.dashpay + } + + /// Mutable access to the DashPay profile cache. + /// + /// Replay/restore surface: bypasses persistence on purpose (the + /// changeset being applied is already durable). Live mutations that + /// must persist go through [`Self::set_dashpay_profile`]. + pub fn dashpay_profile_mut(&mut self) -> &mut Option { + &mut self.dashpay.profile + } + + /// Mutable access to the DashPay payment-history cache. + /// + /// Replay/restore surface: bypasses persistence and the + /// rollback-on-persist-failure contract of + /// [`Self::record_dashpay_payment`], which live mutations must use. + pub fn dashpay_payments_mut( + &mut self, + ) -> &mut std::collections::BTreeMap { + &mut self.dashpay.payments + } + + /// Mutable access to the cached contact profiles. + /// + /// Replay/restore surface: bypasses persistence on purpose (the + /// changeset being applied is already durable). The live writer is + /// the profile sync sweep, which persists a snapshot after writing. + pub fn dashpay_contact_profiles_mut( + &mut self, + ) -> &mut std::collections::BTreeMap< + dpp::prelude::Identifier, + crate::wallet::identity::ContactProfileEntry, + > { + &mut self.dashpay.contact_profiles + } - /// DashPay payment history keyed by transaction id (hex string). - /// Each entry records a single Dash payment to or from a contact - /// identity, with direction, amount, memo, and status. - pub dashpay_payments: BTreeMap, + /// Mutable access to the per-session contact-rescan guard set. + /// + /// In-memory only — never persisted; see the field docs on + /// [`DashPayState::rescan_triggered`] for the self-healing contract. + pub fn dashpay_rescan_triggered_mut( + &mut self, + ) -> &mut std::collections::BTreeSet { + &mut self.dashpay.rescan_triggered + } + + /// Mutable access to the deferred contact-crypto queue. + /// + /// The queue's dedup invariant (≤ 1 entry per + /// `(owner, contact, kind)`) lives in + /// [`upsert_pending_contact_crypto`](crate::changeset::upsert_pending_contact_crypto) — + /// callers inserting entries must go through it. + pub fn dashpay_pending_contact_crypto_mut( + &mut self, + ) -> &mut Vec { + &mut self.dashpay.pending_contact_crypto + } } #[cfg(test)] mod tests { use super::*; + use crate::wallet::identity::ContactRequest; use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use dpp::identity::v0::IdentityV0; + use dpp::prelude::Identifier; use std::collections::BTreeMap; use std::sync::Arc; @@ -239,11 +297,13 @@ mod tests { 100000, 1234567890, ); - managed.add_incoming_contact_request(incoming_request, &p); + managed + .add_incoming_contact_request(incoming_request, &p) + .expect("setup persists"); // Verify it's in incoming requests - assert_eq!(managed.incoming_contact_requests.len(), 1); - assert_eq!(managed.established_contacts.len(), 0); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 1); + assert_eq!(managed.dashpay.established_contacts.len(), 0); // Now add a sent request to the same contact - should auto-establish let outgoing_request = ContactRequest::new( @@ -256,13 +316,18 @@ mod tests { 100000, 1234567891, ); - managed.add_sent_contact_request(outgoing_request, &p); + managed + .add_sent_contact_request(outgoing_request, &p) + .expect("setup persists"); // Verify contact was established - assert_eq!(managed.incoming_contact_requests.len(), 0); - assert_eq!(managed.sent_contact_requests.len(), 0); - assert_eq!(managed.established_contacts.len(), 1); - assert!(managed.established_contacts.contains_key(&contact_id)); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 0); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 0); + assert_eq!(managed.dashpay.established_contacts.len(), 1); + assert!(managed + .dashpay + .established_contacts + .contains_key(&contact_id)); } #[test] @@ -285,11 +350,13 @@ mod tests { 100000, 1234567890, ); - managed.add_sent_contact_request(outgoing_request, &p); + managed + .add_sent_contact_request(outgoing_request, &p) + .expect("setup persists"); // Verify it's in sent requests - assert_eq!(managed.sent_contact_requests.len(), 1); - assert_eq!(managed.established_contacts.len(), 0); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 1); + assert_eq!(managed.dashpay.established_contacts.len(), 0); // Now add an incoming request from the same contact - should auto-establish let incoming_request = ContactRequest::new( @@ -302,13 +369,18 @@ mod tests { 100000, 1234567891, ); - managed.add_incoming_contact_request(incoming_request, &p); + managed + .add_incoming_contact_request(incoming_request, &p) + .expect("setup persists"); // Verify contact was established - assert_eq!(managed.incoming_contact_requests.len(), 0); - assert_eq!(managed.sent_contact_requests.len(), 0); - assert_eq!(managed.established_contacts.len(), 1); - assert!(managed.established_contacts.contains_key(&contact_id)); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 0); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 0); + assert_eq!(managed.dashpay.established_contacts.len(), 1); + assert!(managed + .dashpay + .established_contacts + .contains_key(&contact_id)); } #[test] @@ -331,11 +403,13 @@ mod tests { 100000, 1234567890, ); - managed.add_sent_contact_request(outgoing_request, &p); + managed + .add_sent_contact_request(outgoing_request, &p) + .expect("setup persists"); // Verify it stays in sent requests - assert_eq!(managed.sent_contact_requests.len(), 1); - assert_eq!(managed.established_contacts.len(), 0); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 1); + assert_eq!(managed.dashpay.established_contacts.len(), 0); // Add an incoming request from a different contact let other_contact_id = Identifier::from([3u8; 32]); @@ -349,12 +423,14 @@ mod tests { 100000, 1234567891, ); - managed.add_incoming_contact_request(incoming_request, &p); + managed + .add_incoming_contact_request(incoming_request, &p) + .expect("setup persists"); // Verify both requests stay separate - assert_eq!(managed.sent_contact_requests.len(), 1); - assert_eq!(managed.incoming_contact_requests.len(), 1); - assert_eq!(managed.established_contacts.len(), 0); + assert_eq!(managed.dashpay.sent_contact_requests.len(), 1); + assert_eq!(managed.dashpay.incoming_contact_requests.len(), 1); + assert_eq!(managed.dashpay.established_contacts.len(), 0); } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/tests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/tests.rs deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs index 4e430588bb2..ba47d0c67a1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs @@ -65,6 +65,22 @@ impl IdentityManager { out } + /// Iterate every managed identity (both buckets) as `&ManagedIdentity`. + /// + /// Unlike [`Self::all_identities`] (`&Identity` only) and + /// [`Self::identity_ids`] (ids only), this exposes the full per-identity + /// state — including the in-memory-only `pending_contact_crypto` queue — so + /// a caller can snapshot/aggregate it across identities without a second + /// lookup. Order is unspecified. Mirrors the identity set of + /// [`Self::all_identities`] exactly. + pub fn managed_identities(&self) -> impl Iterator { + self.out_of_wallet_identities.values().chain( + self.wallet_identities + .values() + .flat_map(|inner| inner.values()), + ) + } + /// Backwards-compatible name used by FFI / external callers — same /// as [`Self::managed_identity`]. pub fn managed_identity(&self, identity_id: &Identifier) -> Option<&ManagedIdentity> { 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..35c95eca81d 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 @@ -53,7 +53,7 @@ impl IdentityManager { existing.last_updated_balance_block_time = entry.last_updated_balance_block_time; existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; existing.status = entry.status; - existing.dashpay_profile = entry.dashpay_profile; + *existing.dashpay_profile_mut() = entry.dashpay_profile; for name in entry.dpns_names { if !existing.dpns_names.iter().any(|n| n.label == name.label) { existing.dpns_names.push(name); @@ -64,7 +64,17 @@ impl IdentityManager { existing.contested_dpns_names.push(label); } } - existing.dashpay_payments.extend(entry.dashpay_payments); + existing + .dashpay_payments_mut() + .extend(entry.dashpay_payments); + existing + .dashpay_contact_profiles_mut() + .extend(entry.contact_profiles); + // Ignored senders: union (un-ignore is carried by an explicit + // `ContactChangeSet::unignored` removal, applied separately). + for sender in entry.ignored_senders { + existing.apply_ignored_sender(sender); + } return; } @@ -105,8 +115,14 @@ impl IdentityManager { managed.wallet_id = Some(wallet_id); managed.dpns_names = entry.dpns_names; managed.contested_dpns_names = entry.contested_dpns_names; - managed.dashpay_profile = entry.dashpay_profile; - managed.dashpay_payments = entry.dashpay_payments; + *managed.dashpay_profile_mut() = entry.dashpay_profile; + *managed.dashpay_payments_mut() = entry.dashpay_payments; + *managed.dashpay_contact_profiles_mut() = entry.contact_profiles; + // Fresh-constructed identity: the ignored set starts empty, + // so per-element apply reproduces the wholesale assign. + for sender in entry.ignored_senders { + managed.apply_ignored_sender(sender); + } self.wallet_identities .entry(wallet_id) @@ -131,8 +147,14 @@ impl IdentityManager { // initialized it that way. managed.dpns_names = entry.dpns_names; managed.contested_dpns_names = entry.contested_dpns_names; - managed.dashpay_profile = entry.dashpay_profile; - managed.dashpay_payments = entry.dashpay_payments; + *managed.dashpay_profile_mut() = entry.dashpay_profile; + *managed.dashpay_payments_mut() = entry.dashpay_payments; + *managed.dashpay_contact_profiles_mut() = entry.contact_profiles; + // Fresh-constructed identity: the ignored set starts empty, + // so per-element apply reproduces the wholesale assign. + for sender in entry.ignored_senders { + managed.apply_ignored_sender(sender); + } 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/state/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs index fc81314856c..affa70d0156 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs @@ -8,7 +8,7 @@ pub mod managed_identity; pub mod manager; -pub use managed_identity::{BlockTime, ManagedIdentity}; +pub use managed_identity::{BlockTime, DashPayState, ManagedIdentity}; pub use manager::IdentityLocation; pub use manager::IdentityManager; pub use manager::RegistrationIndex; 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..62c74084b8a 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,61 @@ 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, + + /// The contact's decrypted DIP-15 `encryptedAccountLabel` — the + /// human-readable label the contact attached to the receiving account + /// they shared (a payment-routing hint, e.g. "Main wallet"). Derived + /// during the external-account build by decrypting + /// **`incoming_request`**'s `encrypted_account_label` with the ECDH + /// shared key — never the outgoing request, which carries a label *we* + /// chose. `None` when the contact sent no label or it could not be + /// decrypted to printable text. + /// + /// Cosmetic: a decrypt failure never breaks the payment channel. Reset + /// to `None` on (re-)establish and on rotation so it is always + /// re-derived from the current incoming request rather than going + /// stale against new key material. + #[cfg_attr(feature = "serde", serde(default))] + pub contact_account_label: Option, + + /// The `incoming_request.account_reference` the currently-registered + /// `DashpayExternalAccount` (our outbound sending account for this + /// contact) was built from, or `None` when no external account is known + /// to be built for the current reference. + /// + /// Load-bearing for **rotation self-heal across restart**. When the + /// contact rotates their receiving xpub the sweep tears down the stale + /// external account and re-registers from the new xpub — but if the + /// rebuild is deferred (no signer) and the app restarts before it drains, + /// the persisted account-registration row (an upsert with no tombstone) + /// rebuilds the STALE xpub while `incoming_request` carries the new + /// reference. The account-build sweep would then skip the contact (the + /// account exists) forever, sending to addresses the contact no longer + /// watches. Comparing this marker against `incoming_request.account_reference` + /// lets the sweep detect a stale account and tear it down + rebuild. + /// + /// Reset to `None` on (re-)establish and on rotation (the account must be + /// rebuilt from the new key material). A cold restart that does not + /// restore this field leaves it `None`, which conservatively forces one + /// rebuild on the next sweep — self-healing either way. + #[cfg_attr(feature = "serde", serde(default))] + pub external_account_reference: Option, } impl EstablishedContact { @@ -49,6 +104,52 @@ impl EstablishedContact { note: None, is_hidden: false, accepted_accounts: Vec::new(), + payment_channel_broken: false, + contact_account_label: None, + external_account_reference: None, + } + } + + /// (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, + // The label is a property of the (possibly new) incoming + // request, not user metadata — drop it so it is re-derived + // from the fresh request rather than carried over stale. + contact_account_label: None, + // The external account must be rebuilt from the (possibly new) + // incoming request, so the marker resets — the sweep re-registers + // and re-stamps it. + external_account_reference: None, } } @@ -138,6 +239,54 @@ 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; + existing.contact_account_label = Some("Stale label".to_string()); + + // 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); + // The label is re-derived from the fresh incoming request, not + // carried over (it is a property of the request, not user metadata). + assert_eq!(reestablished.contact_account_label, None); } #[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 71fdae34c21..e64cb41d6cb 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -448,6 +448,11 @@ impl PlatformWallet { asset_locks: Arc::clone(&asset_locks), persister: wallet_persister.clone(), broadcaster: dashpay_broadcaster, + // DashPay write helper: forwards to the live SDK, erasing its + // generic write signatures behind concrete by-value methods. + 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/shielded/activity.rs b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs index fd6f08b739b..b0ec8179d56 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/activity.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/activity.rs @@ -184,10 +184,7 @@ impl ShieldedActivityEntry { /// only perturbs the sort tiebreak, never the `block_height`-first /// ordering, so a non-monotonic read is harmless here. pub fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) + crate::util::now_ms() } } diff --git a/packages/rs-platform-wallet/tests/contact_workflow_tests.rs b/packages/rs-platform-wallet/tests/contact_workflow_tests.rs index dd989e195d4..aef3d0fbc6b 100644 --- a/packages/rs-platform-wallet/tests/contact_workflow_tests.rs +++ b/packages/rs-platform-wallet/tests/contact_workflow_tests.rs @@ -85,41 +85,63 @@ fn test_send_and_accept_contact_request_same_wallet() { // Identity A sends friend request to Identity B let request_a_to_b = create_contact_request(id_a, id_b, 0, 1234567890); - managed_a.add_sent_contact_request(request_a_to_b.clone(), &noop_persister()); + managed_a + .add_sent_contact_request(request_a_to_b.clone(), &noop_persister()) + .expect("setup persists"); // Verify request is pending - assert_eq!(managed_a.sent_contact_requests.len(), 1); - assert_eq!(managed_a.established_contacts.len(), 0); + assert_eq!(managed_a.dashpay().sent_contact_requests().len(), 1); + assert_eq!(managed_a.dashpay().established_contacts().len(), 0); // Identity B receives the request - managed_b.add_incoming_contact_request(request_a_to_b, &noop_persister()); + managed_b + .add_incoming_contact_request(request_a_to_b, &noop_persister()) + .expect("setup persists"); // Verify B has incoming request - assert_eq!(managed_b.incoming_contact_requests.len(), 1); - assert_eq!(managed_b.established_contacts.len(), 0); + assert_eq!(managed_b.dashpay().incoming_contact_requests().len(), 1); + assert_eq!(managed_b.dashpay().established_contacts().len(), 0); // Identity B sends friend request back to Identity A let request_b_to_a = create_contact_request(id_b, id_a, 0, 1234567891); - managed_b.add_sent_contact_request(request_b_to_a.clone(), &noop_persister()); + managed_b + .add_sent_contact_request(request_b_to_a.clone(), &noop_persister()) + .expect("setup persists"); // This should auto-establish on B's side - assert_eq!(managed_b.sent_contact_requests.len(), 0); - assert_eq!(managed_b.incoming_contact_requests.len(), 0); - assert_eq!(managed_b.established_contacts.len(), 1); - assert!(managed_b.established_contacts.contains_key(&id_a)); + assert_eq!(managed_b.dashpay().sent_contact_requests().len(), 0); + assert_eq!(managed_b.dashpay().incoming_contact_requests().len(), 0); + assert_eq!(managed_b.dashpay().established_contacts().len(), 1); + assert!(managed_b + .dashpay() + .established_contacts() + .contains_key(&id_a)); // Identity A receives B's request - managed_a.add_incoming_contact_request(request_b_to_a, &noop_persister()); + managed_a + .add_incoming_contact_request(request_b_to_a, &noop_persister()) + .expect("setup persists"); // This should auto-establish on A's side - assert_eq!(managed_a.sent_contact_requests.len(), 0); - assert_eq!(managed_a.incoming_contact_requests.len(), 0); - assert_eq!(managed_a.established_contacts.len(), 1); - assert!(managed_a.established_contacts.contains_key(&id_b)); + assert_eq!(managed_a.dashpay().sent_contact_requests().len(), 0); + assert_eq!(managed_a.dashpay().incoming_contact_requests().len(), 0); + assert_eq!(managed_a.dashpay().established_contacts().len(), 1); + assert!(managed_a + .dashpay() + .established_contacts() + .contains_key(&id_b)); // Both should have established contacts now - let contact_a = managed_a.established_contacts.get(&id_b).unwrap(); - let contact_b = managed_b.established_contacts.get(&id_a).unwrap(); + let contact_a = managed_a + .dashpay() + .established_contacts() + .get(&id_b) + .unwrap(); + let contact_b = managed_b + .dashpay() + .established_contacts() + .get(&id_a) + .unwrap(); assert_eq!(contact_a.contact_identity_id, id_b); assert_eq!(contact_b.contact_identity_id, id_a); @@ -141,31 +163,45 @@ fn test_send_and_accept_contact_request_different_wallets() { // Identity 1 sends friend request to Identity 2 let request_1_to_2 = create_contact_request(id_1, id_2, 0, 1234567900); - managed_1.add_sent_contact_request(request_1_to_2.clone(), &noop_persister()); + managed_1 + .add_sent_contact_request(request_1_to_2.clone(), &noop_persister()) + .expect("setup persists"); // Identity 2 receives the request - managed_2.add_incoming_contact_request(request_1_to_2, &noop_persister()); + managed_2 + .add_incoming_contact_request(request_1_to_2, &noop_persister()) + .expect("setup persists"); // Verify states before reciprocation - assert_eq!(managed_1.sent_contact_requests.len(), 1); - assert_eq!(managed_2.incoming_contact_requests.len(), 1); + assert_eq!(managed_1.dashpay().sent_contact_requests().len(), 1); + assert_eq!(managed_2.dashpay().incoming_contact_requests().len(), 1); // Identity 2 sends friend request back let request_2_to_1 = create_contact_request(id_2, id_1, 0, 1234567901); - managed_2.add_sent_contact_request(request_2_to_1.clone(), &noop_persister()); + managed_2 + .add_sent_contact_request(request_2_to_1.clone(), &noop_persister()) + .expect("setup persists"); // Should auto-establish on identity 2's side - assert_eq!(managed_2.established_contacts.len(), 1); + assert_eq!(managed_2.dashpay().established_contacts().len(), 1); // Identity 1 receives the reciprocal request - managed_1.add_incoming_contact_request(request_2_to_1, &noop_persister()); + managed_1 + .add_incoming_contact_request(request_2_to_1, &noop_persister()) + .expect("setup persists"); // Should auto-establish on identity 1's side - assert_eq!(managed_1.established_contacts.len(), 1); + assert_eq!(managed_1.dashpay().established_contacts().len(), 1); // Verify both have the friendship established - assert!(managed_1.established_contacts.contains_key(&id_2)); - assert!(managed_2.established_contacts.contains_key(&id_1)); + assert!(managed_1 + .dashpay() + .established_contacts() + .contains_key(&id_2)); + assert!(managed_2 + .dashpay() + .established_contacts() + .contains_key(&id_1)); } #[test] @@ -186,49 +222,61 @@ fn test_multiple_contact_requests_workflow() { let mut managed_main = ManagedIdentity::new(identity_main, 0); // Send requests to three different identities - managed_main.add_sent_contact_request( - create_contact_request(id_main, id_friend1, 0, 1000), - &noop_persister(), - ); - managed_main.add_sent_contact_request( - create_contact_request(id_main, id_friend2, 0, 2000), - &noop_persister(), - ); - managed_main.add_sent_contact_request( - create_contact_request(id_main, id_friend3, 0, 3000), - &noop_persister(), - ); - - assert_eq!(managed_main.sent_contact_requests.len(), 3); + managed_main + .add_sent_contact_request( + create_contact_request(id_main, id_friend1, 0, 1000), + &noop_persister(), + ) + .expect("setup persists"); + managed_main + .add_sent_contact_request( + create_contact_request(id_main, id_friend2, 0, 2000), + &noop_persister(), + ) + .expect("setup persists"); + managed_main + .add_sent_contact_request( + create_contact_request(id_main, id_friend3, 0, 3000), + &noop_persister(), + ) + .expect("setup persists"); + + assert_eq!(managed_main.dashpay().sent_contact_requests().len(), 3); // Receive incoming request from friend1 (should auto-establish) - managed_main.add_incoming_contact_request( - create_contact_request(id_friend1, id_main, 0, 1001), - &noop_persister(), - ); + managed_main + .add_incoming_contact_request( + create_contact_request(id_friend1, id_main, 0, 1001), + &noop_persister(), + ) + .expect("setup persists"); - assert_eq!(managed_main.sent_contact_requests.len(), 2); // friend2 and friend3 left - assert_eq!(managed_main.established_contacts.len(), 1); // friend1 established + assert_eq!(managed_main.dashpay().sent_contact_requests().len(), 2); // friend2 and friend3 left + assert_eq!(managed_main.dashpay().established_contacts().len(), 1); // friend1 established // Receive incoming request from friend2 (should auto-establish) - managed_main.add_incoming_contact_request( - create_contact_request(id_friend2, id_main, 0, 2001), - &noop_persister(), - ); + managed_main + .add_incoming_contact_request( + create_contact_request(id_friend2, id_main, 0, 2001), + &noop_persister(), + ) + .expect("setup persists"); - assert_eq!(managed_main.sent_contact_requests.len(), 1); // only friend3 left - assert_eq!(managed_main.established_contacts.len(), 2); // friend1 and friend2 established + assert_eq!(managed_main.dashpay().sent_contact_requests().len(), 1); // only friend3 left + assert_eq!(managed_main.dashpay().established_contacts().len(), 2); // friend1 and friend2 established // Receive incoming from unknown identity (should stay in incoming) let id_stranger = Identifier::from([99u8; 32]); - managed_main.add_incoming_contact_request( - create_contact_request(id_stranger, id_main, 0, 9000), - &noop_persister(), - ); - - assert_eq!(managed_main.incoming_contact_requests.len(), 1); - assert_eq!(managed_main.sent_contact_requests.len(), 1); - assert_eq!(managed_main.established_contacts.len(), 2); + managed_main + .add_incoming_contact_request( + create_contact_request(id_stranger, id_main, 0, 9000), + &noop_persister(), + ) + .expect("setup persists"); + + assert_eq!(managed_main.dashpay().incoming_contact_requests().len(), 1); + assert_eq!(managed_main.dashpay().sent_contact_requests().len(), 1); + assert_eq!(managed_main.dashpay().established_contacts().len(), 2); } #[test] @@ -247,14 +295,18 @@ fn test_contact_alias_and_metadata() { 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()); + managed_a + .add_sent_contact_request(request_a_to_b, &noop_persister()) + .expect("setup persists"); + managed_a + .add_incoming_contact_request(request_b_to_a, &noop_persister()) + .expect("setup persists"); // Contact should be established - assert_eq!(managed_a.established_contacts.len(), 1); + assert_eq!(managed_a.dashpay().established_contacts().len(), 1); // Get mutable reference to contact and modify metadata - let contact = managed_a.established_contacts.get_mut(&id_b).unwrap(); + let contact = managed_a.established_contact_mut(&id_b).unwrap(); // Set alias contact.set_alias("Best Friend".to_string()); @@ -294,17 +346,19 @@ fn test_reject_contact_request() { let mut managed_a = ManagedIdentity::new(identity_a, 0); // Receive incoming request - managed_a.add_incoming_contact_request( - create_contact_request(id_b, id_a, 0, 1000), - &noop_persister(), - ); + managed_a + .add_incoming_contact_request( + create_contact_request(id_b, id_a, 0, 1000), + &noop_persister(), + ) + .expect("setup persists"); - assert_eq!(managed_a.incoming_contact_requests.len(), 1); + assert_eq!(managed_a.dashpay().incoming_contact_requests().len(), 1); // Reject by removing the request let (removed, _cs) = managed_a.remove_incoming_contact_request(&id_b); assert!(removed.is_some()); - assert_eq!(managed_a.incoming_contact_requests.len(), 0); + assert_eq!(managed_a.dashpay().incoming_contact_requests().len(), 0); } #[test] @@ -320,17 +374,19 @@ fn test_cancel_sent_contact_request() { let mut managed_a = ManagedIdentity::new(identity_a, 0); // Send request - managed_a.add_sent_contact_request( - create_contact_request(id_a, id_b, 0, 1000), - &noop_persister(), - ); + managed_a + .add_sent_contact_request( + create_contact_request(id_a, id_b, 0, 1000), + &noop_persister(), + ) + .expect("setup persists"); - assert_eq!(managed_a.sent_contact_requests.len(), 1); + assert_eq!(managed_a.dashpay().sent_contact_requests().len(), 1); // Cancel by removing the request let (removed, _cs) = managed_a.remove_sent_contact_request(&id_b); assert!(removed.is_some()); - assert_eq!(managed_a.sent_contact_requests.len(), 0); + assert_eq!(managed_a.dashpay().sent_contact_requests().len(), 0); } #[test] @@ -349,17 +405,25 @@ fn test_contact_request_with_different_account_references() { // Send request with account reference 0 let mut request_a_to_b = create_contact_request(id_a, id_b, 0, 1000); request_a_to_b.account_reference = 0; - managed_a.add_sent_contact_request(request_a_to_b.clone(), &noop_persister()); + managed_a + .add_sent_contact_request(request_a_to_b.clone(), &noop_persister()) + .expect("setup persists"); // Receive reciprocal request with account reference 1 let mut request_b_to_a = create_contact_request(id_b, id_a, 1, 1001); request_b_to_a.account_reference = 1; - managed_a.add_incoming_contact_request(request_b_to_a, &noop_persister()); + managed_a + .add_incoming_contact_request(request_b_to_a, &noop_persister()) + .expect("setup persists"); // Should establish contact - assert_eq!(managed_a.established_contacts.len(), 1); + assert_eq!(managed_a.dashpay().established_contacts().len(), 1); - let contact = managed_a.established_contacts.get(&id_b).unwrap(); + let contact = managed_a + .dashpay() + .established_contacts() + .get(&id_b) + .unwrap(); assert_eq!(contact.outgoing_request.account_reference, 0); assert_eq!(contact.incoming_request.account_reference, 1); } @@ -387,20 +451,129 @@ fn test_concurrent_bidirectional_requests() { 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.clone(), &noop_persister()); - managed_b.add_sent_contact_request(request_b_to_a.clone(), &noop_persister()); + managed_a + .add_sent_contact_request(request_a_to_b.clone(), &noop_persister()) + .expect("setup persists"); + managed_b + .add_sent_contact_request(request_b_to_a.clone(), &noop_persister()) + .expect("setup persists"); // Both have sent requests pending - assert_eq!(managed_a.sent_contact_requests.len(), 1); - assert_eq!(managed_b.sent_contact_requests.len(), 1); + assert_eq!(managed_a.dashpay().sent_contact_requests().len(), 1); + assert_eq!(managed_b.dashpay().sent_contact_requests().len(), 1); // Now they receive each other's requests - managed_a.add_incoming_contact_request(request_b_to_a, &noop_persister()); - managed_b.add_incoming_contact_request(request_a_to_b, &noop_persister()); + managed_a + .add_incoming_contact_request(request_b_to_a, &noop_persister()) + .expect("setup persists"); + managed_b + .add_incoming_contact_request(request_a_to_b, &noop_persister()) + .expect("setup persists"); // Both should have auto-established - assert_eq!(managed_a.established_contacts.len(), 1); - assert_eq!(managed_b.established_contacts.len(), 1); - assert_eq!(managed_a.sent_contact_requests.len(), 0); - assert_eq!(managed_b.sent_contact_requests.len(), 0); + assert_eq!(managed_a.dashpay().established_contacts().len(), 1); + assert_eq!(managed_b.dashpay().established_contacts().len(), 1); + assert_eq!(managed_a.dashpay().sent_contact_requests().len(), 0); + assert_eq!(managed_b.dashpay().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()) + .expect("setup persists"); + managed_a + .add_incoming_contact_request(request_b_to_a, &noop_persister()) + .expect("setup persists"); + assert_eq!(managed_a.dashpay().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_contact_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()) + .expect("rotation persists in test"); + + assert!(rekeyed, "an established contact must report re-keying"); + let contact = managed_a + .dashpay() + .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()) + .expect("setup persists"); + let rotated_pending = create_contact_request(id_c, id_a, 4, 3001); + let rekeyed = managed_a + .apply_rotated_incoming_request(rotated_pending, &noop_persister()) + .expect("rotation persists in test"); + assert!( + !rekeyed, + "pending (non-established) rotation is not a re-key" + ); + assert_eq!( + managed_a + .dashpay() + .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()) + .expect("rotation persists in test")); + assert!(!managed_a + .dashpay() + .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 623272b8534..c8f16cc187c 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -50,11 +50,14 @@ //! - **`Zeroizing` wrappers** scrub the plain byte buffers that carry //! no `Drop` of their own: the resolver mnemonic buffer, the BIP-39 //! seed, and the final derived 32-byte scalar. -//! - **Explicit `non_secure_erase` calls** scrub the raw +//! - **The `WipingSecretKey` RAII guard** scrubs the raw //! [`secp256k1::SecretKey`] copies at the two sign sites, where the -//! scalar comes back out of `SecretKey::from_slice`. `SecretKey` has -//! no `Zeroize` impl (only `non_secure_erase()`), so it can't ride a -//! `Zeroizing` wrapper. +//! scalar comes back out of `SecretKey::from_slice`. `SecretKey` is an +//! upstream secp256k1 type with no `Zeroize` impl (only +//! `non_secure_erase()`), so it can't ride a `Zeroizing` wrapper; the +//! guard wipes it on every exit path — normal return, `?`-early-return, +//! and panic-unwind — closing the one leak window a bare inline erase +//! would leave open between construction and the scrub. //! //! Combined, no private key bytes survive past the trait-method //! boundary. @@ -63,7 +66,7 @@ use std::ffi::c_void; use std::os::raw::c_char; use async_trait::async_trait; -use key_wallet::bip32::{DerivationPath, ExtendedPrivKey, ExtendedPubKey}; +use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::secp256k1::{self, Secp256k1}; use key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; use key_wallet::Network; @@ -334,6 +337,207 @@ impl MnemonicResolverCoreSigner { Zeroizing::new(derived.private_key.secret_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 + /// borrowed `ExtendedPrivKey` never leaves [`Self::resolve_and_derive`] + /// and self-wipes on `Drop`. + /// + /// 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> { + // Read the scalar by reference inside the closure; the borrowed + // `ExtendedPrivKey` self-wipes on `Drop` when `resolve_and_derive` + // returns. Only the ECDH product crosses the boundary. + let shared = self.resolve_and_derive(path, |derived| { + platform_encryption::derive_shared_key_ecdh(&derived.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 derived + /// scalar is `Zeroizing`-scrubbed and the intermediate `ExtendedPrivKey` + /// self-wipes on `Drop`), 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 secret = self.derive_priv(path)?; + 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 secret = self.derive_priv(path)?; + 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 intermediate + /// `ExtendedPrivKey` self-wipes on `Drop`; 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}")) + })?, + ]); + self.derive_priv(&path) + } + + /// 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 wipes a [`secp256k1::SecretKey`]'s scalar on `Drop`. +/// +/// `SecretKey` is an upstream secp256k1 type with no `Zeroize` impl (only +/// `non_secure_erase()`), so it can't ride a `Zeroizing` wrapper. Wrapping the +/// `SecretKey::from_slice` copy here wipes it on every exit path — normal +/// return, `?`-early-return, and panic-unwind — closing the leak window a bare +/// inline `non_secure_erase()` would leave open between construction and the +/// manual scrub. This is the one key intermediate that upstream key-wallet's +/// `Zeroize`/`Drop` on `ExtendedPrivKey` cannot cover. +struct WipingSecretKey(secp256k1::SecretKey); + +impl Drop for WipingSecretKey { + fn drop(&mut self) { + self.0.non_secure_erase(); + } } #[async_trait] @@ -355,29 +559,29 @@ impl Signer for MnemonicResolverCoreSigner { 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()))?; + // legitimate field element. The `WipingSecretKey` guard scrubs this + // separate copy on every exit path, including a panic between here and + // the return — `Zeroizing<[u8;32]>` already covers `secret_bytes`. + 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 this `from_slice` copy on every exit path, + // including panic-unwind — `Zeroizing<[u8;32]>` covers `secret_bytes`. + 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) } } @@ -474,6 +678,45 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + /// The derivation-path scope-gate is the ONLY thing stopping + /// `export_auto_accept_private_key` from being a general raw-key + /// exfiltration primitive — it must hand back a scalar ONLY on a DIP-15 + /// auto-accept path (`m/9'/coin'/16'/expiry'`) and reject every other path. + #[test] + fn export_auto_accept_private_key_gates_to_the_auto_accept_path() { + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + // A well-formed auto-accept path exports its 32-byte scalar. + let auto_accept = DerivationPath::from_str("m/9'/1'/16'/123'").expect("valid path"); + let scalar = signer + .export_auto_accept_private_key(&auto_accept) + .expect("a well-formed auto-accept path exports its scalar"); + assert_ne!(*scalar, [0u8; 32], "exported scalar must be non-zero"); + + // Every non-auto-accept path MUST be rejected — otherwise a caller + // could exfiltrate an identity-auth or contactInfo signing key. + for bad in [ + "m/9'/1'/5'/0'/0'/0'/0'", // identity-auth (feature 5', wrong length) + "m/8'/1'/16'/0'", // wrong purpose (comps[0] != 9') + "m/9'/1'/15'/0'", // wrong feature (comps[2] != 16') + "m/9'/1'/16'", // too short (len != 4) + "m/9'/1'/16'/0'/0'", // too long (len != 4) + ] { + let path = DerivationPath::from_str(bad).expect("valid path string"); + assert!( + matches!( + signer.export_auto_accept_private_key(&path), + Err(MnemonicResolverSignerError::DerivationFailed(_)) + ), + "non-auto-accept path {bad} must be rejected, not exported" + ); + } + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + #[tokio::test] async fn public_key_matches_sign_ecdsa_pubkey() { let resolver = make_resolver(english_resolve); @@ -495,6 +738,30 @@ 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) }; + } + #[tokio::test] async fn extended_public_key_matches_independent_derivation() { let resolver = make_resolver(english_resolve); @@ -540,6 +807,255 @@ mod tests { 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..d1f59bc9cab 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,26 @@ 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. +/// +/// This is the single source of truth for the recipient-key cohort membership +/// policy. The pre-send validator (`rs-platform-wallet` `validate_contact_request`) +/// and the recipient-key selector (`select_recipient_key_index`) both defer to +/// it so the accepted cohort cannot drift between the SDK and wallet layers. +pub fn recipient_key_purpose_is_valid(purpose: Purpose) -> bool { + matches!(purpose, Purpose::DECRYPTION | Purpose::ENCRYPTION) +} + impl Sdk { /// Create a contact request document /// @@ -147,7 +174,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 +238,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 +288,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 +393,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 +414,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 +462,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 +486,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 +530,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 +577,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..c17490261d3 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs @@ -1,133 +1,239 @@ //! 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 pages +/// (retrieve-all, up to the per-sweep budget); this only bounds how many +/// documents move per round trip. 100 is the platform document-query maximum. +const CONTACT_REQUEST_PAGE_SIZE: u32 = 100; + +/// Per-sweep page budget. `contactRequest` documents are public and freely +/// indexable by `toUserId`, so a hostile sender can flood a target with cheap +/// throwaway requests; without a cap, every cold-start / restore sweep would +/// fetch and hold the entire spam set in memory at once. The fetch is +/// `$createdAt`-ascending and the caller's high-water cursor resumes from the +/// max `$createdAt` fetched, so capping pages spreads a large backlog across +/// sweeps oldest-first — nothing is buried or skipped, only deferred. 50 × 100 +/// = 5_000 documents per sweep, far above any legitimate pending-request count +/// (and a legit user above it just takes an extra sweep to fully ingest). +/// +/// Forward progress assumes no single `$createdAt` value holds ≥ this budget of +/// matching documents. `$createdAt` is block-granular (every doc in a block +/// shares the block time), so a same-`$createdAt` cluster is bounded by one +/// block's transaction capacity — far below 5_000 fee-paid, signed +/// `contactRequest`s. If that ever ceased to hold, the timestamp cursor could +/// not advance past such a cluster (it would re-read the same oldest 5_000 each +/// sweep); the fix would be a persisted `StartAfter` document-id continuation +/// cursor rather than the `$createdAt` high-water. +/// +/// The wallet caller widens that single-cluster case into a time *window*: it +/// rewinds each sweep's lower `$createdAt` bound by a 10-minute overlap +/// (`SYNC_OVERLAP_MS`) for clock-skew / page-boundary safety. So an attacker +/// who concentrates ≥ this budget of `contactRequest`s within any 10-minute +/// span targeting one recipient (≥5_000 funded `(ownerId, toUserId)` pairs — +/// costly but reachable at scale) keeps the high-water pinned at the window's +/// max `$createdAt`, and the next sweep's rewind lands back inside the same +/// window — the same non-advancing cursor, with a wider trigger. The memory +/// bound still holds (oldest-first, budget-capped); only forward progress past +/// a fully saturated window stalls, and the same `StartAfter` document-id +/// continuation cursor is the recovery. +const MAX_CONTACT_REQUEST_PAGES_PER_SWEEP: u32 = 50; + 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 `contactRequest` documents 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 **or** the per-sweep page + /// budget ([`MAX_CONTACT_REQUEST_PAGES_PER_SWEEP`]) is hit. /// - /// * `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` ⇒ the fetch completed without error and the caller may advance its + /// high-water mark to the max `$createdAt` fetched; a page error + /// short-circuits as `Err` so the caller does not advance. The result may + /// be a budgeted PARTIAL (oldest-first) under a `toUserId` flood — the + /// high-water cursor resumes the remainder on the next sweep. + 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, - }; - - // Fetch the documents - Document::fetch_many(self, query).await + 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; + let mut pages_fetched: u32 = 0; + + 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); + } + pages_fetched += 1; + + // Stop on a short page (exhaustion) or the per-sweep page budget. A + // budget stop on a still-full page is logged: `all` holds the oldest + // requests (`$createdAt ASC`), the caller advances its high-water + // cursor to the max fetched, and the next sweep resumes from here — + // the backlog drains oldest-first across sweeps, never buried. + if !should_fetch_another_contact_request_page(page_len, pages_fetched) { + if page_len >= CONTACT_REQUEST_PAGE_SIZE as usize { + tracing::warn!( + filter_field, + documents = all.len(), + pages = pages_fetched, + "contact-request sweep hit the per-sweep page budget; \ + resuming the remainder next sweep" + ); + } + 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?; + self.fetch_contact_requests_paginated("$ownerId", identity_id, after_created_at) + .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, - }; - - // 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?)) } } + +/// Whether the contact-request pagination loop should fetch another page: +/// only when the last page was full (more may remain) AND the per-sweep page +/// budget has not been reached. A short page (exhaustion) stops first and +/// takes priority over the budget. +fn should_fetch_another_contact_request_page(last_page_len: usize, pages_fetched: u32) -> bool { + last_page_len >= CONTACT_REQUEST_PAGE_SIZE as usize + && pages_fetched < MAX_CONTACT_REQUEST_PAGES_PER_SWEEP +} + +#[cfg(test)] +mod tests { + use super::{ + should_fetch_another_contact_request_page, CONTACT_REQUEST_PAGE_SIZE, + MAX_CONTACT_REQUEST_PAGES_PER_SWEEP, + }; + + #[test] + fn pagination_continues_until_budget_then_stops() { + let full = CONTACT_REQUEST_PAGE_SIZE as usize; + // Full page, still under budget → keep draining. + assert!(should_fetch_another_contact_request_page(full, 1)); + assert!(should_fetch_another_contact_request_page( + full, + MAX_CONTACT_REQUEST_PAGES_PER_SWEEP - 1 + )); + // Full page, budget reached → stop (the high-water cursor resumes the + // remainder next sweep; a spam flood can't force an unbounded fetch). + assert!(!should_fetch_another_contact_request_page( + full, + MAX_CONTACT_REQUEST_PAGES_PER_SWEEP + )); + // A short page is exhaustion — stop regardless of how few pages ran. + assert!(!should_fetch_another_contact_request_page(full - 1, 1)); + assert!(!should_fetch_another_contact_request_page(0, 1)); + } +} diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 9a73096838e..ce482872996 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -7,8 +7,8 @@ mod contact_request; mod contact_request_queries; pub use contact_request::{ - ContactRequestInput, ContactRequestResult, EcdhProvider, RecipientIdentity, - SendContactRequestInput, SendContactRequestResult, + recipient_key_purpose_is_valid, ContactRequestInput, ContactRequestResult, EcdhProvider, + RecipientIdentity, SendContactRequestInput, SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; @@ -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/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 80c4c7cb70b..b34aea3512f 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -10,6 +10,7 @@ use dpp::data_contract::document_type::DocumentType; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION}; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; +use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::StateTransition; @@ -68,50 +69,65 @@ impl> PutDocument for Document { .await?; let settings = settings.unwrap_or_default(); - let transition = if self.revision().is_some() - && self.revision().unwrap() != INITIAL_REVISION - { - BatchTransition::new_document_replacement_transition_from_document( - self.clone(), - document_type.as_ref(), - &identity_public_key, - new_identity_contract_nonce, - settings.user_fee_increase.unwrap_or_default(), - token_payment_info, - signer, - sdk.version(), - settings.state_transition_creation_options, - ) - .await? - } else { - let (document, document_state_transition_entropy) = document_state_transition_entropy - .map(|entropy| (self.clone(), entropy)) - .unwrap_or_else(|| { - let mut rng = StdRng::from_entropy(); - let mut document = self.clone(); - let entropy = rng.gen::<[u8; 32]>(); - document.set_id(Document::generate_document_id_v0( - &document_type.data_contract_id(), - &document.owner_id(), - document_type.name(), - entropy.as_slice(), - )); - (document, entropy) - }); - BatchTransition::new_document_creation_transition_from_document( - document, - document_type.as_ref(), - document_state_transition_entropy, - &identity_public_key, - new_identity_contract_nonce, - settings.user_fee_increase.unwrap_or_default(), - token_payment_info, - signer, - sdk.version(), - settings.state_transition_creation_options, - ) - .await? - }; + let transition = + if self.revision().is_some() && self.revision().unwrap() != INITIAL_REVISION { + BatchTransition::new_document_replacement_transition_from_document( + self.clone(), + document_type.as_ref(), + &identity_public_key, + new_identity_contract_nonce, + settings.user_fee_increase.unwrap_or_default(), + token_payment_info, + signer, + sdk.version(), + settings.state_transition_creation_options, + ) + .await? + } else { + let (document, document_state_transition_entropy) = + match document_state_transition_entropy { + Some(entropy) => { + // A caller-supplied entropy must derive the document's own id. + // Platform consensus recomputes generate_document_id_v0 from the + // transition entropy and rejects the create with + // InvalidDocumentTransitionIdError on mismatch, so guard here + // before broadcasting to fail locally (no wasted nonce/fee). + ensure_entropy_matches_document_id( + &document_type.data_contract_id(), + &self.owner_id(), + document_type.name(), + &entropy, + self.id(), + )?; + (self.clone(), entropy) + } + None => { + let mut rng = StdRng::from_entropy(); + let mut document = self.clone(); + let entropy = rng.gen::<[u8; 32]>(); + document.set_id(Document::generate_document_id_v0( + &document_type.data_contract_id(), + &document.owner_id(), + document_type.name(), + entropy.as_slice(), + )); + (document, entropy) + } + }; + BatchTransition::new_document_creation_transition_from_document( + document, + document_type.as_ref(), + document_state_transition_entropy, + &identity_public_key, + new_identity_contract_nonce, + settings.user_fee_increase.unwrap_or_default(), + token_payment_info, + signer, + sdk.version(), + settings.state_transition_creation_options, + ) + .await? + }; ensure_valid_state_transition_structure(&transition, sdk.version())?; // response is empty for a broadcast, result comes from the stream wait for state transition result @@ -144,3 +160,96 @@ impl> PutDocument for Document { Self::wait_for_response(sdk, state_transition, settings).await } } + +/// Ensures a caller-supplied `entropy` derives the same document id already set +/// on a create document. +/// +/// A document-create state transition carries both the document id and the +/// entropy, and Drive recomputes the id from the entropy during +/// `advanced_structure` validation, rejecting the transition with +/// `InvalidDocumentTransitionIdError` when they disagree. Because +/// [`PutDocument::put_to_platform`] trusts the caller's id verbatim in the +/// `Some(entropy)` arm, a two-phase caller whose id and entropy have drifted +/// would only discover the mismatch after paying (a bumped identity-contract +/// nonce). This check surfaces the mismatch locally before broadcasting. +fn ensure_entropy_matches_document_id( + contract_id: &Identifier, + owner_id: &Identifier, + document_type_name: &str, + entropy: &[u8; 32], + document_id: Identifier, +) -> Result<(), Error> { + let expected_id = Document::generate_document_id_v0( + contract_id, + owner_id, + document_type_name, + entropy.as_slice(), + ); + if expected_id != document_id { + return Err(Error::Generic(format!( + "document id {document_id} does not match the id {expected_id} derived from the \ + supplied entropy; the entropy must be the one used to generate the document id" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn contract_id() -> Identifier { + Identifier::from([1u8; 32]) + } + + fn owner_id() -> Identifier { + Identifier::from([2u8; 32]) + } + + #[test] + fn matching_entropy_and_id_pass() { + let entropy = [7u8; 32]; + let id = Document::generate_document_id_v0( + &contract_id(), + &owner_id(), + "contactRequest", + entropy.as_slice(), + ); + + ensure_entropy_matches_document_id( + &contract_id(), + &owner_id(), + "contactRequest", + &entropy, + id, + ) + .expect("id derived from the supplied entropy must be accepted"); + } + + #[test] + fn mismatched_entropy_and_id_error_before_broadcast() { + // The id was derived from E1, but the caller passes E2 != E1 (mirroring + // the very drift consensus rejects with InvalidDocumentTransitionIdError). + let entropy_used = [1u8; 32]; + let id = Document::generate_document_id_v0( + &contract_id(), + &owner_id(), + "contactRequest", + entropy_used.as_slice(), + ); + + let different_entropy = [2u8; 32]; + let result = ensure_entropy_matches_document_id( + &contract_id(), + &owner_id(), + "contactRequest", + &different_entropy, + id, + ); + + assert!( + matches!(result, Err(Error::Generic(_))), + "a document id derived from a different entropy must be rejected locally" + ); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift index a7a6d34ed4c..88f4bef7765 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift @@ -333,6 +333,24 @@ public final class KeychainSigner: Signer, @unchecked Sendable { return captured } + /// Whether the mnemonic resolver derive-signs an identity `keyType`. + /// + /// Preflight-only. The sign trampoline itself does NOT consult this — it + /// attempts the resolver unconditionally and lets Rust decide via the + /// `UNSUPPORTED_KEY_TYPE` tag. But `canSign` has no data to sign and no + /// data-free way to ask Rust the same question, so it approximates the + /// resolver's supported set here to stay consistent with sign-time routing: + /// a breadcrumb-only row (no stored scalar) is only reported signable when + /// its type is one the resolver handles. + /// + /// Delegates the supported-set decision to the resolver's own FFI + /// predicate `dash_sdk_resolver_supports_key_type`, so this preflight + /// answer can never drift from the sign path's `UNSUPPORTED_KEY_TYPE` + /// rejection — both read the one Rust source of truth. + static func resolverCanDeriveSign(keyType: UInt8) -> Bool { + dash_sdk_resolver_supports_key_type(keyType) + } + /// True iff this signer can produce a signature for the /// supplied `(publicKey, keyType)` pair. Mirrors the dispatch in /// [`signOnDemand`]. @@ -342,7 +360,7 @@ public final class KeychainSigner: Signer, @unchecked Sendable { /// per-wallet mnemonic Keychain item exist — the two inputs the /// derive-and-sign FFI requires. We do NOT actually derive a key /// here; the check is purely "are the prerequisites in place". - fileprivate func canSign(publicKey: Data, keyType: UInt8) -> Bool { + func canSign(publicKey: Data, keyType: UInt8) -> Bool { if keyType == Self.platformAddressHashKeyType { // Resolve the address row first (synchronous lookup); // mnemonic check is gated on having the wallet id. @@ -369,11 +387,31 @@ public final class KeychainSigner: Signer, @unchecked Sendable { row.publicKeyData == publicKey } ) - if let row = try? context.fetch(descriptor).first, - row.privateKeyKeychainIdentifier != nil - { - found = true - return + if let row = try? context.fetch(descriptor).first { + // Stored scalar present (legacy / not-yet-backfilled key). + if row.privateKeyKeychainIdentifier != nil { + found = true + return + } + // Resolver-derivable: a breadcrumb plus a readable mnemonic are + // the two inputs `signIdentityKeyOnDemand` needs to derive-sign. + // Match its `wid.count == 32` precondition so this preflight + // doesn't report a corrupt-walletId row as signable. The key + // type must also be one the resolver actually derive-signs — a + // breadcrumb-only row (no stored scalar) whose type the resolver + // rejects would pass preflight but fail at sign time with + // `publicKeyNotFound` (the sign path routes an unsupported type + // to the — here absent — stored scalar). + if Self.resolverCanDeriveSign(keyType: keyType), + let wid = row.walletId, + wid.count == 32, + let path = row.identityDerivationPath, + !path.isEmpty, + WalletStorage().hasMnemonic(for: wid) + { + found = true + return + } } // Mirror `lookupIdentityPrivateKey`'s fallback: pre- // registration the SwiftData row may not exist yet but @@ -534,6 +572,10 @@ public final class KeychainSigner: Signer, @unchecked Sendable { UInt(dataRaw.count), ecdsaSecp256k1KeyType, self.network.ffiValue, + // Address keys are bound by their own DIP-17 + // derivation; no extra pubkey-binding needed. + nil, + 0, bufPtr.baseAddress, UInt(bufPtr.count), &sigLen, @@ -562,6 +604,108 @@ public final class KeychainSigner: Signer, @unchecked Sendable { return .success(signature) } + /// SwiftData lookup: identity public-key bytes → + /// `(walletId, identityDerivationPath)` breadcrumb. `nil` when the row + /// is absent or carries no breadcrumb yet (an un-backfilled key) — the + /// caller then falls back to the stored scalar. Pinned to the serial + /// queue + a per-call `ModelContext`, like the platform-address resolver. + func resolveIdentityKeyContext( + publicKey: Data + ) -> (walletId: Data, derivationPath: String)? { + var resolved: (walletId: Data, derivationPath: String)? + queue.sync { + let context = ModelContext(self.modelContainer) + let descriptor = FetchDescriptor( + predicate: #Predicate { row in + row.publicKeyData == publicKey + } + ) + guard let row = try? context.fetch(descriptor).first, + let wid = row.walletId, + let path = row.identityDerivationPath, + !path.isEmpty, + wid.count == 32 + else { + return + } + resolved = (wid, path) + } + return resolved + } + + /// One-shot derive-and-sign for an identity key (`keyType < 5`), the + /// derive-sign-destroy counterpart of [`signPlatformAddressOnDemand`]. + /// Resolves the key's `(walletId, derivationPath)` breadcrumb and signs + /// via the resolver, passing the on-chain key bytes as the binding so the + /// FFI rejects (before signing) if the key derived at the path doesn't + /// reproduce this exact key. + /// + /// Returns `nil` when the key carries no breadcrumb yet — the trampoline + /// then falls back to the stored scalar so an un-backfilled key still + /// signs. A `.failure` means a breadcrumb was present but the resolver + /// couldn't sign (mnemonic missing, binding mismatch); the trampoline + /// logs it and still falls back to the verified stored scalar. + func signIdentityKeyOnDemand( + publicKey: Data, + keyType: UInt8, + data: Data + ) -> Result? { + guard let ctx = resolveIdentityKeyContext(publicKey: publicKey) else { + return nil + } + + var sigBuf = [UInt8](repeating: 0, count: 128) + var sigLen: UInt = 0 + var errTag: UInt8 = 0 + defer { + sigBuf.withUnsafeMutableBufferPointer { ptr in + if let base = ptr.baseAddress { + memset_s(UnsafeMutableRawPointer(base), ptr.count, 0, ptr.count) + } + } + } + + let rc = ctx.walletId.withUnsafeBytes { walletBytes -> Int32 in + let walletPtr = walletBytes.bindMemory(to: UInt8.self).baseAddress + return ctx.derivationPath.withCString { pPtr -> Int32 in + return data.withUnsafeBytes { dataRaw -> Int32 in + return publicKey.withUnsafeBytes { expRaw -> Int32 in + return sigBuf.withUnsafeMutableBufferPointer { bufPtr -> Int32 in + let dataBase = dataRaw.bindMemory(to: UInt8.self).baseAddress + let expBase = expRaw.bindMemory(to: UInt8.self).baseAddress + return dash_sdk_sign_with_mnemonic_resolver_and_path( + self.mnemonicResolver.handle, + walletPtr, + pPtr, + dataBase, + UInt(dataRaw.count), + keyType, + self.network.ffiValue, + expBase, + UInt(expRaw.count), + bufPtr.baseAddress, + UInt(bufPtr.count), + &sigLen, + &errTag + ) + } + } + } + } + } + + guard rc == 0 else { + if errTag == SignWithMnemonicResolverError.resolverNotFound.rawValue { + let walletHex = ctx.walletId.map { String(format: "%02x", $0) }.joined() + return .failure(.mnemonicMissing(walletIdHex: walletHex)) + } + return .failure(.signWithMnemonicFailed(tag: errTag)) + } + + let signature = Data(sigBuf.prefix(Int(sigLen))) + return .success(signature) + } + /// v1 sign primitive. Wraps the raw 32-byte ECDSA scalar in a /// throwaway FFI signer just long enough to produce a signature; /// the keychain bytes are zeroed from the local copy as soon as @@ -637,24 +781,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) } @@ -715,8 +841,9 @@ private func keychainSignerSignAsyncTrampoline( // Dispatch on key_type. Platform-address signing (`0xFF`) is a // single-call derive-and-sign path — no separate key lookup, // because the derived bytes never come back to Swift. Identity - // signing (`< 5`) keeps the original two-step `lookup → ffiSign` - // path because the identity key really does live in the Keychain. + // signing (`< 5`) prefers the same derive-sign-destroy path (from + // the key's stored breadcrumb) and falls back to the stored scalar + // when a key has no breadcrumb yet. if keyType == KeychainSigner.platformAddressHashKeyType { switch signer.signPlatformAddressOnDemand( addressHash: pubkeyData, @@ -731,6 +858,38 @@ private func keychainSignerSignAsyncTrampoline( return } + // Identity signing (`keyType < 5`): derive-sign-destroy via the resolver + // when the key carries a derivation breadcrumb; otherwise fall back to the + // stored scalar. The fallback keeps already-materialized keys — and any not + // yet backfilled — signable, so the cutover is non-lockout by construction. + // Every fallback is logged so the zero-fallback acceptance gate can catch + // un-migrated rows or resolver failures before the stored scalar is removed. + // + // Rust owns the supported-key-type decision: we attempt the resolver for + // any identity key and treat its `UNSUPPORTED_KEY_TYPE` tag as the routing + // signal (fall through to the stored scalar silently, no fallback log — + // the resolver simply doesn't handle this type). This avoids mirroring the + // Rust ECDSA-only set in Swift, so a future Rust-derivable key type is + // automatically routed through the resolver without a matching Swift edit. + switch signer.signIdentityKeyOnDemand( + publicKey: pubkeyData, + keyType: keyType, + data: dataToSign + ) { + case .success(let sig)?: + reportSuccess(sig) + return + case .failure(.signWithMnemonicFailed(let tag))? + where tag == SignWithMnemonicResolverError.unsupportedKeyType.rawValue: + // Rust does not derive-sign this key type — route to the stored + // scalar with no spurious fallback log. + break + case .failure(let err)?: + print("⚠️ IDENTITY_SIGN_FALLBACK resolver-failed: \(err.localizedDescription)") + case nil: + print("⚠️ IDENTITY_SIGN_FALLBACK no-breadcrumb") + } + let privateKey: Data switch signer.lookupIdentityPrivateKey(publicKey: pubkeyData) { case .failure(let err): 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..3106c69cc04 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift @@ -0,0 +1,178 @@ +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. +/// A present profile (`ContactProfileRowFFI.is_present == true`) upserts +/// this row; a confirmed-absent entry (`is_present == false`) DELETEs it, +/// so a contact who removed their on-chain profile can't leave a stale +/// name/avatar behind. Read back at load to rebuild the Rust +/// `contact_profiles` map (present entries only — the negative cache +/// re-derives on the first sweep) so the cache survives relaunch instead +/// of refetching every contact. +/// +/// 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..6d5bf0c4453 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift @@ -94,6 +94,53 @@ 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 + + /// The contact's decrypted DIP-15 `encryptedAccountLabel` — the label + /// the contact chose for the account they shared (a payment-routing + /// hint, e.g. "Main wallet"). **System-derived and read-only**, unlike + /// the owner-private `contactAlias`/`contactNote`: it is decrypted in + /// Rust from the contact's incoming request, so it is populated only on + /// the incoming-direction row (the outgoing row carries a label *we* + /// sent, which is not surfaced). Optional so existing rows ride the + /// lightweight migration. + public var contactAccountLabel: String? + + /// `EstablishedContact::accepted_accounts` — the DIP-15 + /// rotated-account acceptances for this relationship. Mirrors + /// `ContactRequestFFI::accepted_accounts`: a property of the + /// relationship (not one direction), so it is replicated onto + /// both directions like `paymentChannelBroken`; always empty for + /// pending rows. Defaulted to an empty array so existing rows + /// ride SwiftData's lightweight migration. + public var contactAcceptedAccounts: [UInt32] = [] + // MARK: - Relationships /// Owning identity — the wallet-managed identity this row's @@ -120,7 +167,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 +183,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/Persistence/Models/PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift index f30cd83dd34..5ed049b04fb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift @@ -37,6 +37,23 @@ public final class PersistentPublicKey { // MARK: - Private Key Reference (optional) public var privateKeyKeychainIdentifier: String? + // MARK: - Derivation breadcrumb (derive-sign-destroy) + /// 32-byte wallet id that owns this identity key, denormalized from the + /// discovery breadcrumb. Paired with `identityDerivationPath`, it lets the + /// signer derive this key on demand from the Keychain-held seed instead of + /// reading a stored scalar. `nil` for rows persisted before this column + /// existed and for keys with no wallet association; such rows fall back to + /// the stored scalar until the backfill populates them. Additive optional + /// column => SwiftData lightweight migration. + public var walletId: Data? + + /// Full DIP-9 identity-authentication path + /// `m/9'/coin'/5'/0'/ECDSA'/identityIndex'/keyIndex'` the signer feeds to + /// the mnemonic resolver to derive this key's private scalar at sign time. + /// The authoritative breadcrumb; `nil` until written on persist or + /// backfilled from the key's Keychain metadata. + public var identityDerivationPath: String? + // MARK: - Metadata public var identityId: String public var createdAt: 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..182e49b380c 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,65 @@ public final class ManagedIdentity: @unchecked Sendable { guard hasProfile else { return nil } return DashPayProfile(ffi: ffiProfile) } + + /// Live in-memory DashPay sync state — collection counts + the high-water + /// sync cursors. The cursors are not persisted (they reset on cold restart), + /// so this live read is the only window onto them. + public struct DashPaySyncState: Sendable { + public let establishedContacts: UInt32 + public let incomingRequests: UInt32 + public let sentRequests: UInt32 + public let ignoredSenders: UInt32 + public let contactProfiles: UInt32 + public let presentContactProfiles: UInt32 + public let dashpayPayments: UInt32 + public let hasDashPayProfile: Bool + /// `nil` when the cursor hasn't advanced yet (no sweep has fetched). + public let highWaterReceivedMs: UInt64? + public let highWaterSentMs: UInt64? + } + + /// Read the live DashPay sync state for this managed identity. All scalars, + /// so nothing to free. + public func getDashPaySyncState() throws -> DashPaySyncState { + var ffi = DashPaySyncStateFFI() + try managed_identity_get_dashpay_sync_state(handle, &ffi).check() + return DashPaySyncState( + establishedContacts: ffi.established_contacts, + incomingRequests: ffi.incoming_requests, + sentRequests: ffi.sent_requests, + ignoredSenders: ffi.ignored_senders, + contactProfiles: ffi.contact_profiles, + presentContactProfiles: ffi.present_contact_profiles, + dashpayPayments: ffi.dashpay_payments, + hasDashPayProfile: ffi.has_dashpay_profile, + highWaterReceivedMs: ffi.has_high_water_received ? ffi.high_water_received_ms : nil, + highWaterSentMs: ffi.has_high_water_sent ? ffi.high_water_sent_ms : nil + ) + } + + // 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 +1655,132 @@ 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 `ownerIdentityId`, valid for 1 hour. The QR's `du` is the owner's DPNS + /// name; pass the locally-cached `username` when known, or an empty string to + /// have Rust resolve it on-chain (needed for imported/restored identities + /// whose name isn't cached locally). The returned URI is rendered as a QR; a + /// scanner sends a contact request the owner auto-accepts. All + /// derivation/encoding/resolution happens Rust-side. + public func buildAutoAcceptQR( + ownerIdentityId: Identifier, + username: String + ) async throws -> String { + let handle = self.handle + let coreSigner = MnemonicResolver() + let ownerBytes: [UInt8] = ownerIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { () -> String in + var outURI: UnsafeMutablePointer? + let result: PlatformWalletFFIResult = withExtendedLifetime(coreSigner) { + ownerBytes.withUnsafeBufferPointer { ownerBp in + username.withCString { uPtr in + platform_wallet_build_auto_accept_qr( + handle, + ownerBp.baseAddress!, + 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 +1801,71 @@ 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 { + 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_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 { @@ -1742,7 +1880,7 @@ extension ManagedPlatformWallet { let result = ourBytes.withUnsafeBufferPointer { ourBp -> PlatformWalletFFIResult in contactBytes.withUnsafeBufferPointer { contactBp in - platform_wallet_reject_contact_request( + platform_wallet_unignore_contact_sender( handle, ourBp.baseAddress!, contactBp.baseAddress! @@ -1807,6 +1945,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 +1961,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 +2025,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. /// @@ -1954,48 +2157,54 @@ extension ManagedPlatformWallet { let avatarBytes = update.avatarBytes return try await Task.detached(priority: .userInitiated) { () -> DashPayProfile in - _ = signer var outProfile = DashPayProfileFFI() - let result: PlatformWalletFFIResult = idBytes.withUnsafeBufferPointer { - idBp -> PlatformWalletFFIResult in - let idPtr = idBp.baseAddress! - return invokeWithOptionalCStrings( - displayName, - publicMessage, - avatarUrl - ) { namePtr, msgPtr, urlPtr -> PlatformWalletFFIResult in - let bytes = avatarBytes ?? Data() - if let avatarBytes, !avatarBytes.isEmpty { - return avatarBytes.withUnsafeBytes { rawBuf -> PlatformWalletFFIResult in - let bytesPtr = rawBuf.baseAddress?.assumingMemoryBound(to: UInt8.self) + // Pin the KeychainSigner across the whole synchronous FFI call: + // Rust holds a `passUnretained` ctx pointer to it via `signerHandle`, + // so a bare `_ = signer` keepalive can be released under -O before the + // call returns. `withExtendedLifetime` keeps ARC holding it for the + // call's duration (matches the other *_with_signer wrappers). + let result: PlatformWalletFFIResult = withExtendedLifetime(signer) { + idBytes.withUnsafeBufferPointer { + idBp -> PlatformWalletFFIResult in + let idPtr = idBp.baseAddress! + return invokeWithOptionalCStrings( + displayName, + publicMessage, + avatarUrl + ) { namePtr, msgPtr, urlPtr -> PlatformWalletFFIResult in + let bytes = avatarBytes ?? Data() + if let avatarBytes, !avatarBytes.isEmpty { + return avatarBytes.withUnsafeBytes { rawBuf -> PlatformWalletFFIResult in + let bytesPtr = rawBuf.baseAddress?.assumingMemoryBound(to: UInt8.self) + return platform_wallet_create_or_update_dashpay_profile_with_signer( + handle, + idPtr, + namePtr, + msgPtr, + urlPtr, + bytesPtr, + UInt(avatarBytes.count), + doCreate, + signerHandle, + &outProfile + ) + } + } else { + _ = bytes return platform_wallet_create_or_update_dashpay_profile_with_signer( handle, idPtr, namePtr, msgPtr, urlPtr, - bytesPtr, - UInt(avatarBytes.count), + nil, + 0, doCreate, signerHandle, &outProfile ) } - } else { - _ = bytes - return platform_wallet_create_or_update_dashpay_profile_with_signer( - handle, - idPtr, - namePtr, - msgPtr, - urlPtr, - nil, - 0, - doCreate, - signerHandle, - &outProfile - ) } } } @@ -2007,6 +2216,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/PlatformWalletFFI.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift index 4983772625e..a34efb2bf70 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletFFI.swift @@ -89,6 +89,10 @@ enum SignWithMnemonicResolverError: UInt8 { case unsupportedKeyType = 8 case resolverNotFound = 9 case resolverFailed = 10 + /// The key derived at the path did not reproduce the caller-supplied + /// expected key — the signature was withheld (mirrors the Rust + /// `SIGN_WITH_RESOLVER_ERR_PUBKEY_MISMATCH`). + case pubkeyMismatch = 11 } // MARK: - 32-byte tuple helpers diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 1a48986f2ab..54154fe81f6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -17,6 +17,34 @@ final class SyncGenerationCounter: @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: @@ -59,6 +87,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? @@ -129,6 +169,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? @@ -141,6 +189,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? @@ -165,6 +220,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() } } @@ -233,6 +289,8 @@ public class PlatformWalletManager: ObservableObject { self.handle = handle self.persistenceHandler = handler self.eventHandler = eventHandler + self.modelContainer = modelContainer + self.signerNetwork = network self.isConfigured = true startProgressPolling() @@ -251,12 +309,21 @@ public class PlatformWalletManager: ObservableObject { /// property. If `name` is provided, writes it onto the persisted /// [`PersistentWallet`] row so the wallet detail view has a /// user-facing label. + /// + /// `birthHeight` controls the SPV historical-scan window. Pass `nil` for a + /// freshly **generated** mnemonic — the scan starts at the current chain tip + /// (nothing was funded before now). Pass `0` when **importing/restoring an + /// existing** mnemonic, so the wallet scans from genesis and sees funds + /// (including DashPay payments) received before this device knew the wallet; + /// without it, history — and the coreHeight rescan backfill — is clamped to + /// the tip. `Some(h)` pins a known funding height. @discardableResult public func createWallet( mnemonic: String, network: Network, name: String? = nil, - createDefaultAccounts: Bool = true + createDefaultAccounts: Bool = true, + birthHeight: UInt32? = nil ) throws -> ManagedPlatformWallet { try ensureConfigured() var walletHandle: Handle = NULL_HANDLE @@ -266,11 +333,13 @@ public class PlatformWalletManager: ObservableObject { let accountOptions: UInt32 = createDefaultAccounts ? 1 : 0 try mnemonic.withCString { mnemonicPtr in - try platform_wallet_manager_create_wallet_from_mnemonic( + try platform_wallet_manager_create_wallet_from_mnemonic_with_birth_height( handle, mnemonicPtr, network.ffiValue, accountOptions, + birthHeight != nil, + birthHeight ?? 0, &walletHandle, &walletId ).check() @@ -286,12 +355,17 @@ public class PlatformWalletManager: ObservableObject { } /// Create a wallet from raw 64-byte seed bytes. + /// + /// See `createWallet(mnemonic:...)` for the `birthHeight` semantics: `nil` + /// scans from the current tip (fresh wallet), `0` scans from genesis + /// (imported/restored wallet that may have prior on-chain history). @discardableResult public func createWallet( seed: Data, network: Network, name: String? = nil, - createDefaultAccounts: Bool = true + createDefaultAccounts: Bool = true, + birthHeight: UInt32? = nil ) throws -> ManagedPlatformWallet { try ensureConfigured() guard seed.count == 64 else { @@ -307,12 +381,14 @@ public class PlatformWalletManager: ObservableObject { let accountOptions: UInt32 = createDefaultAccounts ? 1 : 0 try seed.withUnsafeBytes { seedPtr in - try platform_wallet_manager_create_wallet_from_seed( + try platform_wallet_manager_create_wallet_from_seed_with_birth_height( handle, network.ffiValue, seedPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), UInt(seed.count), accountOptions, + birthHeight != nil, + birthHeight ?? 0, &walletHandle, &walletId ).check() @@ -333,15 +409,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 @@ -385,6 +466,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 @@ -409,6 +528,172 @@ 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 + } + + // Heal pre-breadcrumb identity keys so they sign via the resolver + // (derive-sign-destroy) rather than the stored scalar. Idempotent and + // Keychain-sourced; runs once the seed is confirmed present for this + // wallet, which is exactly when its identity keys become signable. + // Fire-and-forget off the main actor — signing falls back to the stored + // scalar until this heals, so it never needs to block unlock. + persistenceHandler?.scheduleBackfillIdentityKeyBreadcrumbs(walletId: walletId) + + // 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 } + }() + // Hop to the main actor via a direct call, not a `MainActor.run` + // closure: capturing the task-isolated `self` into a main-actor + // closure is what Swift 6 region isolation rejects. `self` is + // `@MainActor` (Sendable) and the locally-built `drainError` / + // `drained` are region-transferable, so the call is race-free. + await self?.finishContactCryptoDrain( + walletId: walletId, drained: drained, drainError: drainError) + } + return true + } + + /// Main-actor tail of the deferred contact-crypto drain: clears the + /// `draining` flag and surfaces any failure on `lastError`. Split out of + /// the `Task.detached` body so it hops back via a direct `@MainActor` + /// call instead of capturing `self` into a `MainActor.run` closure. + @MainActor + private func finishContactCryptoDrain( + walletId: Data, drained: UInt32, drainError: Error? + ) { + setDashPayDraining(walletId, false) + if let drainError { + 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))" + ) + } + } + /// 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 @@ -628,6 +913,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) @@ -674,6 +963,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 @@ -834,10 +1156,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 698f88af39d..d5db23b600c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -7,7 +7,10 @@ import DashSDKFFI /// Allocated as a class so its pointer can be passed as the opaque `context` /// to the Rust persistence callbacks. Must be retained for the lifetime of /// the `PlatformWalletManager`. -public class PlatformWalletPersistenceHandler { +// All mutable state (`backgroundContext`, caches) is confined to `serialQueue` +// — the handler's de-facto actor — so it is safe to hand to a `@Sendable` +// closure (e.g. the off-main `serialQueue.async` backfill dispatch). +public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let modelContainer: ModelContainer /// Network this handler's owning `PlatformWalletManager` is bound @@ -46,6 +49,17 @@ public class PlatformWalletPersistenceHandler { /// atomically. private var inChangeset = false + /// Breadcrumb backfills that arrived on the serial queue while a + /// changeset round was open. The backfill both mutates + /// `backgroundContext` and saves it, so running it mid-round would + /// commit the round's staged (uncommitted) writes early and break the + /// "each Rust `store()` is one atomic transaction" invariant. Instead + /// the request is parked here and drained by `endChangeset` once the + /// round has committed/rolled back and `inChangeset` is clear — the + /// backfill still completes, just cleanly outside any open round. + /// Confined to `serialQueue` like all other mutable handler state. + private var deferredBackfills: [(walletId: Data, items: [KeychainManager.IdentityPrivateKeyMetadata])] = [] + public init(modelContainer: ModelContainer, network: Network? = nil) { self.modelContainer = modelContainer self.network = network @@ -1066,28 +1080,60 @@ public class PlatformWalletPersistenceHandler { /// kind callback, and the whole round is atomic from SwiftData's /// perspective: a crash between callbacks leaves the store in /// its pre-round state rather than half-applied. - func endChangeset(walletId: Data, success: Bool) { + /// Returns `true` iff the round's staged writes were durably committed + /// (`success && save()` succeeded). A `false` return — a per-kind failure, + /// or a `save()` that threw and was rolled back — is forwarded to Rust via + /// the C shim so `store()` reports a persistence failure instead of + /// silently advancing its in-memory state (pending queues, cleared drain + /// entries, ignored-sender deltas) against writes that never reached disk. + @discardableResult + func endChangeset(walletId: Data, success: Bool) -> Bool { onQueue { _ = walletId - defer { self.inChangeset = false } + // Clear the flag before draining deferred backfills so each one's + // save() lands cleanly outside the round; `drainDeferredBackfills` + // is guarded on `!inChangeset`, so the ordering inside this `defer` + // (clear, then drain) is load-bearing. + defer { + self.inChangeset = false + self.drainDeferredBackfills() + } if success { do { try backgroundContext.save() + return true } catch { // The context still has the pending changes on // its dirty list after a failed save; drop them so // the next round starts clean. SQLite's WAL will // only have committed data prior to this save, so - // the user-visible store is consistent. + // the user-visible store is consistent — but the + // round did NOT commit, so report failure upward. print("⚠️ endChangeset: save failed: \(error.localizedDescription)") backgroundContext.rollback() + return false } } else { backgroundContext.rollback() + return false } } } + /// Run any breadcrumb backfills that were parked while a changeset + /// round was open. Must be called on `serialQueue` with `inChangeset` + /// already cleared so each `backfillCore` mutates + saves cleanly on + /// its own. Draining after the round's own `save()`/`rollback()` keeps + /// the backfill's writes out of the round's transaction. + private func drainDeferredBackfills() { + guard !inChangeset, !deferredBackfills.isEmpty else { return } + let pending = deferredBackfills + deferredBackfills.removeAll() + for request in pending { + _ = backfillCore(walletId: request.walletId, items: request.items) + } + } + // MARK: - Identity scalar persistence /// Upsert / remove rows from `PersistentIdentity` in response to @@ -1214,6 +1260,21 @@ 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 emits a row only for + // contacts it (re)fetched this sweep — present ones upsert, + // confirmed-absent ones (`is_present == false`) delete. A + // contact simply MISSING from this flush is "no update" (not a + // delete). 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 @@ -1389,6 +1450,80 @@ 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. + /// + /// A contact NOT in this flush keeps its existing row (Rust emits a row + /// only for contacts it (re)fetched this sweep, so a missing snapshot is + /// "no update"). A contact present in the flush as a `isPresent == false` + /// tombstone is DELETED — that's a contact who removed their on-chain + /// profile, and the stale name/avatar must not survive. 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 + ) + ) + guard profile.isPresent else { + // Confirmed-absent: delete the stale row if one exists; a + // never-persisted contact is a no-op. + if let existing = try? backgroundContext.fetch(descriptor).first { + backgroundContext.delete(existing) + } + continue + } + 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 @@ -1399,16 +1534,11 @@ 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: no secret crosses the FFI. Each wallet-derivable + /// key persists its `(walletId, identityDerivationPath)` breadcrumb so the + /// signer derives it on demand from the Keychain seed (derive-sign-destroy). + /// A key already materialized by another path keeps / adopts its existing + /// `privateKeyKeychainIdentifier`; a genuinely watch-only key has neither. func persistIdentityKeys( walletId: Data, upserts: [IdentityKeyEntrySnapshot], @@ -1494,33 +1624,37 @@ public class PlatformWalletPersistenceHandler { row.contractBounds = snapshotBoundsIds row.contractBoundsDocumentTypeName = snapshotBoundsDocType - // 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. + // Private-key handling: no secret crosses the FFI. A + // wallet-derivable key whose private bytes were materialized by + // another path (e.g. identity registration writes its keychain + // items directly) adopts that existing keychain account by a + // public-key-hex lookup — no derivation, no secret loaded — so the + // legacy fast-path signer lookup and the `hasPrivateKey` marker + // still work for already-materialized keys. A genuinely watch-only + // key finds nothing and stays so. Every wallet-derivable key also + // gets its breadcrumb persisted below, so a freshly discovered key + // (no keychain item yet) signs by deriving on demand from the seed. + if entry.derivationIndices != nil, + row.privateKeyKeychainIdentifier == nil + { + if let account = KeychainManager.shared.identityPrivateKeyAccount( + publicKeyHex: entry.publicKeyData.toHexString() + ) { + row.privateKeyKeychainIdentifier = account + } + } + + // Persist the derivation breadcrumb so the signer can derive this + // key on demand from the Keychain seed (derive-sign-destroy), + // independent of whether a scalar was carried this callback. Always + // overwrite when the key is wallet-derivable so a backfilled value + // and a freshly-persisted one stay byte-identical. if let indices = entry.derivationIndices { let resolvedWalletId = entry.walletId ?? walletId - let keychainId = deriveAndStoreIdentityKey( - entry: entry, - walletId: resolvedWalletId, - indices: indices, - publicKeyHex: entry.publicKeyData.toHexString(), - publicKeyHashHex: entry.publicKeyHash.toHexString(), - identityIdBase58: identityHex - ) - row.privateKeyKeychainIdentifier = keychainId - } else { - row.privateKeyKeychainIdentifier = nil + row.walletId = resolvedWalletId + if let path = identityAuthPath(walletId: resolvedWalletId, indices: indices) { + row.identityDerivationPath = path + } } row.lastAccessed = Date() @@ -1539,10 +1673,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 } @@ -1687,6 +1820,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 @@ -1704,7 +1846,8 @@ public class PlatformWalletPersistenceHandler { walletId: Data, upserts: [ContactRequestSnapshot], removedSent: [ContactRequestRemovalSnapshot], - removedIncoming: [ContactRequestRemovalSnapshot] + removedIncoming: [ContactRequestRemovalSnapshot], + ignored: [ContactIgnoredSenderSnapshot] ) { onQueue { for entry in upserts { @@ -1720,8 +1863,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 } @@ -1754,6 +1902,12 @@ 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 + existing.contactAccountLabel = entry.contactAccountLabel + existing.contactAcceptedAccounts = entry.contactAcceptedAccounts if existing.owner !== owner { existing.owner = owner } @@ -1770,8 +1924,14 @@ 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 + row.contactAccountLabel = entry.contactAccountLabel + row.contactAcceptedAccounts = entry.contactAcceptedAccounts backgroundContext.insert(row) } } @@ -1790,6 +1950,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 @@ -1819,6 +2003,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 @@ -1835,6 +2101,21 @@ 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 + /// The contact's decrypted account label — system-derived, + /// incoming-row only (nil on outgoing / pending rows). + let contactAccountLabel: String? + /// `EstablishedContact::accepted_accounts` — DIP-15 rotated-account + /// acceptances. Established rows only (replicated onto both + /// directions); empty for pending rows. + let contactAcceptedAccounts: [UInt32] } /// Owned snapshot of a `ContactRequestRemovalFFI` row. Carries @@ -1846,138 +2127,260 @@ 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. /// - /// Idempotent per `(wallet, identity_index, key_index)` triple: - /// repeated persister callbacks for the same key overwrite - /// cleanly via `storeIdentityPrivateKey`'s delete-then-add. + /// 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. /// - /// 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( - entry: IdentityKeyEntrySnapshot, + /// 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 key derivation-path helpers + + /// Resolve the wallet's network and format the DIP-9 identity-auth path + /// for `(identityIndex, keyIndex)`. Pure string formatting (the FFI + /// formatter takes only network + indices, no mnemonic — not key + /// derivation). `nil` if the wallet row is missing or the path build + /// fails. Scoped to THIS handler's network via `walletRecordPredicate`, + /// since the same `walletId` can have a row per network. + private func identityAuthPath( walletId: Data, - indices: (identityIndex: UInt32, keyIndex: UInt32), - publicKeyHex: String, - publicKeyHashHex: String, - identityIdBase58: String + indices: (identityIndex: UInt32, keyIndex: UInt32) ) -> 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 - // 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). 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())…") + guard let persistentWallet = try? backgroundContext.fetch(walletDescriptor).first else { return nil } let network: Network = persistentWallet.network ?? .testnet + return try? KeyDerivation.getIdentityAuthenticationPath( + network: network, + identityIndex: indices.identityIndex, + keyIndex: indices.keyIndex + ) + } - // 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 - } + /// One-time, Keychain-driven, self-verifying backfill of the derivation + /// breadcrumb columns for `walletId`'s identity keys that were materialized + /// before those columns existed. For each `identity_privkey.*` item owned + /// by the wallet it matches the `PersistentPublicKey` row by public key and + /// — when the stored path is the canonical DIP-9 path for its indices (a + /// seedless self-check) — writes `(walletId, identityDerivationPath)` so the + /// key signs via the resolver instead of the stored scalar. + /// + /// Idempotent: rows that already carry a path are skipped. Keychain-sourced, + /// so it heals even after a SwiftData store rebuild. The sign-time pubkey + /// binding is the ultimate guard; this only rejects an obviously-corrupt + /// path up front. A non-zero `failed` count means some materialized key + /// could not be migrated — a signal the scalar-deletion gate must not be + /// crossed yet. + /// Fire-and-forget production entry: scans the Keychain and runs the + /// backfill on the serial queue, OFF the calling (main) thread — the + /// `@MainActor` unlock path must not block on the Keychain scan + the + /// serial-queue SwiftData work. Safe to run lazily: an un-migrated key + /// still signs via the stored-scalar fallback until this heals it. + func scheduleBackfillIdentityKeyBreadcrumbs(walletId: Data) { + let walletIdHex = walletId.toHexString() + serialQueue.async { [weak self] in + guard let self else { return } + let items = KeychainManager.shared.allIdentityPrivateKeyMetadata() + .filter { $0.walletId.caseInsensitiveCompare(walletIdHex) == .orderedSame } + _ = self.backfillCore(walletId: walletId, items: items) + } + } + + /// Testable entry point: the caller supplies the metadata items (so a unit + /// test can inject them without the real Keychain). Runs the SwiftData work + /// synchronously on the serial queue. + @discardableResult + func backfillIdentityKeyBreadcrumbs( + walletId: Data, + items: [KeychainManager.IdentityPrivateKeyMetadata] + ) -> (written: Int, skipped: Int, failed: Int) { + onQueue { backfillCore(walletId: walletId, items: items) } + } - // 4. Build the DIP-9 authentication path. The string form - // doubles as the keychain account suffix so the explorer - // can render it. - let derivationPath: String - do { - derivationPath = try KeyDerivation.getIdentityAuthenticationPath( - network: network, - identityIndex: indices.identityIndex, - keyIndex: indices.keyIndex - ) - } catch { - print("⚠️ deriveAndStoreIdentityKey: path build failed: \(error.localizedDescription)") - return nil + /// Backfill body. **Assumes it is already running on `serialQueue`** (it + /// touches `backgroundContext` directly — do not wrap in `onQueue`). + private func backfillCore( + walletId: Data, + items: [KeychainManager.IdentityPrivateKeyMetadata] + ) -> (written: Int, skipped: Int, failed: Int) { + guard !items.isEmpty else { return (0, 0, 0) } + + // A backfill that lands mid-round must NOT touch `backgroundContext`: + // its save() would flush the round's staged writes early, and even a + // save-less mutation would ride the round's own `save()`/`rollback()`. + // Park the request and let `endChangeset` replay it once the round has + // settled. Nothing is written on this call — the caller (unlock path) + // is fire-and-forget, and the deletion-gate `failed` count is only read + // from the synchronous, out-of-round entry point. + if inChangeset { + deferredBackfills.append((walletId: walletId, items: items)) + return (0, 0, 0) } - // 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) - } - } - } - guard rc == 0 else { - print("⚠️ deriveAndStoreIdentityKey: FFI derive failed (rc=\(rc))") - // Zero out any partial write before returning. - privateKey.resetBytes(in: 0..( + predicate: walletRecordPredicate(walletId: walletId) ) + let network: Network = + (try? backgroundContext.fetch(walletDescriptor).first)?.network ?? .testnet - // 7. Scrub the local copy regardless of outcome. - privateKey.resetBytes(in: 0..( + predicate: #Predicate { $0.publicKeyData == pubKeyData } + ) + guard let row = try? backgroundContext.fetch(descriptor).first else { + // No row yet (e.g. store rebuilt before discovery re-ran). + // Discovery re-materializes and writes the column itself; + // nothing for the backfill to heal here. + continue + } + if row.identityDerivationPath != nil { + skipped += 1 + continue + } + guard + let expectedPath = try? KeyDerivation.getIdentityAuthenticationPath( + network: network, + identityIndex: meta.identityIndex, + keyIndex: meta.keyIndex + ), + expectedPath == meta.derivationPath + else { + print("⚠️ backfill: path self-check failed for \(meta.publicKey.prefix(8))… — left unmigrated") + failed += 1 + continue + } + row.walletId = walletId + row.identityDerivationPath = meta.derivationPath + written += 1 + } - if account == nil { - print("⚠️ deriveAndStoreIdentityKey: keychain write failed for \(derivationPath)") + // Save only when a row actually changed; `failed`/`skipped` paths never + // mutate the context. + if written > 0 { + try? backgroundContext.save() + } + if written > 0 || failed > 0 { + print("ℹ️ backfill(\(walletId.toHexString().prefix(8))…): wrote \(written), skipped \(skipped), failed \(failed)") } - return account + return (written, skipped, failed) } // MARK: - Identity snapshot structs @@ -2013,6 +2416,45 @@ public class PlatformWalletPersistenceHandler { /// optional because every DashPay profile field but the /// implicit `$ownerId` is optional in the contract schema. let dashpayProfile: DashpayProfileSnapshot? + /// Cached **contact** profiles for this identity — one per + /// (re)fetched entry of the Rust `contact_profiles` map + /// (`IdentityEntryFFI.contact_profiles`). Distinct from + /// `dashpayProfile` (the owner's own profile): these are + /// contacts' public profiles, keyed by the contact's identity + /// id. Empty when no contact profile rode this flush. Each row is + /// applied independently: a present one upserts, an + /// `isPresent == false` tombstone deletes, and a contact simply + /// missing from the array is left intact (no update). + let contactProfiles: [ContactProfileSnapshot] + } + + /// Owned snapshot of one `ContactProfileRowFFI` — the contact's + /// identity id, the five public profile fields, and the + /// `checked_at_ms` self-heal timestamp. Decouples every contained + /// `String` / `Data` from the FFI heap so the callback can return + /// immediately and the Rust side can run its free-loop. Same + /// `*_present`-gated decode as `DashpayProfileSnapshot` plus the + /// leading `contactIdentityId` key and trailing `checkedAtMs`. + struct ContactProfileSnapshot { + let contactIdentityId: Data + /// `false` for a confirmed-absent contact (a tombstone row): the + /// persist side emits one so the upsert can DELETE the stale row for + /// a contact who removed their profile. `true` for a present profile + /// (all fields below are authoritative). + let isPresent: Bool + let displayName: String? + let bio: String? + let publicMessage: String? + let avatarUrl: String? + /// 32-byte SHA-256 of the avatar binary. `nil` when the source + /// `avatar_hash_present == false`. + let avatarHash: Data? + /// 8-byte DHash perceptual fingerprint. `nil` when the source + /// `avatar_fingerprint_present == false`. + let avatarFingerprint: Data? + /// Wall-clock ms of the last fetch attempt on the Rust side + /// (`ContactProfileEntry.checked_at_ms`). + let checkedAtMs: UInt64 } /// Owned snapshot of the `dashpay_profile_*` fields on @@ -2052,8 +2494,9 @@ public class PlatformWalletPersistenceHandler { let publicKeyHash: Data /// Owning wallet if this key is derivable from one we control. let walletId: Data? - /// DIP-9 `(identity_index, key_index)` pair. Present iff the - /// client is expected to re-derive the private key locally. + /// DIP-9 `(identity_index, key_index)` pair. Present iff the key is + /// wallet-derivable; the client derives it on demand from the Keychain + /// seed at this path when it needs to sign (no secret crosses the FFI). let derivationIndices: (identityIndex: UInt32, keyIndex: UInt32)? /// Full ContractBounds projection mirrored from Rust: /// `nil` when the key has no bounds; `.singleContract` for @@ -3108,11 +3551,22 @@ public class PlatformWalletPersistenceHandler { // saves) — acceptable for a user-initiated wipe. // // PHASE 1: delete every identity's cascade-children - // whose inverse to identity is non-optional - // (DPNS names, DashPay profile, DashPay contact - // requests). PublicKey, Document, and + // whose inverse to identity is non-optional (DPNS + // names, DashPay profile, DashPay contact profiles, + // DashPay contact requests, DashPay payments, DashPay + // ignored senders). PublicKey, Document, and // TokenBalance inverses to identity are already // Optional and don't need pre-deletion. + // + // Every one of these rows has a non-optional + // `owner: PersistentIdentity`, so omitting any of them + // makes PHASE 2's identity delete hit the SwiftData + // fatal PHASE 1 exists to avoid — aborting the wipe and + // leaving sender-controlled DashPay strings (contact + // profile display name / public message / avatar URL), + // plaintext counterparty/memo/amount/txid (payments), + // and privacy-relevant ignored-sender ids on disk after + // a user-initiated wallet wipe. for identity in identitiesToDelete { for name in Array(identity.dpnsNames) { backgroundContext.delete(name) @@ -3120,9 +3574,18 @@ public class PlatformWalletPersistenceHandler { if let profile = identity.dashpayProfile { backgroundContext.delete(profile) } + for contactProfile in Array(identity.contactProfiles) { + backgroundContext.delete(contactProfile) + } for cr in Array(identity.contactRequests) { backgroundContext.delete(cr) } + for payment in Array(identity.dashpayPayments) { + backgroundContext.delete(payment) + } + for ignored in Array(identity.dashpayIgnoredSenders) { + backgroundContext.delete(ignored) + } } try backgroundContext.save() @@ -4357,6 +4820,208 @@ public class PlatformWalletPersistenceHandler { allocation.identityKeyArrays.append((keyBuf, sortedKeys.count)) } + // DashPay contact rows — restores pending + established + // contacts (with their contactInfo metadata) into the + // Rust state at load. Without this, contacts re-derive + // from chain on the first sweep and the re-establish + // round wipes alias/note/hidden during the DIP-15 + // deferred-publish window (M3 relaunch-durability gap). + let contactRows = identity.contactRequests + if contactRows.isEmpty { + entry.contacts = nil + entry.contacts_count = 0 + } else { + let contactBuf = UnsafeMutablePointer.allocate( + capacity: contactRows.count + ) + for (c, contact) in contactRows.enumerated() { + var row = ContactRequestFFI() + copyBytes(contact.ownerIdentityId, into: &row.owner_id) + copyBytes(contact.contactIdentityId, into: &row.contact_id) + row.is_outgoing = contact.isOutgoing + row.sender_key_index = contact.senderKeyIndex + row.recipient_key_index = contact.recipientKeyIndex + row.account_reference = contact.accountReference + row.core_height_created_at = contact.coreHeightCreatedAt + row.created_at = contact.createdAtMillis + row.payment_channel_broken = contact.paymentChannelBroken + row.is_hidden = contact.contactHidden + + let payloads: [(Data?, WritableKeyPath?>, WritableKeyPath)] = [ + (contact.encryptedPublicKey, \.encrypted_public_key, \.encrypted_public_key_len), + (contact.encryptedAccountLabel, \.encrypted_account_label, \.encrypted_account_label_len), + (contact.autoAcceptProof, \.auto_accept_proof, \.auto_accept_proof_len), + ] + for (data, ptrPath, lenPath) in payloads { + if let data, !data.isEmpty { + let buf = UnsafeMutablePointer.allocate(capacity: data.count) + data.copyBytes(to: buf, count: data.count) + row[keyPath: ptrPath] = UnsafePointer(buf) + row[keyPath: lenPath] = UInt(data.count) + allocation.scalarBuffers.append((buf, data.count)) + } + } + + if let alias = contact.contactAlias, !alias.isEmpty { + row.alias = UnsafePointer(duplicateCString(alias, allocation: allocation)) + } + if let note = contact.contactNote, !note.isEmpty { + row.note = UnsafePointer(duplicateCString(note, allocation: allocation)) + } + // Direction-specific: only the incoming row stored the + // contact's label, so this is null on outgoing rows. + if let label = contact.contactAccountLabel, !label.isEmpty { + row.contact_account_label = UnsafePointer(duplicateCString(label, allocation: allocation)) + } + + // Relationship-wide (both directions carry it): feed the + // DIP-15 accepted-account acceptances back so the FFI row + // rebuild restores them instead of resetting to empty. + let accepted = contact.contactAcceptedAccounts + if !accepted.isEmpty { + let buf = UnsafeMutablePointer.allocate(capacity: accepted.count) + accepted.withUnsafeBufferPointer { src in + buf.initialize(from: src.baseAddress!, count: accepted.count) + } + row.accepted_accounts = UnsafePointer(buf) + row.accepted_accounts_len = UInt(accepted.count) + allocation.u32Buffers.append((buf, accepted.count)) + } + + contactBuf[c] = row + } + entry.contacts = UnsafePointer(contactBuf) + entry.contacts_count = UInt(contactRows.count) + allocation.contactArrays.append((contactBuf, contactRows.count)) + } + + // DashPay payment history — restores the dashpay_payments map + // at load. Without this the in-memory map starts empty and only + // Received entries are re-derived from UTXOs, so Sent entries + + // memos silently vanish on every relaunch (H1). + let paymentRows = identity.dashpayPayments + if paymentRows.isEmpty { + entry.payments = nil + entry.payments_count = 0 + } else { + let paymentBuf = UnsafeMutablePointer.allocate( + capacity: paymentRows.count + ) + for (c, payment) in paymentRows.enumerated() { + var row = PaymentRestoreEntryFFI() + row.txid = UnsafePointer(duplicateCString(payment.txid, allocation: allocation)) + copyBytes(payment.counterpartyIdentityId, into: &row.counterparty_id) + row.amount_duffs = payment.amountDuffs + row.direction_raw = payment.directionRaw + row.status_raw = payment.statusRaw + if let memo = payment.memo, !memo.isEmpty { + row.memo = UnsafePointer(duplicateCString(memo, allocation: allocation)) + } + paymentBuf[c] = row + } + entry.payments = UnsafePointer(paymentBuf) + entry.payments_count = UInt(paymentRows.count) + allocation.paymentArrays.append((paymentBuf, paymentRows.count)) + } + + // DashPay ignored senders (per-sender mute, local-only) — + // restores the ignored_senders set at load. Without this the + // set starts empty on relaunch and a previously-ignored + // sender's still-on-platform immutable contactRequests re-ingest + // on the next sweep, resurfacing the ignored sender. Each entry + // is a bare 32-byte sender id — a flat `[u8; 32]` array, no + // owned pointers; Swift allocates + frees the buffer (via + // `allocation.ignoredSenderArrays`), Rust only reads + copies. + // Drop any row with a wrong-length id BEFORE allocating (same + // abort-on-corrupt convention as the contact-profile array). + let ignoredRows = identity.dashpayIgnoredSenders.filter { + $0.ignoredSenderId.count == 32 + } + if ignoredRows.isEmpty { + entry.ignored_senders = nil + entry.ignored_senders_count = 0 + } else { + let ignoredBuf = UnsafeMutablePointer.allocate( + capacity: ignoredRows.count + ) + for (c, row) in ignoredRows.enumerated() { + var idTuple: FFIByteTuple32 = + (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) + copyBytes(row.ignoredSenderId, into: &idTuple) + ignoredBuf[c] = idTuple + } + entry.ignored_senders = UnsafePointer(ignoredBuf) + entry.ignored_senders_count = UInt(ignoredRows.count) + allocation.ignoredSenderArrays.append((ignoredBuf, ignoredRows.count)) + } + + // Cached contact profiles — restores the contact_profiles map + // (present entries only) at load. Without this the cache + // starts empty on relaunch and the requests/contacts UI shows + // raw identity ids until the next profile sweep re-fetches + // every contact. Same ownership convention as the payments + // array above: Swift allocates + frees (via + // `allocation.contactProfileArrays` in `LoadAllocation.release`); + // Rust only reads + copies out, never frees. + // Drop any row with a wrong-length contact id BEFORE allocating — + // `copyBytes` would otherwise zero-pad it and restore the profile + // under a wrong key (matching the abort-on-corrupt convention the + // UTXO restore uses). Filtering up front also keeps the fixed- + // capacity buffer fully initialized so the count stays exact. + let contactProfileRows = identity.contactProfiles.filter { + $0.contactIdentityId.count == 32 + } + if contactProfileRows.isEmpty { + entry.contact_profiles = nil + entry.contact_profiles_count = 0 + } else { + let cpBuf = UnsafeMutablePointer.allocate( + capacity: contactProfileRows.count + ) + for (c, profile) in contactProfileRows.enumerated() { + var row = ContactProfileRestoreEntryFFI() + copyBytes(profile.contactIdentityId, into: &row.contact_id) + if let displayName = profile.displayName, !displayName.isEmpty { + row.display_name = UnsafePointer( + duplicateCString(displayName, allocation: allocation)) + } + if let bio = profile.bio, !bio.isEmpty { + row.bio = UnsafePointer( + duplicateCString(bio, allocation: allocation)) + } + if let avatarUrl = profile.avatarUrl, !avatarUrl.isEmpty { + row.avatar_url = UnsafePointer( + duplicateCString(avatarUrl, allocation: allocation)) + } + if let publicMessage = profile.publicMessage, !publicMessage.isEmpty { + row.public_message = UnsafePointer( + duplicateCString(publicMessage, allocation: allocation)) + } + // Gate the byte arrays on presence — an absent hash / + // fingerprint must round-trip as `_present == false`, + // not as an all-zero value (which Rust would otherwise + // restore as a real `Some([0u8; N])`). + if let avatarHash = profile.avatarHash, avatarHash.count == 32 { + copyBytes(avatarHash, into: &row.avatar_hash) + row.avatar_hash_present = true + } else { + row.avatar_hash_present = false + } + if let avatarFingerprint = profile.avatarFingerprint, + avatarFingerprint.count == 8 { + copyBytes(avatarFingerprint, into: &row.avatar_fingerprint) + row.avatar_fingerprint_present = true + } else { + row.avatar_fingerprint_present = false + } + row.checked_at_ms = profile.checkedAtMs + cpBuf[c] = row + } + entry.contact_profiles = UnsafePointer(cpBuf) + entry.contact_profiles_count = UInt(contactProfileRows.count) + allocation.contactProfileArrays.append((cpBuf, contactProfileRows.count)) + } + buf[j] = entry } allocation.identityArrays.append((buf, identities.count)) @@ -4620,8 +5285,33 @@ private final class LoadAllocation { /// `scalarBuffers` (same `UnsafeMutablePointer.allocate` /// shape as xpub bytes). var identityKeyArrays: [(UnsafeMutablePointer, Int)] = [] + /// Per-identity `ContactRequestFFI` arrays (DashPay contact + /// restore — M3). Byte payloads live in `scalarBuffers`; the + /// alias/note strings live in `cStringBuffers`. NOTE: these rows + /// are load-allocation-owned — Rust's `free_contact_requests_ffi` + /// must never run on them (it owns only persist-side rows). + var contactArrays: [(UnsafeMutablePointer, Int)] = [] + /// Per-identity `PaymentRestoreEntryFFI` arrays (DashPay payment + /// restore — H1). The txid/memo strings live in `cStringBuffers`. + var paymentArrays: [(UnsafeMutablePointer, Int)] = [] + /// Per-identity ignored-sender arrays (DashPay ignored-sender + /// restore). Each row is a bare 32-byte sender id (`FFIByteTuple32`) — + /// flat POD, no owned pointers, so nothing extra rides + /// `scalarBuffers`/`cStringBuffers`. + var ignoredSenderArrays: [(UnsafeMutablePointer, Int)] = [] + /// Per-identity `ContactProfileRestoreEntryFFI` arrays (cached + /// contact-profile restore). The four optional profile strings each + /// row references live in `cStringBuffers`. NOTE: these rows are + /// load-allocation-owned — Rust only reads them; it must never run a + /// free over them. + var contactProfileArrays: + [(UnsafeMutablePointer, Int)] = [] /// Byte buffers backing `root_xpub_bytes` and `account_xpub_bytes`. var scalarBuffers: [(UnsafeMutablePointer, Int)] = [] + /// `u32` buffers backing `ContactRequestFFI::accepted_accounts` (the + /// DIP-15 rotated-account acceptances). Separate from `scalarBuffers` + /// because the element type differs; freed by `deallocate()`. + var u32Buffers: [(UnsafeMutablePointer, Int)] = [] /// NUL-terminated c-string buffers carried by identity entries /// (`label`, dpns name labels, etc.). Allocated via plain /// `UnsafeMutablePointer.allocate`, freed by `deallocate()`. @@ -4679,9 +5369,28 @@ private final class LoadAllocation { ptr.deinitialize(count: count) ptr.deallocate() } + for (ptr, count) in contactArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } + for (ptr, count) in paymentArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } + for (ptr, count) in ignoredSenderArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } + for (ptr, count) in contactProfileArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } for (ptr, _) in scalarBuffers { ptr.deallocate() } + for (ptr, _) in u32Buffers { + ptr.deallocate() + } for (ptr, _) in cStringBuffers { ptr.deallocate() } @@ -4910,8 +5619,11 @@ private func changesetEndCallback( .fromOpaque(context) .takeUnretainedValue() let walletId = Data(bytes: walletIdPtr, count: 32) - handler.endChangeset(walletId: walletId, success: success) - return 0 + // Forward the commit outcome: a failed/rolled-back save returns non-zero so + // Rust's `store()` reports a persistence failure (it would otherwise treat + // the round as durably committed and clear its pending state). + let committed = handler.endChangeset(walletId: walletId, success: success) + return committed ? 0 : 1 } private func persistSyncStateCallback( @@ -5178,6 +5890,43 @@ private func persistIdentitiesCallback( dashpayProfile = nil } + // Walk the cached contact-profile rows into owned snapshots. + // Rust projects a row per (re)fetched contact — present + // profiles and `is_present == false` tombstones for + // confirmed-absent ones. Each `*_present` sub-flag is checked + // individually because zero-valued payloads (empty strings, + // all-zero hashes / fingerprints) are valid contract values. + // The Rust-side `free_identity_entry_ffi` releases the row + // array + every C string after this callback returns. + var contactProfiles: + [PlatformWalletPersistenceHandler.ContactProfileSnapshot] = [] + let contactProfilesCount = Int(e.contact_profiles_count) + if contactProfilesCount > 0, let rowsPtr = e.contact_profiles { + contactProfiles.reserveCapacity(contactProfilesCount) + for j in 0..?, @@ -5446,7 +6201,9 @@ private func persistContactsCallback( removedSentPtr: UnsafePointer?, removedSentCount: UInt, removedIncomingPtr: UnsafePointer?, - removedIncomingCount: UInt + removedIncomingCount: UInt, + ignoredPtr: UnsafePointer?, + ignoredCount: UInt ) -> Int32 { guard let context = context, let walletIdPtr = walletIdPtr else { @@ -5490,6 +6247,14 @@ private func persistContactsCallback( } else { autoAcceptProof = nil } + let acceptedAccounts: [UInt32] + if let acceptedPtr = e.accepted_accounts, e.accepted_accounts_len > 0 { + acceptedAccounts = Array( + UnsafeBufferPointer(start: acceptedPtr, count: Int(e.accepted_accounts_len)) + ) + } else { + acceptedAccounts = [] + } upserts.append(.init( ownerIdentityId: dataFromTuple32(e.owner_id), @@ -5502,7 +6267,13 @@ private func persistContactsCallback( encryptedAccountLabel: encryptedAccountLabel, autoAcceptProof: autoAcceptProof, coreHeightCreatedAt: e.core_height_created_at, - createdAtMillis: e.created_at + createdAtMillis: e.created_at, + paymentChannelBroken: e.payment_channel_broken, + contactAlias: e.alias.map { String(cString: $0) }, + contactNote: e.note.map { String(cString: $0) }, + contactHidden: e.is_hidden, + contactAccountLabel: e.contact_account_label.map { String(cString: $0) }, + contactAcceptedAccounts: acceptedAccounts )) } } @@ -5531,11 +6302,25 @@ private func persistContactsCallback( } } + var ignored: [PlatformWalletPersistenceHandler.ContactIgnoredSenderSnapshot] = [] + if ignoredCount > 0, let ignoredPtr = ignoredPtr { + ignored.reserveCapacity(Int(ignoredCount)) + for i in 0.. String? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + ] + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + return nil + } + + let decoder = JSONDecoder() + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + account.hasPrefix("identity_privkey.") + else { + continue + } + guard let metadataData = item[kSecAttrGeneric as String] as? Data, + let metadata = try? decoder.decode(IdentityPrivateKeyMetadata.self, from: metadataData) + else { + continue + } + if metadata.publicKey.caseInsensitiveCompare(publicKeyHex) == .orderedSame { + return account + } + } + return nil + } + + /// Decode the metadata blob (`kSecAttrGeneric`) of every + /// `identity_privkey.*` keychain item — no secret bytes loaded. The + /// breadcrumb backfill uses this to populate + /// `PersistentPublicKey.{walletId, identityDerivationPath}` for keys + /// materialized before those columns existed; sourcing it from the + /// Keychain (not SwiftData) means it heals even if the SwiftData store + /// was rebuilt. + public nonisolated func allIdentityPrivateKeyMetadata() -> [IdentityPrivateKeyMetadata] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + ] + if let accessGroup = accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + return [] + } + + let decoder = JSONDecoder() + var out: [IdentityPrivateKeyMetadata] = [] + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + account.hasPrefix("identity_privkey.") + else { + continue + } + guard let metadataData = item[kSecAttrGeneric as String] as? Data, + let metadata = try? decoder.decode( + IdentityPrivateKeyMetadata.self, from: metadataData) + else { + continue + } + out.append(metadata) + } + return out + } + /// Delete the identity private-key row for the /// `(walletId, derivationPath)` pair — symmetric with /// `storeIdentityPrivateKey` (which writes under the diff --git a/packages/swift-sdk/SwiftExampleApp/CLAUDE.md b/packages/swift-sdk/SwiftExampleApp/CLAUDE.md index 470e8bc2562..97eba597575 100644 --- a/packages/swift-sdk/SwiftExampleApp/CLAUDE.md +++ b/packages/swift-sdk/SwiftExampleApp/CLAUDE.md @@ -6,6 +6,10 @@ This document provides guidance for AI assistants working with the SwiftExampleA SwiftExampleApp is an iOS application demonstrating the integration of both Core (SPV wallet) and Platform (identity/documents) functionality of the Dash SDK. +## Funding a testnet wallet for testing + +To get testnet funds for a wallet in the app, use the built-in faucet: **Wallet → Receive** has a "request from testnet" button that funds the displayed receive address. No external faucet or pasted seed is needed — create a fresh wallet, open Wallet → Receive, and tap it. Use this when an end-to-end test needs a funded wallet (e.g. registering an identity, signing state transitions). + ## Key Architecture Patterns ### Unified SDK Integration diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 94fe5af4cb2..fd24c73e4ff 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -4,7 +4,7 @@ import SwiftData import LocalAuthentication enum RootTab: Hashable { - case sync, wallets, identities, contracts, settings + case sync, wallets, identities, dashpay, settings } struct ContentView: View { @@ -12,16 +12,21 @@ struct ContentView: View { let bootstrapError: Error? let onRetry: () -> Void - // NOTE: `walletManager` and `appUIState` are intentionally NOT - // declared here. An `@EnvironmentObject` subscribes the entire view - // to that object's `objectWillChange`, so holding `walletManager` - // (which publishes `spvProgress` on a fast cadence during sync) on - // this root view re-rendered the whole `TabView` several times a - // second — tearing down each tab's content and any sheet/pushed - // view inside it. Both observations now live in the leaf - // `GlobalSyncIndicatorOverlay` instead, so sync ticks no longer - // invalidate `ContentView.body`. + // NOTE: `walletManager` is intentionally NOT declared here. An + // `@EnvironmentObject` subscribes the entire view to that object's + // `objectWillChange`, and `walletManager` publishes `spvProgress` on + // a fast cadence during sync — holding it on this root view + // re-rendered the whole `TabView` several times a second, tearing + // down each tab's content and any sheet/pushed view inside it. That + // observation now lives in the leaf `GlobalSyncIndicatorOverlay`, so + // sync ticks no longer invalidate `ContentView.body`. + // + // `appUIState` IS declared: it owns the root tab selection (so deep + // views like DashPayTabView / IdentityDetailView can switch tabs) + // and only publishes on low-frequency UI events (tab switch, banner + // toggle), never on sync progress. @EnvironmentObject var walletManagerStore: WalletManagerStore + @EnvironmentObject var appUIState: AppUIState @EnvironmentObject var platformState: AppState @Environment(\.modelContext) private var modelContext @@ -32,7 +37,14 @@ struct ContentView: View { /// re-derive or delete. @Query private var persistentWallets: [PersistentWallet] - @State private var selectedTab: RootTab = .sync + /// Root tab selection — owned by AppUIState so deep views (e.g. + /// IdentityDetailView's Contacts row) can switch tabs. + private var selectedTab: Binding { + Binding( + get: { appUIState.selectedTab }, + set: { appUIState.selectedTab = $0 } + ) + } // Orphan-mnemonic recovery flow. The whole batch surfaces in one // alert + one sheet now: the primary "Recover Wallets?" alert @@ -80,7 +92,7 @@ struct ContentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - TabView(selection: $selectedTab) { + TabView(selection: selectedTab) { // Tab 1: Sync Status SyncStatusView() .tabItem { @@ -103,21 +115,23 @@ struct ContentView: View { } .tag(RootTab.identities) - // Tab 4: Contracts (locally-persisted data contracts + - // their tokens). Friends moved to a per-identity drill-in - // under the DashPay section of IdentityDetailView. - // - // The current network is threaded in so the contracts + - // tokens lists stay scoped to it — `PersistentDataContract` - // rows from another network would otherwise leak into - // the picker after a network switch. - ContractsTabView(network: platformState.currentNetwork) - .tabItem { - Label("Contracts", systemImage: "doc.text") - } - .tag(RootTab.contracts) + // Tab 4: DashPay — first-class contacts / requests / + // payments surface. The root-tab + // selection binding lets its empty states + // deep-link to the Wallets / Identities tabs. + DashPayTabView( + network: platformState.currentNetwork, + selectedTab: selectedTab + ) + .accessibilityIdentifier("dashpay.tab") + .tabItem { + Label("DashPay", systemImage: "person.2.fill") + } + .tag(RootTab.dashpay) - // Tab 5: Settings (includes Platform section) + // Tab 5: Settings (Platform section + Contracts, which + // moved here from its own tab so the bar shows 5 tabs + // directly instead of collapsing into iOS's "More"). SettingsView() .tabItem { Label("Settings", systemImage: "gearshape") @@ -129,13 +143,13 @@ struct ContentView: View { // which publishes on a fast cadence while syncing. Reading // it directly in `ContentView.body` would subscribe the // whole `TabView` to every progress tick, re-creating each - // tab's content (including `ContractsTabView` and any sheet - // it presents) several times a second — which tears down a - // pushed drill-down and dismisses sheets presented from it - // (e.g. the document-create flow). Isolating the volatile - // observation in this leaf keeps the tab content stable; - // only the overlay re-renders on progress. - GlobalSyncIndicatorOverlay(isSyncTab: selectedTab == .sync) + // tab's content (and any sheet it presents) several times a + // second — which tears down a pushed drill-down and + // dismisses sheets presented from it (e.g. the + // document-create flow). Isolating the volatile observation + // in this leaf keeps the tab content stable; only the + // overlay re-renders on progress. + GlobalSyncIndicatorOverlay(isSyncTab: selectedTab.wrappedValue == .sync) } .onAppear { checkForOrphanMnemonic() } .onChange(of: persistentWallets.count) { _, _ in @@ -490,10 +504,17 @@ struct ContentView: View { } do { + // Recovery restores an existing wallet that may have prior on-chain + // history (incl. DashPay payments). Scan from the wallet's persisted + // birth height when known (avoids re-scanning years of irrelevant + // history), falling back to genesis only for wallets that predate + // that metadata — never the current tip, which would skip the + // history recovery exists to recover. let managed = try recoveryManager.createWallet( mnemonic: mnemonic, network: restoredNetwork, - name: restoredName + name: restoredName, + birthHeight: restoredBirthHeight ?? 0 ) let walletIdMatch = managed.walletId let descriptor = FetchDescriptor( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift index 766ac154551..245a6e7d1c9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -24,12 +24,22 @@ struct AccountListView: View { /// than by raw `accountType` tag so BIP44 leads, PlatformPayment /// sits next, BIP32 follows, CoinJoin after, and every special- /// purpose account tails off in tag order. + /// + /// DashPay friendship accounts (tags 12 receiving / 13 external) + /// are hidden here: they're per-contact protocol plumbing, one + /// pair per friendship, and would crowd the list as contacts + /// grow. Their funds already roll into the wallet's Core + /// Balance, and the DashPay tab surfaces the received-from- + /// contacts number; the Storage Explorer still lists the raw + /// rows for debugging. private var orderedAccounts: [PersistentAccount] { - accounts.sorted { lhs, rhs in - let lhsKey = AccountListView.sortKey(for: lhs) - let rhsKey = AccountListView.sortKey(for: rhs) - return lhsKey < rhsKey - } + accounts + .filter { $0.accountType != 12 && $0.accountType != 13 } + .sorted { lhs, rhs in + let lhsKey = AccountListView.sortKey(for: lhs) + let rhsKey = AccountListView.sortKey(for: rhs) + return lhsKey < rhsKey + } } private static func sortKey( @@ -69,7 +79,12 @@ struct AccountListView: View { var body: some View { ZStack { - if accounts.isEmpty && shieldedAccountsForThisWallet.isEmpty { + // Gate on `orderedAccounts` (the FILTERED list actually rendered), + // not the raw `accounts` query: a wallet whose only rows are + // DashPay friendship accounts (tags 12/13, hidden here) has a + // non-empty `accounts` but an empty `orderedAccounts`, which would + // otherwise show an empty Section instead of the empty state. + if orderedAccounts.isEmpty && shieldedAccountsForThisWallet.isEmpty { ContentUnavailableView( "No Accounts", systemImage: "folder", @@ -78,7 +93,7 @@ struct AccountListView: View { } else { let balances = walletManager.accountBalances(for: wallet.walletId) List { - if !accounts.isEmpty { + if !orderedAccounts.isEmpty { Section { ForEach(orderedAccounts) { account in NavigationLink( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 23797ffc69e..7d9f307f3e2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -15,6 +15,10 @@ struct CoreContentView: View { @State private var showProofDetail = false @State private var masternodesEnabled: Bool = true @State private var platformSyncExpanded: Bool = false + /// Last completed DashPay sync pass, polled from the FFI on appear + /// and refreshed whenever an in-flight pass finishes (the + /// `dashPaySyncIsSyncing` falling edge) or Sync Now completes. + @State private var dashPayLastSync: Date? // Progress values come from PlatformWalletManager (polled from FFI each second) /// All persisted platform addresses across every wallet. Summed @@ -406,7 +410,79 @@ var body: some View { Text("Platform Sync Status") } - // Section 3: ZK Shielded Sync Status + // Section 3: DashPay Sync Status — the recurring + // contact-request/profile/payment-reconcile loop + // (`DashPaySyncManager`). State mirrors the sibling + // sections: spinner while a pass is in flight, relative + // last-sync stamp after, manual Sync Now. + Section { + VStack(spacing: 8) { + HStack { + if walletManager.dashPaySyncIsSyncing { + ProgressView() + .scaleEffect(0.7) + Text("Syncing...") + .font(.subheadline) + .foregroundColor(.secondary) + } else if let lastSync = dashPayLastSync { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.caption) + Text("Last sync: \(lastSync, style: .relative)") + .font(.caption) + .foregroundColor(.secondary) + } else { + Image(systemName: "circle.dashed") + .foregroundColor(.secondary) + .font(.caption) + Text("Not synced yet") + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + if (try? walletManager.isDashPaySyncRunning()) == true { + Text("Recurring") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Stopped") + .font(.caption) + .foregroundColor(.orange) + } + } + + HStack { + Spacer() + Button { + Task { + _ = try? await walletManager.dashPaySyncNow() + refreshDashPayLastSync() + } + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + Text("Sync Now") + } + .font(.caption) + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .controlSize(.mini) + .disabled(walletManager.dashPaySyncIsSyncing) + .accessibilityIdentifier("sync.dashpay.syncNow") + } + } + .padding(.vertical, 4) + .onAppear { refreshDashPayLastSync() } + .onChange(of: walletManager.dashPaySyncIsSyncing) { _, syncing in + if !syncing { refreshDashPayLastSync() } + } + } header: { + Text("DashPay Sync Status") + } + + // Section 4: ZK Shielded Sync Status Section { VStack(spacing: 8) { // Sync state @@ -661,6 +737,13 @@ var body: some View { // MARK: - Sync Methods + /// Pull the last completed DashPay pass timestamp from the FFI. + /// `0` means "no pass has ever completed" — render as nil. + private func refreshDashPayLastSync() { + let unix = (try? walletManager.dashPayLastSyncUnixSeconds()) ?? 0 + dashPayLastSync = unix > 0 ? Date(timeIntervalSince1970: TimeInterval(unix)) : nil + } + private func toggleSync() { if isSpvRunning { pauseSync() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index 34fb48b2c46..7e025a8df3b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -355,10 +355,16 @@ struct CreateWalletView: View { for net in selectedNetworks { do { let mgr = try walletManagerStore.backgroundManager(for: net) + // An imported mnemonic may already have on-chain + // history (incl. DashPay payments) from before this + // device — scan from genesis (birthHeight 0) so it + // is seen. A freshly generated mnemonic has nothing + // before now, so scan from the tip (nil). let managed = try mgr.createWallet( mnemonic: mnemonicPhrase, network: net, - name: walletLabel + name: walletLabel, + birthHeight: showImportOption ? 0 : nil ) createdWallets.append((net, managed.walletId)) } catch { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index 1c3a88c3321..3a016017a94 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -26,6 +26,20 @@ struct TransactionListView: View { /// output as "to-self" and reports ~0 for asset locks. The /// `amountDuffs` on the asset-lock row is the actual L1 burn. @Query private var assetLocks: [PersistentAssetLock] + /// This wallet's owning identities. The DashPay payment / contact + /// join below must be scoped to these — two identities in one store + /// (A pays B) each persist a row for the same txid, and joining by + /// txid alone would let B's incoming payment resolve to A's `.sent` + /// row (or the wrong counterparty name). `ownerIdentityIds` narrows + /// the join to this wallet's identities, leaving at most one payment + /// row per txid. + @Query private var walletIdentities: [PersistentIdentity] + /// DashPay payments + the contact-name sources, to label a tx that is a + /// DashPay payment with who it's with (+ memo). Scoped to this wallet's + /// identities in the joins below. + @Query private var dashpayPayments: [PersistentDashpayPayment] + @Query private var contactProfiles: [PersistentDashpayContactProfile] + @Query private var contactRequests: [PersistentDashpayContactRequest] @State private var selectedTransaction: PersistentTransaction? init(walletId: Data) { @@ -38,6 +52,15 @@ struct TransactionListView: View { predicate: PersistentAssetLock.predicate(walletId: walletId) ) _assetLocks = Query(assetLockDescriptor) + _walletIdentities = Query( + filter: #Predicate { $0.wallet?.walletId == walletId } + ) + } + + /// The owning identity ids of this wallet — the scope for the + /// DashPay payment / contact joins below. + private var ownerIdentityIds: Set { + Set(walletIdentities.map(\.identityId)) } /// Lookup `txid (display-order hex) → total asset-lock amount in @@ -61,6 +84,38 @@ struct TransactionListView: View { return map } + /// `txid (display-order hex) → DashPay payment`, so a row can tell whether + /// the tx it's rendering is a DashPay payment (and to whom). Scoped to + /// this wallet's identities so a txid another identity in the same store + /// also recorded (A pays B) can't resolve to the wrong direction/row. + private var dashpayPaymentByTxid: [String: PersistentDashpayPayment] { + let owners = ownerIdentityIds + let scoped = dashpayPayments.filter { owners.contains($0.ownerIdentityId) } + return Dictionary(scoped.map { ($0.txid, $0) }, uniquingKeysWith: { first, _ in first }) + } + + /// Resolve a counterparty identity id → best display name (alias > profile + /// > DPNS > truncated id), reusing the shared DashPay name helper. Scoped + /// to this wallet's identities so a contact row owned by a sibling + /// identity can't leak the wrong alias/name. + private func dashpayCounterpartyName(for contactId: Data) -> String { + let owners = ownerIdentityIds + let alias = contactRequests.first { + owners.contains($0.ownerIdentityId) + && $0.contactIdentityId == contactId + && ($0.contactAlias?.isEmpty == false) + }?.contactAlias + let profileName = contactProfiles.first { + owners.contains($0.ownerIdentityId) && $0.contactIdentityId == contactId + }?.displayName + return dashPayContactDisplayName( + contactId: contactId, + alias: alias, + profileDisplayName: profileName, + dpnsLabel: nil + ) + } + private var transactions: [PersistentTransaction] { _ = transactionObservation // keep the subscription alive var seen: Set = [] @@ -119,13 +174,19 @@ struct TransactionListView: View { private var transactionsList: some View { let assetLockAmounts = assetLockAmountByTxid + let payments = dashpayPaymentByTxid return List(transactions) { transaction in + let payment = payments[transaction.txidHex] Button { selectedTransaction = transaction } label: { TransactionRowView( transaction: transaction, - assetLockAmountDuffs: assetLockAmounts[transaction.txidHex] + assetLockAmountDuffs: assetLockAmounts[transaction.txidHex], + dashpayPayment: payment, + dashpayCounterpartyName: payment.map { + dashpayCounterpartyName(for: $0.counterpartyIdentityId) + } ) } .buttonStyle(.plain) @@ -145,8 +206,22 @@ struct TransactionRowView: View { /// to mint platform credits. `nil` for non-asset-lock rows or /// when no matching row was found. var assetLockAmountDuffs: Int64? = nil + /// The DashPay payment this tx belongs to, if any — joined by `txid` in + /// `TransactionListView`. When set, the row shows the contact context + /// (who + memo + a DashPay badge) instead of a bare txid; a DashPay + /// payment is otherwise an ordinary `standard` core tx, indistinguishable + /// from a plain send. + var dashpayPayment: PersistentDashpayPayment? = nil + /// The counterparty's resolved display name (alias / profile / DPNS), or + /// nil to fall through to a truncated identity id. + var dashpayCounterpartyName: String? = nil + + private var isDashPay: Bool { dashpayPayment != nil } private var typeIcon: String { + // A DashPay payment is a person-to-person send/receive — mark it with + // a contact glyph so it reads differently from a raw on-chain tx. + if isDashPay { return "person.crop.circle.fill" } // Asset-lock / asset-unlock txs override direction-based icons // since the `direction` classifier reports `Internal` (the // credit output is structurally self-owned), but the intent @@ -165,6 +240,9 @@ struct TransactionRowView: View { } private var typeColor: Color { + // DashPay rows are indigo — a third axis distinct from the + // green/red send-receive and the purple asset-lock rows. + if isDashPay { return .indigo } // Asset-lock txs render purple — distinct from the red // outgoing / green incoming axis so the user can scan the // list and immediately spot identity-funding rows. @@ -179,6 +257,29 @@ struct TransactionRowView: View { } } + /// Primary label: the contact context for a DashPay payment, else the + /// truncated txid. + private var primaryText: String { + guard let payment = dashpayPayment else { return truncatedTxid } + let verb = payment.direction == .sent ? "Sent to" : "Received from" + return "\(verb) \(dashpayCounterpartyName ?? "contact")" + } + + @ViewBuilder + private var dashPayBadge: some View { + HStack(spacing: 4) { + Image(systemName: "person.2.fill") + .font(.caption2) + Text("DashPay") + .font(.caption2) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.indigo.opacity(0.2)) + .foregroundColor(.indigo) + .cornerRadius(4) + } + private var isConfirmed: Bool { // context: 0=mempool, 1=instantSend, 2=inBlock, 3=inChainLockedBlock transaction.context >= 2 @@ -232,11 +333,12 @@ struct TransactionRowView: View { .frame(width: 40) VStack(alignment: .leading, spacing: 4) { - // Transaction ID (truncated) and timestamp + // DashPay payment → contact context; otherwise the txid. HStack { - Text(truncatedTxid) - .font(.system(.subheadline, design: .monospaced)) + Text(primaryText) + .font(isDashPay ? .subheadline : .system(.subheadline, design: .monospaced)) .foregroundColor(.primary) + .lineLimit(1) Spacer() @@ -245,10 +347,23 @@ struct TransactionRowView: View { .foregroundColor(.secondary) } + // DashPay memo (if the sender attached one). + if let memo = dashpayPayment?.memo, + !memo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(memo) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + // confirmation and amount HStack { confirmationBadge + if isDashPay { + dashPayBadge + } + Spacer() VStack(alignment: .trailing, spacing: 2) { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 9576cef5224..7f05ccee830 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -838,10 +838,14 @@ struct WalletInfoView: View { do { let mgr = try walletManagerStore.backgroundManager(for: network) + // Enabling an existing wallet on another network: the mnemonic is + // pre-existing and may already have on-chain history there — scan + // from genesis (birthHeight 0) so prior funds/payments are seen. let created = try mgr.createWallet( mnemonic: mnemonic, network: network, - name: wallet.name ?? wallet.label + name: wallet.name ?? wallet.label, + birthHeight: 0 ) // Persist the mnemonic AND the per-wallet metadata under the // newly-enabled network's scoped walletId so that wallet is diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index dc3e663432d..765421952d4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -17,6 +17,12 @@ import SwiftDashSDK final class AppUIState: ObservableObject { /// Whether the detailed sync banner should be shown on the Wallets tab. @Published var showWalletsSyncDetails: Bool = true + + /// Root tab selection. Lives here (not as ContentView @State) so views + /// deep inside other tabs' navigation stacks can deep-link — e.g. + /// IdentityDetailView's "Contacts" row jumps to the DashPay tab with + /// that identity pre-selected. + @Published var selectedTab: RootTab = .sync } @main @@ -208,6 +214,7 @@ struct SwiftExampleAppApp: App { do { try walletManager.stopPlatformAddressSync() try walletManager.stopShieldedSync() + try walletManager.stopDashPaySync() } catch { SDKLogger.error( "Failed to stop sync coordinators: \(error.localizedDescription)" @@ -247,6 +254,15 @@ struct SwiftExampleAppApp: App { if try !walletManager.isShieldedSyncRunning() { try walletManager.startShieldedSync() } + + // DashPay contact-request + profile sweep (background + // loop). Wallet-driven — every registered wallet is swept + // each pass — so manager scope is the right place to start + // it, same as the address / shielded loops above. + // Idempotent: starting while running is a no-op. + if try !walletManager.isDashPaySyncRunning() { + try walletManager.startDashPaySync() + } } catch { SDKLogger.error( "Failed to bind wallet-scoped services: \(error.localizedDescription)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift index 96b4fa01ab9..fcf416d5b5c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsTabView.swift @@ -2,9 +2,11 @@ import SwiftUI import SwiftData import SwiftDashSDK -/// Tab-root view for the Contracts tab. +/// Contracts surface — reached from Settings → Platform (pushed onto +/// that screen's stack), or standalone with its own stack. See +/// `embedsOwnNavigationStack`. /// -/// Lays out two sections inside a `NavigationStack`: +/// Lays out two sections, optionally wrapped in its own `NavigationStack`: /// /// 1. A unified search bar at the top that handles three input /// shapes: @@ -34,8 +36,8 @@ struct ContractsTabView: View { @EnvironmentObject var transitionState: TransitionState /// Needed only to forward into the presented `DocumentsView` sheet /// (which declares it as an `@EnvironmentObject`). Reading it in this - /// leaf tab view re-renders only `ContractsTabView`, not the parent - /// `TabView`, so it doesn't reintroduce the progress-tick churn the + /// leaf view re-renders only `ContractsTabView`, not the host screen, + /// so it doesn't reintroduce the progress-tick churn the /// `WalletManagerStore` indirection in `ContentView` guards against. @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext @@ -49,6 +51,11 @@ struct ContractsTabView: View { /// re-runs `init`, which rebuilds the `@Query` predicate). private let network: Network + /// When `false`, the view renders without its own `NavigationStack` + /// so it can attach to a host stack (e.g. the Settings → Contracts + /// push) instead of nesting a second navigation bar. + private let embedsOwnNavigationStack: Bool + @Query private var dataContracts: [PersistentDataContract] /// Flat list of every token across every saved contract on the @@ -59,8 +66,9 @@ struct ContractsTabView: View { /// name in different contracts stay distinguishable. @Query private var allTokens: [PersistentToken] - init(network: Network) { + init(network: Network, embedsOwnNavigationStack: Bool = true) { self.network = network + self.embedsOwnNavigationStack = embedsOwnNavigationStack let target = network.rawValue // `PersistentDataContract.predicate(network:)` already exists // for this exact use; reuse it here so the keyed-network @@ -153,100 +161,99 @@ struct ContractsTabView: View { private static let keywordSearchLimit: UInt32 = 50 var body: some View { - NavigationStack { - List { - searchSection - resultsSection - contractsSection - tokensSection + List { + searchSection + resultsSection + contractsSection + tokensSection + } + .navigationTitle("Contracts") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingDocuments = true + } label: { + Image(systemName: "doc.text.magnifyingglass") + } + .accessibilityLabel("Browse Documents") + .accessibilityIdentifier("contracts.browseDocuments") } - .navigationTitle("Contracts") - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { Button { - showingDocuments = true + showingLoadContract = true } label: { - Image(systemName: "doc.text.magnifyingglass") + Label("Load Contract", systemImage: "arrow.down.circle") } - .accessibilityLabel("Browse Documents") - .accessibilityIdentifier("contracts.browseDocuments") - } - ToolbarItem(placement: .navigationBarTrailing) { - Menu { - Button { - showingLoadContract = true - } label: { - Label("Load Contract", systemImage: "arrow.down.circle") - } - Button { - showingRegisterContract = true - } label: { - Label("Register Contract", systemImage: "plus.circle") - } + Button { + showingRegisterContract = true } label: { - Image(systemName: "plus") + Label("Register Contract", systemImage: "plus.circle") } - .disabled(isLoading) - .accessibilityLabel("Add Contract or Token") + } label: { + Image(systemName: "plus") } + .disabled(isLoading) + .accessibilityLabel("Add Contract or Token") } - .sheet(isPresented: $showingLoadContract) { - LoadDataContractView(isLoading: $isLoading) - .environmentObject(platformState) - .environment(\.modelContext, modelContext) - } - .sheet(isPresented: $showingRegisterContract) { - RegisterContractSourceView() - .environmentObject(platformState) - .environmentObject(transitionState) - .environment(\.modelContext, modelContext) - } - .sheet(isPresented: $showingDocuments) { - // `DocumentsView` brings its own `NavigationView`, so it's - // presented as a sheet (never pushed). It declares - // `AppState`, `PlatformWalletManager`, and `TransitionState` - // as `@EnvironmentObject`s — forward all three explicitly - // like the sibling sheets above. The SwiftData - // `modelContext` is inherited through the sheet, but pin it - // explicitly to match the surrounding pattern. - DocumentsView() - .environmentObject(platformState) - .environmentObject(walletManager) - .environmentObject(transitionState) - .environment(\.modelContext, modelContext) - } - .sheet(item: $selectedContract) { contract in - // Saved-contract details. Presented from this stable - // container so a deep drill-down inside it (document - // type -> New Document) isn't torn down when the - // `@Query` list re-renders. - DataContractDetailsView(contract: contract) - .environmentObject(platformState) - .environment(\.modelContext, modelContext) - } - .sheet(item: $pendingPreview) { preview in - // Render the full saved-contract details view against - // the throwaway in-memory container so tokens, document - // types + indexes, and groups all surface via the - // existing drill-down screens. The container lives on - // `preview` and is dropped when the sheet dismisses. - DataContractDetailsView( - contract: preview.contract, - onSave: isAlreadySaved(preview.contractIdBase58) - ? nil - : { Task { await savePreview(preview) } }, - isSaving: isSavingPreview - ) + } + .sheet(isPresented: $showingLoadContract) { + LoadDataContractView(isLoading: $isLoading) .environmentObject(platformState) - .modelContainer(preview.container) - } - .alert("Error", isPresented: $showError) { - Button("OK") { } - } message: { - Text(errorMessage ?? "Unknown error occurred") - } + .environment(\.modelContext, modelContext) + } + .sheet(isPresented: $showingRegisterContract) { + RegisterContractSourceView() + .environmentObject(platformState) + .environmentObject(transitionState) + .environment(\.modelContext, modelContext) + } + .sheet(isPresented: $showingDocuments) { + // `DocumentsView` brings its own `NavigationView`, so it's + // presented as a sheet (never pushed). It declares + // `AppState`, `PlatformWalletManager`, and `TransitionState` + // as `@EnvironmentObject`s — forward all three explicitly + // like the sibling sheets above. The SwiftData + // `modelContext` is inherited through the sheet, but pin it + // explicitly to match the surrounding pattern. + DocumentsView() + .environmentObject(platformState) + .environmentObject(walletManager) + .environmentObject(transitionState) + .environment(\.modelContext, modelContext) + } + .sheet(item: $selectedContract) { contract in + // Saved-contract details. Presented from this stable + // container so a deep drill-down inside it (document + // type -> New Document) isn't torn down when the + // `@Query` list re-renders. + DataContractDetailsView(contract: contract) + .environmentObject(platformState) + .environment(\.modelContext, modelContext) + } + .sheet(item: $pendingPreview) { preview in + // Render the full saved-contract details view against + // the throwaway in-memory container so tokens, document + // types + indexes, and groups all surface via the + // existing drill-down screens. The container lives on + // `preview` and is dropped when the sheet dismisses. + DataContractDetailsView( + contract: preview.contract, + onSave: isAlreadySaved(preview.contractIdBase58) + ? nil + : { Task { await savePreview(preview) } }, + isSaving: isSavingPreview + ) + .environmentObject(platformState) + .modelContainer(preview.container) } + .alert("Error", isPresented: $showError) { + Button("OK") { } + } message: { + Text(errorMessage ?? "Unknown error occurred") + } + .wrappedInNavigationStack(embedsOwnNavigationStack) } // MARK: Sections @@ -1075,3 +1082,17 @@ struct TokenListRow: View { .environmentObject(TransitionState()) .environmentObject(PlatformWalletManager()) } + +private extension View { + /// Wrap in a `NavigationStack` only when `wrap` is true. Lets a view + /// own its navigation chrome standalone yet attach to a host stack + /// when embedded (avoiding a nested second navigation bar). + @ViewBuilder + func wrappedInNavigationStack(_ wrap: Bool) -> some View { + if wrap { + NavigationStack { self } + } else { + self + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index ce2ae408048..215336b15ee 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -645,6 +645,9 @@ struct CreateIdentityView: View { } } + /// Footer copy for the funding-source section. Built out-of-line — + /// inlining the concatenation chain (with a conditional in the + /// middle) blows the SwiftUI type-checker's time budget. private static func fundingSourceFooterText(showShielded: Bool) -> String { var text = "Any account on the selected wallet with a balance can fund " + "the identity — Core or Platform Payment. Empty accounts are hidden. " diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift new file mode 100644 index 00000000000..7a0f020ff9b --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -0,0 +1,497 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Add-contact sheet (restyled from `AddFriendView`). +/// +/// Two modes: **Username (DPNS)** with live prefix search, and +/// **Identity ID** with inline base58 validation. Either way the +/// resolved target renders as a preview card that gates "Send +/// Request" (never a dead end: not-found offers +/// clear-and-retry instead of a terminal error). +struct AddContactView: View { + let identity: PersistentIdentity + /// Fires after a successful broadcast with the recipient id and + /// the DPNS name used to find them (nil in ID mode). The tab + /// root inserts the id into the optimistic-send overlay and + /// records the DPNS hint. + let onSent: (Identifier, String?) -> Void + + @EnvironmentObject var walletManager: PlatformWalletManager + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + private enum Mode: Hashable { + case dpns, identityId + } + + /// DPNS resolution states: typing → searching → not-found → + /// found. `idle` covers "fewer than 2 characters typed". + private enum SearchState: Equatable { + case idle + case searching + case notFound + case found([DpnsSearchResult]) + } + + @State private var mode: Mode = .dpns + @State private var searchText = "" + @State private var searchState: SearchState = .idle + @State private var searchTask: Task? + + /// DPNS mode: the result row the user picked. Resolution to a + /// preview card; gates Send. + @State private var selectedResult: DpnsSearchResult? + + @State private var idText = "" + + /// Optional DIP-15 `encryptedAccountLabel` the sender attaches to the + /// receiving account they share. The recipient decrypts it and sees it + /// as the contact's "Their account" hint. + @State private var accountLabel = "" + + @State private var isSending = false + @State private var errorMessage: String? + + /// Send-collision flow. + @State private var showCollisionAlert = false + @State private var collisionRecipient: Identifier? + + /// Minimum prefix length before firing a search. + private let minSearchLength = 2 + /// Debounce for the live search. + private let searchDebounce: Duration = .milliseconds(300) + + // MARK: - Derived + + /// ID mode: parsed base58, nil while invalid. + /// + /// The 32-byte length gate is load-bearing: `Data.identifier(fromBase58:)` + /// decodes partial input to fewer bytes, and a short id reaching + /// `getDashPayProfile` trips `withFFIBytes`'s 32-byte precondition + /// (crash observed while typing into this field, 2026-06-12). + private var parsedIdentityId: Identifier? { + let trimmed = idText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let decoded = Data.identifier(fromBase58: trimmed), + decoded.count == 32 + else { return nil } + return decoded + } + + private var resolvedRecipient: Identifier? { + switch mode { + case .dpns: return selectedResult?.identityId + case .identityId: return parsedIdentityId + } + } + + private var canSend: Bool { + resolvedRecipient != nil + && resolvedRecipient != identity.identityId + && !isSending + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + Picker("Search by", selection: $mode) { + Text("Username (DPNS)").tag(Mode.dpns) + Text("Identity ID").tag(Mode.identityId) + } + .pickerStyle(.segmented) + .padding() + .accessibilityIdentifier("dashpay.addContact.mode") + + Form { + switch mode { + case .dpns: + dpnsSections + case .identityId: + idSections + } + + if let recipient = resolvedRecipient { + previewSection(recipient: recipient) + accountLabelSection + sendSection + } + + if let errorMessage { + Section { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + } + } + .navigationTitle("Add Contact") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(isSending) + .accessibilityIdentifier("dashpay.addContact.cancel") + } + } + .alert( + "Request already received", + isPresented: $showCollisionAlert, + presenting: collisionRecipient + ) { recipient in + Button("Accept") { + acceptIncoming(from: recipient) + } + Button("Continue anyway") { + send(to: recipient) + } + } message: { _ in + Text("This person already sent you a request — accept it instead?") + } + } + } + + // MARK: - DPNS mode (§6.4 four-state machine) + + @ViewBuilder + private var dpnsSections: some View { + Section { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search usernames", text: $searchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("dashpay.addContact.input") + if !searchText.isEmpty { + Button { + clearSearch() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + .accessibilityIdentifier("dashpay.addContact.clear") + } + } + + switch searchState { + case .idle: + if searchText.trimmingCharacters(in: .whitespacesAndNewlines).count < minSearchLength { + Text("Type at least \(minSearchLength) characters to search.") + .font(.caption) + .foregroundColor(.secondary) + } + case .searching: + HStack(spacing: 10) { + ProgressView() + Text("Searching…") + .font(.caption) + .foregroundColor(.secondary) + } + case .notFound: + // Never a dead end — message + clear-and-retry. + VStack(alignment: .leading, spacing: 8) { + Text("No usernames match \"\(searchText)\".") + .font(.caption) + .foregroundColor(.secondary) + Button("Clear and try again") { + clearSearch() + } + .font(.caption) + .accessibilityIdentifier("dashpay.addContact.retry") + } + case .found(let results): + ForEach(results) { result in + Button { + selectedResult = result + errorMessage = nil + } label: { + HStack(spacing: 10) { + DashPayAvatarView( + avatarUrl: nil, + displayName: result.fullName, + size: 32 + ) + VStack(alignment: .leading, spacing: 2) { + Text(result.fullName) + .font(.subheadline) + .foregroundColor(.primary) + Text(result.identityId.toBase58String().prefix(16) + "…") + .font(.caption2) + .foregroundColor(.secondary) + } + Spacer() + if selectedResult == result { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + } + } + } + .accessibilityIdentifier( + "dashpay.addContact.result.\(result.fullName)" + ) + } + } + } header: { + Text("Username") + } footer: { + Text("Live search against the Dash Platform Name Service.") + } + .onChange(of: searchText) { _, newValue in + scheduleSearch(for: newValue) + } + } + + private func clearSearch() { + searchTask?.cancel() + searchText = "" + searchState = .idle + selectedResult = nil + } + + /// Debounced (~300 ms) prefix search; min 2 chars. Cancels any + /// in-flight lookup when the prefix changes. + private func scheduleSearch(for text: String) { + searchTask?.cancel() + selectedResult = nil + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= minSearchLength else { + searchState = .idle + return + } + searchTask = Task { @MainActor in + try? await Task.sleep(for: searchDebounce) + guard !Task.isCancelled else { return } + guard let wallet = try? requireWallet() else { + searchState = .idle + errorMessage = "No wallet available for this identity" + return + } + searchState = .searching + do { + let results = try await wallet.searchDpnsNames(prefix: trimmed, limit: 10) + guard !Task.isCancelled else { return } + searchState = results.isEmpty ? .notFound : .found(results) + } catch { + guard !Task.isCancelled else { return } + searchState = .idle + errorMessage = "Search failed: \(error.localizedDescription)" + } + } + } + + // MARK: - Identity ID mode + + @ViewBuilder + private var idSections: some View { + Section { + TextField("Paste identity ID (base58)", text: $idText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("dashpay.addContact.idInput") + + // Inline validation gates the send button (§6.4). + if !idText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && parsedIdentityId == nil { + Text("Not a valid identity id (expected base58)") + .font(.caption) + .foregroundColor(.red) + } + } header: { + Text("Identity ID") + } footer: { + Text("The contact's unique Platform identity identifier.") + } + } + + // MARK: - Preview + send + + /// Resolved-target preview card — Send is only reachable from + /// here (§6.4 "found" state). Profile data is a cache-only read; + /// most unknown identities won't have one, so the card falls + /// back to the DPNS name / truncated id. + private func previewSection(recipient: Identifier) -> some View { + let profile = cachedProfile(recipient) + let name = previewDisplayName(recipient: recipient, profile: profile) + return Section("Send to") { + HStack(spacing: 10) { + DashPayAvatarView( + avatarUrl: profile?.avatarUrl, + displayName: name + ) + VStack(alignment: .leading, spacing: 2) { + Text(name) + .font(.headline) + Text(recipient.toBase58String().prefix(20) + "…") + .font(.caption) + .foregroundColor(.secondary) + if let msg = profile?.publicMessage? + .trimmingCharacters(in: .whitespacesAndNewlines), + !msg.isEmpty { + Text(msg) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + Spacer() + } + if recipient == identity.identityId { + Text("That's this identity — pick someone else.") + .font(.caption) + .foregroundColor(.red) + } + } + } + + /// Optional account-label field (DIP-15 `encryptedAccountLabel`). Empty + /// → no label is sent. Shown once a recipient is resolved. + private var accountLabelSection: some View { + Section("Account label (optional)") { + TextField("e.g. Main wallet", text: $accountLabel) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("dashpay.addContact.accountLabel") + } + } + + private var sendSection: some View { + Section { + Button { + attemptSend() + } label: { + HStack { + Spacer() + if isSending { + ProgressView() + } else { + Label("Send Request", systemImage: "paperplane") + } + Spacer() + } + } + .disabled(!canSend) + .accessibilityIdentifier("dashpay.addContact.send") + } + } + + private func previewDisplayName( + recipient: Identifier, + profile: DashPayProfile? + ) -> String { + dashPayContactDisplayName( + contactId: recipient, + alias: nil, + profileDisplayName: profile?.displayName, + dpnsLabel: selectedResult?.fullName + ) + } + + private func cachedProfile(_ contactId: Identifier) -> DashPayProfile? { + guard let wallet = try? requireWallet() else { return nil } + return dashPayCachedProfile( + wallet: wallet, + ownerIdentityId: identity.identityId, + contactId: contactId + ) + } + + private func requireWallet() throws -> ManagedPlatformWallet { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + throw PlatformWalletError.walletOperation( + "No loaded wallet for identity \(identity.identityIdBase58)" + ) + } + return wallet + } + + // MARK: - Send flow (§6.4 collision check first) + + /// Check the local store for a pending incoming request from the + /// target before sending — if one exists, surface the collision + /// alert (Accept / Continue anyway) instead of silently + /// double-requesting. + private func attemptSend() { + guard let recipient = resolvedRecipient else { return } + errorMessage = nil + + if hasPendingIncomingRequest(from: recipient) { + collisionRecipient = recipient + showCollisionAlert = true + } else { + send(to: recipient) + } + } + + /// A *pending* incoming request = incoming row present with no + /// outgoing row for the same contact (an established pair has + /// both, and re-requesting an established contact is pointless + /// but harmless — no alert for that). + private func hasPendingIncomingRequest(from recipient: Identifier) -> Bool { + let ownerId = identity.identityId + let contactId = recipient + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.ownerIdentityId == ownerId && $0.contactIdentityId == contactId + } + ) + guard let rows = try? modelContext.fetch(descriptor), !rows.isEmpty else { + return false + } + return rows.contains { !$0.isOutgoing } && !rows.contains { $0.isOutgoing } + } + + private func send(to recipient: Identifier) { + isSending = true + errorMessage = nil + Task { @MainActor in + defer { isSending = false } + do { + let wallet = try requireWallet() + let signer = KeychainSigner(modelContainer: modelContext.container) + let label = accountLabel.trimmingCharacters(in: .whitespacesAndNewlines) + _ = try await wallet.sendContactRequest( + senderIdentityId: identity.identityId, + recipientIdentityId: recipient, + accountLabel: label.isEmpty ? nil : label, + signer: signer + ) + onSent(recipient, mode == .dpns ? selectedResult?.fullName : nil) + kickDashPaySync(walletManager) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } + } + + /// Collision-alert "Accept" path: resolve the live incoming + /// `ContactRequest` and accept it — establishing the contact + /// directly instead of sending a redundant request. + private func acceptIncoming(from recipient: Identifier) { + isSending = true + errorMessage = nil + Task { @MainActor in + defer { isSending = false } + do { + let wallet = try requireWallet() + let managed = try wallet.managedIdentity(identityId: identity.identityId) + guard let request = try managed.getIncomingContactRequest( + senderId: recipient + ) else { + errorMessage = "Their request isn't in local state — pull to refresh and accept it from Requests." + return + } + let signer = KeychainSigner(modelContainer: modelContext.container) + _ = try await wallet.acceptContactRequest(request, signer: signer) + kickDashPaySync(walletManager) + dismiss() + } catch { + errorMessage = "Accept failed: \(error.localizedDescription)" + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactDetailView.swift new file mode 100644 index 00000000000..7261195885f --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactDetailView.swift @@ -0,0 +1,561 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Per-contact detail: profile header, Send Dash (via +/// the existing `SendDashPayPaymentSheet`), `@Query`-driven payment +/// history, and the alias / note / hide controls — `contactInfo`- +/// backed: edits publish a self-encrypted document so they +/// sync across devices and survive restore-from-seed. +struct ContactDetailView: View { + let identity: PersistentIdentity + let contactId: Data + + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var contactMeta: DashPayContactMetaStore + @Environment(\.modelContext) private var modelContext + + /// Payment history with this contact. Refreshed on demand via + /// `refreshDashPayPayments` (the Rust map is read → upserted → + /// observed here reactively). + @Query private var payments: [PersistentDashpayPayment] + + /// This pair's request rows — drives the broken-channel state + /// reactively: when a fresh request arrives and the Rust side + /// clears `payment_channel_broken`, the persister updates the + /// rows and Send Dash re-enables without a manual refresh. + @Query private var pairRows: [PersistentDashpayContactRequest] + + @State private var showPaymentSheet = false + @State private var showAliasEditor = false + @State private var showNoteEditor = false + @State private var isRefreshingPayments = false + @State private var paymentsError: String? + + init(identity: PersistentIdentity, contactId: Data) { + self.identity = identity + self.contactId = contactId + _payments = Query( + filter: PersistentDashpayPayment.predicate( + ownerIdentityId: identity.identityId, + counterpartyIdentityId: contactId + ), + sort: [SortDescriptor(\PersistentDashpayPayment.createdAt, order: .reverse)] + ) + let ownerId = identity.identityId + let contact = contactId + _pairRows = Query( + filter: #Predicate { + $0.ownerIdentityId == ownerId && $0.contactIdentityId == contact + } + ) + } + + // MARK: - Derived + + private var channelBroken: Bool { + pairRows.contains(where: \.paymentChannelBroken) + } + + /// contactInfo-backed alias — read off the established contact + /// rows (both directions carry the same value; first non-nil + /// wins). Reactive via the `pairRows` `@Query`: the recurring + /// sync's decrypted contactInfo lands through the persister and + /// re-renders here. + private var localAlias: String? { + pairRows.compactMap(\.contactAlias).first + } + + private var localNote: String? { + pairRows.compactMap(\.contactNote).first + } + + /// The contact's decrypted DIP-15 `encryptedAccountLabel` — the label + /// the contact chose for the account they shared (a payment-routing + /// hint). Read off the **incoming** row only: the outgoing row carries + /// a label *we* sent, which we don't surface. System-derived and + /// read-only, distinct from the owner-private `localAlias`/`localNote`. + private var contactAccountLabel: String? { + pairRows.first(where: { !$0.isOutgoing })?.contactAccountLabel + } + + private var dpnsHint: String? { + contactMeta.dpnsHint( + network: identity.network, + owner: identity.identityId, + contact: contactId + ) + } + + private var profile: DashPayProfile? { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + return nil + } + return dashPayCachedProfile( + wallet: wallet, + ownerIdentityId: identity.identityId, + contactId: contactId + ) + } + + private var displayName: String { + dashPayContactDisplayName( + contactId: contactId, + alias: localAlias, + profileDisplayName: profile?.displayName, + dpnsLabel: dpnsHint + ) + } + + private var isHidden: Bool { + pairRows.contains(where: \.contactHidden) + } + + /// In-flight contactInfo save — disables the controls so a slow + /// publish can't be double-submitted; errors render inline. + @State private var isSavingContactInfo = false + @State private var contactInfoError: String? + /// Set after a save whose document publish was deferred/skipped, so the + /// UI doesn't claim a cross-device sync that didn't happen (H2). + @State private var publishNotice: String? + + var body: some View { + List { + headerSection + sendSection + paymentsSection + localSettingsSection + } + .navigationTitle(displayName) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showPaymentSheet) { + SendDashPayPaymentSheet( + senderIdentity: identity, + contact: DashPayContact( + id: contactId, + displayName: displayName, + identityId: contactId, + dpnsName: dpnsHint + ), + onSent: { refreshPayments() } + ) + .environmentObject(walletManager) + } + .sheet(isPresented: $showAliasEditor) { + ContactLocalFieldEditor( + title: "Alias", + prompt: "e.g. Mom", + footer: "An alias overrides this contact's display name. Encrypted and synced to your other devices once this identity has two or more contacts.", + initialValue: localAlias ?? "", + identifierPrefix: "dashpay.detail.alias", + onSave: { value in + saveContactInfo(alias: value, note: localNote, hidden: isHidden) + } + ) + } + .sheet(isPresented: $showNoteEditor) { + ContactLocalFieldEditor( + title: "Note", + prompt: "Anything to remember about this contact", + footer: "Notes are private (encrypted) and synced to your other devices once this identity has two or more contacts.", + initialValue: localNote ?? "", + identifierPrefix: "dashpay.detail.note", + onSave: { value in + saveContactInfo(alias: localAlias, note: value, hidden: isHidden) + } + ) + } + .task { + refreshPayments() + } + .onChange(of: walletManager.dashPaySyncIsSyncing) { _, syncing in + // A Sent payment is flipped Pending → Confirmed in the + // in-memory model by a Core block event, and that change + // reaches SwiftData only through a payment refresh (the + // changeset/store path does not persist DashPay payments). + // Re-pull on each completed DashPay sync pass so the status + // updates live here without a manual Refresh. + if !syncing { + refreshPayments() + } + } + } + + // MARK: - Sections + + private var headerSection: some View { + Section { + HStack(spacing: 14) { + DashPayAvatarView( + avatarUrl: profile?.avatarUrl, + displayName: displayName, + size: 56 + ) + VStack(alignment: .leading, spacing: 3) { + Text(displayName) + .font(.title3) + .fontWeight(.semibold) + if let dpns = dpnsHint { + Text(dpns) + .font(.caption) + .foregroundColor(.secondary) + } + Text(contactId.toBase58String().prefix(20) + "…") + .font(.caption2) + .foregroundColor(.secondary) + if let msg = profile?.publicMessage? + .trimmingCharacters(in: .whitespacesAndNewlines), + !msg.isEmpty { + Text(msg) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + } + .padding(.vertical, 4) + + if let note = localNote { + Label(note, systemImage: "note.text") + .font(.caption) + .foregroundColor(.secondary) + } + + // The contact's own label for the account they shared (DIP-15 + // encryptedAccountLabel) — a read-only payment-routing hint, + // distinct from the owner-private alias/note above. + if let accountLabel = contactAccountLabel { + VStack(alignment: .leading, spacing: 1) { + Text("Their account") + .font(.caption2) + .foregroundColor(.secondary) + Label(accountLabel, systemImage: "wallet.pass") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + private var sendSection: some View { + Section { + Button { + showPaymentSheet = true + } label: { + Label("Send Dash", systemImage: "paperplane.fill") + .fontWeight(.medium) + } + .disabled(channelBroken) + .accessibilityIdentifier("dashpay.detail.sendDash") + + if channelBroken { + // Broken payment channel. Re-enables + // reactively when a new request flips the flag. + Label( + "Payment channel broken — ask the contact to send a new request", + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundColor(.orange) + } + } + } + + private var paymentsSection: some View { + Section { + if payments.isEmpty { + if isRefreshingPayments { + // Loading: single inline ProgressView. + HStack(spacing: 10) { + ProgressView() + Text("Loading payments…") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + Text("No payments yet") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + ForEach(payments, id: \.txid) { payment in + PaymentHistoryRow(payment: payment) + } + } + + if let paymentsError { + // Error: keep the last-known list, caption only. + Text(paymentsError) + .font(.caption) + .foregroundColor(.red) + } + } header: { + HStack { + Text("Payments (\(payments.count))") + Spacer() + Button { + refreshPayments() + } label: { + Image(systemName: "arrow.clockwise") + .symbolEffect( + .rotate, + options: .nonRepeating, + isActive: isRefreshingPayments + ) + } + .disabled(isRefreshingPayments) + .accessibilityIdentifier("dashpay.detail.refreshPayments") + } + } + } + + private var localSettingsSection: some View { + Section { + Button { + showAliasEditor = true + } label: { + HStack { + Label("Alias", systemImage: "person.text.rectangle") + Spacer() + Text(localAlias ?? "None") + .foregroundColor(.secondary) + } + } + .foregroundColor(.primary) + .accessibilityIdentifier("dashpay.detail.aliasEdit") + + Button { + showNoteEditor = true + } label: { + HStack { + Label("Note", systemImage: "note.text") + Spacer() + Text(localNote == nil ? "None" : "Edit") + .foregroundColor(.secondary) + } + } + .foregroundColor(.primary) + .accessibilityIdentifier("dashpay.detail.noteEdit") + + Toggle(isOn: Binding( + get: { isHidden }, + set: { hidden in + saveContactInfo(alias: localAlias, note: localNote, hidden: hidden) + } + )) { + Label("Hide contact", systemImage: "eye.slash") + } + .disabled(isSavingContactInfo) + .accessibilityIdentifier("dashpay.detail.hideToggle") + + if isSavingContactInfo { + HStack(spacing: 10) { + ProgressView() + Text("Saving…") + .font(.caption) + .foregroundColor(.secondary) + } + } + if let contactInfoError { + Text(contactInfoError) + .font(.caption) + .foregroundColor(.red) + } + // Honest publish-state banner (H2): tell the user when an edit + // was saved locally but NOT published cross-device, instead of + // the footer claiming an unconditional sync. + if let publishNotice { + Label(publishNotice, systemImage: "icloud.slash") + .font(.caption) + .foregroundColor(.orange) + } + } header: { + Text("Contact settings") + } footer: { + // contactInfo-backed (M3): self-encrypted on Platform. The + // footer states the steady-state behaviour; the per-save + // `publishNotice` above corrects it when a publish was deferred. + Text("Alias, note and hide are encrypted and synced to your other devices via Platform once this identity has two or more contacts.") + } + } + + /// Persist alias/note/hidden through the contactInfo pipeline: + /// local state updates immediately (the persister round lands in + /// the rows the `pairRows` query watches); the document publish + /// happens in the same call unless deferred by the DIP-15 + /// ≥2-contacts privacy rule. + private func saveContactInfo(alias: String?, note: String?, hidden: Bool) { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + contactInfoError = "No wallet available for this identity" + return + } + isSavingContactInfo = true + contactInfoError = nil + publishNotice = nil + Task { @MainActor in + defer { isSavingContactInfo = false } + do { + let signer = KeychainSigner(modelContainer: modelContext.container) + let outcome = try await wallet.setDashPayContactInfo( + identityId: identity.identityId, + contactId: contactId, + alias: alias?.isEmpty == true ? nil : alias, + note: note?.isEmpty == true ? nil : note, + hidden: hidden, + signer: signer + ) + switch outcome { + case .published: + publishNotice = nil + case .deferredUntilTwoContacts: + publishNotice = "Saved on this device. It will sync to your other devices once this identity has two or more contacts." + case .skippedWatchOnly: + publishNotice = "Saved on this device only — this watch-only identity can't publish to Platform." + } + } catch { + contactInfoError = "Save failed: \(error.localizedDescription)" + } + } + } + + // MARK: - Payment refresh + + /// One FFI read + one persistence pass; the `@Query` above picks + /// the upserts up reactively. + private func refreshPayments() { + // Collapse overlapping triggers (`.task` on appear, `onSent`, the + // sync falling-edge `onChange`, the manual Refresh button) into one + // in-flight pass — the FFI read + SwiftData upsert is idempotent, so + // a concurrent second pass is wasted work and flickers the spinner. + guard !isRefreshingPayments else { return } + guard let walletId = identity.wallet?.walletId else { + paymentsError = "Identity has no wallet association" + return + } + isRefreshingPayments = true + paymentsError = nil + Task { @MainActor in + defer { isRefreshingPayments = false } + do { + _ = try walletManager.refreshDashPayPayments( + walletId: walletId, + identityId: identity.identityId + ) + } catch { + paymentsError = "Payment refresh failed: \(error.localizedDescription)" + } + } + } +} + +// MARK: - Payment row + +/// One payment-history entry. `PaymentEntry` has no timestamp, so +/// the row shows direction + txid prefix + amount + status (§6.4). +struct PaymentHistoryRow: View { + let payment: PersistentDashpayPayment + + var body: some View { + HStack(spacing: 10) { + Image(systemName: payment.direction == .sent + ? "arrow.up.right.circle.fill" + : "arrow.down.left.circle.fill") + .foregroundColor(payment.direction == .sent ? .blue : .green) + VStack(alignment: .leading, spacing: 2) { + Text(payment.direction == .sent ? "Sent" : "Received") + .font(.subheadline) + .fontWeight(.medium) + Text(payment.txid.prefix(16) + "…") + .font(.caption2) + .foregroundColor(.secondary) + if let memo = payment.memo, !memo.isEmpty { + Text(memo) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(formattedAmount) + .font(.subheadline.monospacedDigit()) + .fontWeight(.semibold) + Text(statusLabel) + .font(.caption2) + .foregroundColor(statusColor) + } + } + .padding(.vertical, 2) + } + + private var formattedAmount: String { + let dash = Double(payment.amountDuffs) / 100_000_000 + return String(format: "%.8f DASH", dash) + } + + private var statusLabel: String { + switch payment.status { + case .pending: return "Pending" + case .confirmed: return "Confirmed" + case .failed: return "Failed" + } + } + + private var statusColor: Color { + switch payment.status { + case .pending: return .orange + case .confirmed: return .green + case .failed: return .red + } + } +} + +// MARK: - Local field editor + +/// Tiny Form-based editor sheet for the device-local alias / note +/// fields — same shape as `EditAliasView` but writing to the +/// `DashPayContactMetaStore` instead of a SwiftData row. Saving an +/// empty value clears the field. +struct ContactLocalFieldEditor: View { + let title: String + let prompt: String + let footer: String + let initialValue: String + let identifierPrefix: String + let onSave: (String?) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var value: String = "" + + var body: some View { + NavigationStack { + Form { + Section { + TextField(prompt, text: $value) + .accessibilityIdentifier("\(identifierPrefix).field") + } footer: { + Text(footer) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .accessibilityIdentifier("\(identifierPrefix).cancel") + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(trimmed.isEmpty ? nil : trimmed) + dismiss() + } + .accessibilityIdentifier("\(identifierPrefix).save") + } + } + .onAppear { value = initialValue } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift new file mode 100644 index 00000000000..0ad941084cb --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -0,0 +1,391 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Incoming + outgoing contact requests. Incoming rows +/// carry Accept / Reject with per-row in-flight state; the Outgoing +/// section renders pending sent requests (previously loaded but +/// never shown anywhere in the app). +/// +/// Pending vs established, from the `@Query` rows: a pair with both +/// direction rows is established (shown in ContactsView); a single +/// incoming row is a pending incoming request; a single outgoing row +/// is a pending sent request. +struct ContactRequestsView: View { + let identity: PersistentIdentity + + /// Optimistic overlay for *send* — owned by the tab root so + /// AddContactView can insert into it; pruned here when the + /// `@Query` reflects the new outgoing row or a sync completes. + @Binding var optimisticSentIds: Set + + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var contactMeta: DashPayContactMetaStore + @Environment(\.modelContext) private var modelContext + + @Query private var requestRows: [PersistentDashpayContactRequest] + + /// Contact ids with an Accept/Reject currently in flight — the + /// row's buttons are replaced by a `ProgressView` (blocks + /// double-tap → duplicate accepts). + @State private var inFlightIds: Set = [] + + /// Optimistic overlay for accept/reject: ids whose incoming + /// row should stop rendering before the persister catches up. + /// Pruned in `onChange(of: requestRows)` once the query reflects + /// the change; fallback-cleared after the next completed sync + /// pass so a lost callback can't hide a row forever. + @State private var removedOverlayIds: Set = [] + + /// Per-row inline errors (§6.4: failure restores the buttons + /// with an inline error on the row). + @State private var rowErrors: [Data: String] = [:] + + init(identity: PersistentIdentity, optimisticSentIds: Binding>) { + self.identity = identity + _optimisticSentIds = optimisticSentIds + _requestRows = Query( + filter: PersistentDashpayContactRequest.predicate( + ownerIdentityId: identity.identityId + ) + ) + } + + // MARK: - Derived rows + + private var rowsByContact: [Data: [PersistentDashpayContactRequest]] { + Dictionary(grouping: requestRows, by: \.contactIdentityId) + } + + /// Equatable change signal for the `@Query` rows — `@Model` + /// classes aren't `Equatable`, so `onChange` watches this + /// `(contact, direction)` set instead. Overlay pruning only + /// cares about rows appearing/disappearing, which this captures. + private var rowSignature: Set { + Set(requestRows.map { + $0.contactIdentityId.toHexString() + ($0.isOutgoing ? ":o" : ":i") + }) + } + + /// Incoming-only pairs, minus the optimistic-removal overlay. + private var incomingPending: [PersistentDashpayContactRequest] { + rowsByContact.compactMap { contactId, rows -> PersistentDashpayContactRequest? in + guard !removedOverlayIds.contains(contactId), + !rows.contains(where: { $0.isOutgoing }), + let incoming = rows.first(where: { !$0.isOutgoing }) else { + return nil + } + return incoming + } + .sorted { $0.createdAtMillis > $1.createdAtMillis } + } + + /// Outgoing-only pairs. + private var outgoingPending: [PersistentDashpayContactRequest] { + rowsByContact.compactMap { _, rows -> PersistentDashpayContactRequest? in + guard !rows.contains(where: { !$0.isOutgoing }), + let outgoing = rows.first(where: { $0.isOutgoing }) else { + return nil + } + return outgoing + } + .sorted { $0.createdAtMillis > $1.createdAtMillis } + } + + /// Sent requests still riding the optimistic overlay (broadcast + /// done, persister row not landed yet). + private var optimisticOutgoing: [Data] { + optimisticSentIds + .filter { rowsByContact[$0] == nil } + .sorted { $0.toHexString() < $1.toHexString() } + } + + var body: some View { + SwiftUI.Group { + if incomingPending.isEmpty && outgoingPending.isEmpty + && optimisticOutgoing.isEmpty { + List { + DashPayListEmptyRow( + icon: "tray", + title: "No pending requests", + message: "Incoming contact requests and your pending sent requests show up here." + ) + } + .listStyle(.insetGrouped) + } else { + List { + if !incomingPending.isEmpty { + Section { + ForEach(incomingPending, id: \.contactIdentityId) { row in + IncomingRequestRow( + displayName: displayName(for: row.contactIdentityId), + // Privacy: do NOT load a pending (unsolicited) sender's + // avatar — it's a sender-chosen URL, and an AsyncImage GET + // before the user accepts would leak the recipient's IP / + // online status to the sender. Show initials until the + // contact is accepted (established rows load it normally). + avatarUrl: nil, + createdAtMillis: row.createdAtMillis, + isInFlight: inFlightIds.contains(row.contactIdentityId), + errorMessage: rowErrors[row.contactIdentityId], + onAccept: { accept(contactId: row.contactIdentityId) }, + onIgnore: { ignore(contactId: row.contactIdentityId) } + ) + } + } header: { + Text("Incoming (\(incomingPending.count))") + } + } + + if !outgoingPending.isEmpty || !optimisticOutgoing.isEmpty { + Section { + ForEach(outgoingPending, id: \.contactIdentityId) { row in + OutgoingRequestRow( + displayName: displayName(for: row.contactIdentityId), + avatarUrl: cachedProfile(row.contactIdentityId)?.avatarUrl, + createdAtMillis: row.createdAtMillis + ) + } + // Synthetic rows for just-broadcast sends + // the persister hasn't projected yet. + ForEach(optimisticOutgoing, id: \.self) { contactId in + OutgoingRequestRow( + displayName: displayName(for: contactId), + avatarUrl: cachedProfile(contactId)?.avatarUrl, + createdAtMillis: nil + ) + } + } header: { + Text("Outgoing (\(outgoingPending.count + optimisticOutgoing.count))") + } + } + } + .listStyle(.insetGrouped) + } + } + .refreshable { + await attachOrStartSync(walletManager) + } + .onChange(of: rowSignature) { _, _ in + pruneOverlays() + } + .onChange(of: walletManager.dashPaySyncIsSyncing) { _, syncing in + // Fallback clearing rule: after the next completed + // sync pass, expire whatever the query still doesn't + // reflect — rows must not stay hidden (or synthetically + // shown) forever on a missed callback. + if !syncing { + removedOverlayIds.removeAll() + optimisticSentIds.removeAll() + } + } + } + + // MARK: - Overlay maintenance + + /// Drop overlay entries the `@Query` already reflects: + /// - removal overlay: the incoming-only pair is gone (row + /// deleted on reject, or promoted to established on accept); + /// - send overlay: the outgoing row landed. + private func pruneOverlays() { + let byContact = rowsByContact + removedOverlayIds = removedOverlayIds.filter { contactId in + guard let rows = byContact[contactId] else { return false } + // Still an incoming-only pair → keep hiding it. + return rows.contains { !$0.isOutgoing } + && !rows.contains { $0.isOutgoing } + } + optimisticSentIds = optimisticSentIds.filter { byContact[$0] == nil } + } + + // MARK: - Actions + + private func requireWallet() throws -> ManagedPlatformWallet { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + throw PlatformWalletError.walletOperation( + "No loaded wallet for identity \(identity.identityIdBase58)" + ) + } + return wallet + } + + private func accept(contactId: Data) { + rowErrors[contactId] = nil + inFlightIds.insert(contactId) + Task { @MainActor in + defer { inFlightIds.remove(contactId) } + do { + let wallet = try requireWallet() + let managed = try wallet.managedIdentity(identityId: identity.identityId) + guard let request = try managed.getIncomingContactRequest( + senderId: contactId + ) else { + rowErrors[contactId] = "Request not in local state — pull to refresh" + return + } + let signer = KeychainSigner(modelContainer: modelContext.container) + _ = try await wallet.acceptContactRequest(request, signer: signer) + // Optimistic removal — the persister will promote the + // pair to established shortly. + removedOverlayIds.insert(contactId) + kickDashPaySync(walletManager) + } catch { + rowErrors[contactId] = "Accept failed: \(error.localizedDescription)" + } + } + } + + private func ignore(contactId: Data) { + rowErrors[contactId] = nil + inFlightIds.insert(contactId) + Task { @MainActor in + defer { inFlightIds.remove(contactId) } + do { + let wallet = try requireWallet() + try await wallet.ignoreContactSender( + ourIdentityId: identity.identityId, + contactIdentityId: contactId + ) + removedOverlayIds.insert(contactId) + } catch { + rowErrors[contactId] = "Ignore failed: \(error.localizedDescription)" + } + } + } + + // MARK: - Display helpers + + private func displayName(for contactId: Data) -> String { + _ = contactMeta.version + return dashPayContactDisplayName( + contactId: contactId, + alias: contactMeta.alias( + network: identity.network, + owner: identity.identityId, + contact: contactId + ), + profileDisplayName: cachedProfile(contactId)?.displayName, + dpnsLabel: contactMeta.dpnsHint( + network: identity.network, + owner: identity.identityId, + contact: contactId + ) + ) + } + + private func cachedProfile(_ contactId: Data) -> DashPayProfile? { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + return nil + } + return dashPayCachedProfile( + wallet: wallet, + ownerIdentityId: identity.identityId, + contactId: contactId + ) + } +} + +// MARK: - Incoming row + +struct IncomingRequestRow: View { + let displayName: String + let avatarUrl: String? + let createdAtMillis: UInt64 + let isInFlight: Bool + let errorMessage: String? + let onAccept: () -> Void + let onIgnore: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + DashPayAvatarView(avatarUrl: avatarUrl, displayName: displayName) + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.headline) + Text(relativeTimestamp(millis: createdAtMillis)) + .font(.caption2) + .foregroundColor(.secondary) + } + Spacer() + } + + if isInFlight { + // Both buttons replaced by a spinner while the + // accept/ignore round-trips. + HStack { + Spacer() + ProgressView() + Spacer() + } + } else { + HStack(spacing: 12) { + Button("Accept", action: onAccept) + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityIdentifier("dashpay.request.accept") + Button("Ignore", action: onIgnore) + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + .accessibilityIdentifier("dashpay.request.ignore") + } + } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Outgoing row + +struct OutgoingRequestRow: View { + let displayName: String + let avatarUrl: String? + /// `nil` for synthetic optimistic rows (no persisted timestamp yet). + let createdAtMillis: UInt64? + + var body: some View { + HStack(spacing: 10) { + DashPayAvatarView(avatarUrl: avatarUrl, displayName: displayName) + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.headline) + if let millis = createdAtMillis { + Text(relativeTimestamp(millis: millis)) + .font(.caption2) + .foregroundColor(.secondary) + } else { + Text("Just now") + .font(.caption2) + .foregroundColor(.secondary) + } + } + Spacer() + Text("Pending") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.orange) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.15)) + .cornerRadius(4) + } + .padding(.vertical, 4) + } +} + +/// "3 min. ago"-style relative timestamp from a Unix-millis value; +/// falls back to "—" for the zero sentinel. +func relativeTimestamp(millis: UInt64) -> String { + guard millis > 0 else { return "—" } + let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1000) + return date.formatted(.relative(presentation: .named)) +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift new file mode 100644 index 00000000000..edc73820e76 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -0,0 +1,299 @@ +import SwiftUI +import SwiftData +import Combine +import SwiftDashSDK + +/// Established-contacts list for the DashPay tab. +/// +/// `@Query`-driven: a contact is *established* when both direction +/// rows exist for the same `(owner, contact)` pair — the Rust +/// `established` map projects both the sent and the incoming +/// request, so the join on `contactIdentityId` is the local +/// equivalent of that map (see the persister's upsert notes on +/// `PersistentDashpayContactRequest`). +struct ContactsView: View { + let identity: PersistentIdentity + + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var contactMeta: DashPayContactMetaStore + + /// Every contact-request row owned by this identity, both + /// directions. Grouped into established pairs in `contacts`. + @Query private var requestRows: [PersistentDashpayContactRequest] + + @State private var searchText = "" + + init(identity: PersistentIdentity) { + self.identity = identity + _requestRows = Query( + filter: PersistentDashpayContactRequest.predicate( + ownerIdentityId: identity.identityId + ) + ) + } + + /// One row per established contact. Alias / hidden come off the + /// contact rows themselves (contactInfo-backed since M3, so they + /// re-render reactively through the `requestRows` query); the + /// DPNS hint stays in the meta store (add-time UI hint, not + /// protocol state); profile display joins the wallet cache. ORs + /// the pair's `paymentChannelBroken` flags. + private var contacts: [EstablishedContactItem] { + // DPNS hints still read through the meta store — tie the + // computation to its published `version` for those edits. + _ = contactMeta.version + let byContact = Dictionary(grouping: requestRows, by: \.contactIdentityId) + return byContact.compactMap { contactId, rows -> EstablishedContactItem? in + guard rows.contains(where: { $0.isOutgoing }), + rows.contains(where: { !$0.isOutgoing }) else { + return nil + } + // contactInfo displayHidden — hidden contacts stay + // established (and payable) but leave the list. + guard !rows.contains(where: \.contactHidden) else { + return nil + } + let profile = cachedProfile(contactId) + let dpnsHint = contactMeta.dpnsHint( + network: identity.network, + owner: identity.identityId, + contact: contactId + ) + let name = dashPayContactDisplayName( + contactId: contactId, + alias: rows.compactMap(\.contactAlias).first, + profileDisplayName: profile?.displayName, + dpnsLabel: dpnsHint + ) + return EstablishedContactItem( + contactId: contactId, + displayName: name, + avatarUrl: profile?.avatarUrl, + dpnsName: dpnsHint, + paymentChannelBroken: rows.contains(where: \.paymentChannelBroken) + ) + } + .sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) + == .orderedAscending + } + } + + private func filtered(_ contacts: [EstablishedContactItem]) -> [EstablishedContactItem] { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return contacts } + return contacts.filter { contact in + contact.displayName.localizedCaseInsensitiveContains(trimmed) + || (contact.dpnsName?.localizedCaseInsensitiveContains(trimmed) ?? false) + || contact.contactId.toHexString().hasPrefix(trimmed.lowercased()) + } + } + + /// Whether this identity has any hidden established contact — gates + /// the "Hidden contacts" recovery link (the only in-app way back to + /// a hidden contact's detail view, where the hide toggle lives). + private var hasHiddenContacts: Bool { + let byContact = Dictionary(grouping: requestRows, by: \.contactIdentityId) + return byContact.contains { _, rows in + rows.contains(where: { $0.isOutgoing }) + && rows.contains(where: { !$0.isOutgoing }) + && rows.contains(where: \.contactHidden) + } + } + + var body: some View { + // Derive the (filtered) row list once per body evaluation — the + // profile cascade behind `contacts` is expensive, so re-deriving + // it for the empty check, the header count, and the ForEach would + // triple the cost on every keystroke. + let rows = filtered(contacts) + // `SwiftUI.Group` — unqualified `Group` resolves to the + // Codable DPP type from SwiftDashSDK. + return SwiftUI.Group { + if rows.isEmpty && searchText.isEmpty && !hasHiddenContacts { + List { + DashPayListEmptyRow( + icon: "person.2.slash", + title: "No contacts yet", + message: "Add your first contact to send Dash by username." + ) + } + .listStyle(.insetGrouped) + } else { + List { + Section { + searchField + ForEach(rows) { contact in + NavigationLink { + ContactDetailView( + identity: identity, + contactId: contact.contactId + ) + } label: { + ContactListRow(contact: contact) + } + .accessibilityIdentifier( + "dashpay.contact.\(contact.contactId.toBase58String())" + ) + } + } header: { + Text("Contacts (\(rows.count))") + } + + if hasHiddenContacts { + Section { + NavigationLink( + value: DashPayHiddenContactsRoute( + ownerIdentityId: identity.identityId + ) + ) { + Label("Hidden contacts", systemImage: "eye.slash") + } + .accessibilityIdentifier("dashpay.openHidden") + } + } + } + .listStyle(.insetGrouped) + } + } + .refreshable { + await attachOrStartSync(walletManager) + } + } + + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search contacts", text: $searchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("dashpay.search") + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + .accessibilityIdentifier("dashpay.search.clear") + } + } + } + + /// Cache-only profile read off the wallet handle (no network). + /// Misses are common — contacts' profiles only populate after a + /// profile sync has seen them. + private func cachedProfile(_ contactId: Data) -> DashPayProfile? { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + return nil + } + return dashPayCachedProfile( + wallet: wallet, + ownerIdentityId: identity.identityId, + contactId: contactId + ) + } +} + +// MARK: - Pull-to-refresh sync attach + +/// Single sync-in-progress signal: a pull-to-refresh during an +/// in-flight sync *attaches* to it (waits for `dashPaySyncIsSyncing` +/// to clear) instead of double-firing; otherwise it starts one pass. +/// Shared by ContactsView and ContactRequestsView. +@MainActor +func attachOrStartSync(_ walletManager: PlatformWalletManager) async { + if walletManager.dashPaySyncIsSyncing { + for await syncing in walletManager.$dashPaySyncIsSyncing.values where !syncing { + break + } + } else { + _ = try? await walletManager.dashPaySyncNow() + } +} + +/// Fire-and-forget kick of a DashPay sync pass after a local mutation +/// (send request / accept / pay). It pulls the counterparty's state and +/// promotes the established pair without waiting for the next background +/// poll tick — so the user isn't left staring at a stale list right +/// after acting. Non-blocking: callers dismiss/continue immediately and +/// the Rust manager folds an in-flight pass into a no-op. +@MainActor +func kickDashPaySync(_ walletManager: PlatformWalletManager) { + Task { _ = try? await walletManager.dashPaySyncNow() } +} + +// MARK: - Row model + view + +/// UI model for one established contact row, resolved from the +/// request-row pair + profile cache + local metadata. +struct EstablishedContactItem: Identifiable { + let contactId: Data + let displayName: String + let avatarUrl: String? + let dpnsName: String? + let paymentChannelBroken: Bool + + var id: Data { contactId } +} + +struct ContactListRow: View { + let contact: EstablishedContactItem + + var body: some View { + HStack(spacing: 10) { + DashPayAvatarView( + avatarUrl: contact.avatarUrl, + displayName: contact.displayName + ) + VStack(alignment: .leading, spacing: 2) { + Text(contact.displayName) + .font(.headline) + Text(contact.dpnsName + ?? String(contact.contactId.toHexString().prefix(12)) + "…") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if contact.paymentChannelBroken { + // Broken payment channel — warning badge; the + // detail view explains and disables Send Dash. + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .accessibilityLabel("Payment channel broken") + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Empty-row helper + +/// Inline empty state rendered as a list row, shared by the +/// Contacts / Requests lists so pull-to-refresh keeps working on an +/// empty list (a bare VStack outside a List loses `.refreshable`). +struct DashPayListEmptyRow: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 40)) + .foregroundColor(.gray) + Text(title) + .font(.headline) + Text(message) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .listRowBackground(Color.clear) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayContactMeta.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayContactMeta.swift new file mode 100644 index 00000000000..18a8f6738db --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayContactMeta.swift @@ -0,0 +1,183 @@ +import Foundation +import SwiftUI +import SwiftDashSDK + +/// Device-local, per-contact metadata for the DashPay tab: alias, +/// note, hidden flag, and a DPNS-label hint captured at add time. +/// +/// These are scoped to "This device only" — a later milestone replaces +/// this store with `contactInfo` documents synced via Platform. Until +/// then UserDefaults is the honest backing: no sync semantics exist, so +/// none are implied. +/// +/// Keys are scoped by `(network, owner identity, contact identity)` +/// so two owner identities (or two networks) never share a contact's +/// alias. The published `version` counter makes SwiftUI views that +/// read through this store re-render after a write — UserDefaults +/// alone doesn't participate in SwiftUI invalidation for computed +/// reads. +@MainActor +final class DashPayContactMetaStore: ObservableObject { + /// Bumped on every write so observing views recompute reads. + @Published private(set) var version = 0 + + private let defaults = UserDefaults.standard + + // MARK: - Alias (local display-name override) + + func alias(network: Network, owner: Data, contact: Data) -> String? { + nonEmpty(defaults.string(forKey: key("alias", network, owner, contact))) + } + + func setAlias(_ alias: String?, network: Network, owner: Data, contact: Data) { + write(nonEmpty(alias), forKey: key("alias", network, owner, contact)) + } + + // MARK: - Note + + func note(network: Network, owner: Data, contact: Data) -> String? { + nonEmpty(defaults.string(forKey: key("note", network, owner, contact))) + } + + func setNote(_ note: String?, network: Network, owner: Data, contact: Data) { + write(nonEmpty(note), forKey: key("note", network, owner, contact)) + } + + // MARK: - Hidden + + func isHidden(network: Network, owner: Data, contact: Data) -> Bool { + defaults.bool(forKey: key("hidden", network, owner, contact)) + } + + func setHidden(_ hidden: Bool, network: Network, owner: Data, contact: Data) { + defaults.set(hidden, forKey: key("hidden", network, owner, contact)) + version += 1 + } + + // MARK: - DPNS hint + + /// DPNS label observed when the contact was added via username + /// search. Display-precedence fallback only — contacts' DPNS + /// labels aren't persisted in SwiftData (only managed identities' + /// are), so this hint is "the data available" for the M2 rows. + func dpnsHint(network: Network, owner: Data, contact: Data) -> String? { + nonEmpty(defaults.string(forKey: key("dpnsHint", network, owner, contact))) + } + + func setDpnsHint(_ name: String?, network: Network, owner: Data, contact: Data) { + write(nonEmpty(name), forKey: key("dpnsHint", network, owner, contact)) + } + + // MARK: - Helpers + + private func key(_ field: String, _ network: Network, _ owner: Data, _ contact: Data) -> String { + "dashpay.meta.\(field).\(network.rawValue).\(owner.toHexString()).\(contact.toHexString())" + } + + private func write(_ value: String?, forKey key: String) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + version += 1 + } + + private func nonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } +} + +// MARK: - Display-name precedence + +/// Resolve the display precedence for a DashPay contact: +/// local alias → DashPay profile `displayName` → DPNS label → +/// truncated hex id. Every input but the id is optional; empty +/// strings count as absent. +func dashPayContactDisplayName( + contactId: Data, + alias: String?, + profileDisplayName: String?, + dpnsLabel: String? +) -> String { + for candidate in [alias, profileDisplayName, dpnsLabel] { + if let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty { + return trimmed + } + } + return String(contactId.toHexString().prefix(12)) + "…" +} + +// MARK: - Cache-only profile read + +/// Cache-only DashPay profile read off a loaded wallet handle (no +/// network). Prefers the per-contact `getContactProfile` entry, then +/// falls back to the global `getDashPayProfile` for the same identity. +/// Misses are common — a contact's profile only populates after a +/// profile sync has seen them — so both throwing calls degrade to nil. +func dashPayCachedProfile( + wallet: ManagedPlatformWallet, + ownerIdentityId: Data, + contactId: Data +) -> DashPayProfile? { + (try? wallet.getContactProfile( + ownerIdentityId: ownerIdentityId, + contactIdentityId: contactId + )) ?? (try? wallet.getDashPayProfile(identityId: contactId)) ?? nil +} + +// MARK: - Txid display order + +/// Hex-encode a raw 32-byte txid in canonical (reversed) display +/// order, matching `PersistentTransaction.txidHex` and the tx list / +/// payment history. The FFI hands back wire/internal byte order, so a +/// bare `toHexString()` reads reversed from block explorers — this +/// flip lines the toasted id up with everything else the user sees. +func txidDisplayHex(_ txid: Data) -> String { + txid.reversed().map { String(format: "%02x", $0) }.joined() +} + +// MARK: - Avatar + +/// Shared avatar bubble: AsyncImage when the profile has an +/// `avatarUrl`, initial-circle fallback otherwise (§6.2). The +/// initial comes from the resolved display name. +struct DashPayAvatarView: View { + let avatarUrl: String? + let displayName: String + var size: CGFloat = 40 + + var body: some View { + if let url = avatarUrl.flatMap({ URL(string: $0) }) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else { + initialCircle + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } else { + initialCircle + .frame(width: size, height: size) + } + } + + private var initialCircle: some View { + Circle() + .fill(Color.blue.opacity(0.2)) + .overlay( + Text(displayName.prefix(1).uppercased()) + .font(size > 50 ? .title : .headline) + .foregroundColor(.blue) + ) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift new file mode 100644 index 00000000000..b3b9aad1a98 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift @@ -0,0 +1,188 @@ +import CoreImage.CIFilterBuiltins +import SwiftDashSDK +import SwiftUI + +/// Read-only DashPay profile sheet, promoted out of +/// `IdentityDetailView`'s inline card: large avatar, display name, +/// DPNS handle, public message, and an Edit button that hands off to +/// `DashPayProfileEditorView` (the tab root presents the editor +/// after this sheet dismisses, via `onEdit`). +struct DashPayProfileView: View { + let identity: PersistentIdentity + let profile: DashPayProfile? + let onEdit: () -> Void + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var walletManager: PlatformWalletManager + + /// DIP-15 auto-accept QR state (generated lazily on appear). + @State private var qrImage: UIImage? + @State private var qrURI: String? + @State private var qrError: String? + + private var displayName: String { + if let name = profile?.displayName? + .trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty { + return name + } + if let dpns = identity.mainDpnsName ?? identity.dpnsName { + return dpns + } + return String(identity.identityIdBase58.prefix(12)) + "…" + } + + var body: some View { + NavigationStack { + List { + Section { + VStack(spacing: 12) { + DashPayAvatarView( + avatarUrl: profile?.avatarUrl, + displayName: displayName, + size: 96 + ) + Text(displayName) + .font(.title2) + .fontWeight(.semibold) + if let dpns = identity.mainDpnsName ?? identity.dpnsName { + Text(dpns) + .font(.subheadline) + .foregroundColor(.blue) + } + if let msg = profile?.publicMessage? + .trimmingCharacters(in: .whitespacesAndNewlines), + !msg.isEmpty { + Text(msg) + .font(.callout) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .listRowBackground(Color.clear) + } + + Section("Identity") { + Text(identity.identityIdBase58) + .font(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + + Section("Add me (DIP-15 QR)") { + if let qrImage { + VStack(spacing: 8) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .padding(8) + .background(Color.white) + .cornerRadius(12) + Text("Scan to send me a contact request — auto-accepted for 1 hour.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + if let qrURI { + Text(qrURI) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + .accessibilityIdentifier("dashpay.profile.qrURI") + } + } + .frame(maxWidth: .infinity) + } else if let qrError { + Text(qrError) + .font(.caption) + .foregroundColor(.orange) + } else { + HStack(spacing: 8) { + ProgressView() + Text("Generating QR…") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .task { await generateAutoAcceptQR() } + + if let url = profile?.avatarUrl? + .trimmingCharacters(in: .whitespacesAndNewlines), + !url.isEmpty { + Section("Avatar URL") { + Text(url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .navigationTitle("Your Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Done") { dismiss() } + .accessibilityIdentifier("dashpay.profile.done") + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + onEdit() + } label: { + Label("Edit", systemImage: "pencil") + } + .accessibilityIdentifier("dashpay.profile.edit") + } + } + } + } + + /// Build the DIP-15 auto-accept QR for this identity (once), via the Rust + /// `buildAutoAcceptQR`. The QR's `du` is the owner's DPNS name; Rust resolves + /// it on-chain when it isn't cached locally, and surfaces a clear error only + /// if no name is registered for the identity at all. + private func generateAutoAcceptQR() async { + guard qrImage == nil, qrError == nil else { return } + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + qrError = "No wallet loaded for this identity." + return + } + // Prefer the locally-cached DPNS name; pass "" so Rust resolves it + // on-chain when the cache is empty (imported/restored identities carry + // the name on-chain but not in the local field). If no name is + // registered at all, the Rust call surfaces a clear error. + let username = (identity.mainDpnsName ?? identity.dpnsName)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + do { + let uri = try await wallet.buildAutoAcceptQR( + ownerIdentityId: identity.identityId, + username: username + ) + qrURI = uri + qrImage = Self.makeQRCode(from: uri) + } catch { + qrError = "Couldn't build the QR: \(error.localizedDescription)" + } + } + + /// Render a string as a QR `UIImage` (native CoreImage generator, scaled 10× + /// for crispness). Mirrors the receive-address QR helper. + private static func makeQRCode(from string: String) -> UIImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + guard + let output = filter.outputImage? + .transformed(by: CGAffineTransform(scaleX: 10, y: 10)), + let cgImage = context.createCGImage(output, from: output.extent) + else { return nil } + return UIImage(cgImage: cgImage) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift new file mode 100644 index 00000000000..934050587a0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -0,0 +1,909 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Root of the DashPay tab: active-identity picker → +/// profile header card → segmented [Contacts | Requests] → toolbar +/// + (AddContactView) and refresh. Owns its own NavigationStack like +/// the other tab wrappers in `ContentView`. +struct DashPayTabView: View { + let network: Network + /// Root tab selection — the empty states deep-link to the + /// Wallets / Identities tabs. + @Binding var selectedTab: RootTab + + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var appState: AppState + @Environment(\.modelContext) private var modelContext + @Environment(\.scenePhase) private var scenePhase + + /// All persisted identities on the active network. Filtered down + /// to wallet-backed, on-network identities in `eligibleIdentities`. + @Query private var identities: [PersistentIdentity] + + /// Selection persists across launches. Stores the base58 + /// id; a stale id (identity deleted / other network) falls back + /// to the first eligible identity in `activeIdentity`. + @AppStorage("dashpay.activeIdentityId") private var storedIdentityId: String = "" + + /// Device-local contact metadata (alias / note / hide / DPNS + /// hint) shared with every child view via the environment. + @StateObject private var contactMeta = DashPayContactMetaStore() + + @State private var segment: DashPaySegment = .contacts + @State private var showAddContact = false + @State private var showAddViaQR = false + + /// Optimistic overlay for *send*: contact ids whose request + /// was just broadcast but whose outgoing row hasn't landed via + /// the persister yet. Rendered as synthetic "Pending" rows in the + /// Outgoing section; pruned there when the query catches up or a + /// sync pass completes. + @State private var optimisticSentIds: Set = [] + + // Own-profile state for the header card (wallet-cache read, same + // pattern as IdentityDetailView's DashPay Profile section). + @State private var ownProfile: DashPayProfile? + @State private var showProfileView = false + @State private var showProfileEditor = false + @State private var pendingEditorAfterProfileView = false + + /// DPNS-username prompt state. `showRegisterName` drives the + /// registration sheet. `usernameResolvedIds` holds identities for + /// which an on-chain lookup has *confirmed* no name — the prompt only + /// shows for those, so an identity that has a username we simply + /// hadn't cached yet never gets nagged to register one. + @State private var showRegisterName = false + @State private var usernameResolvedIds: Set = [] + /// Surfaces a failed/declined "finish setup" unlock so the banner tap + /// isn't a silent no-op (wrong seed, or a watch-only wallet with no + /// Keychain mnemonic). + @State private var unlockError: String? + + /// Effective-foreground tracking for the sync cadence: the tab is on + /// screen (`tabVisible`) **and** the app is active (`scenePhase`). + /// `syncForeground` is the last cadence we applied, so we only act on + /// transitions (and kick at most once per entry). + @State private var tabVisible = false + @State private var syncForeground = false + + enum DashPaySegment: Hashable { + case contacts, requests + } + + /// Background sync cadence. While the DashPay tab is foreground we + /// poll fast so a contact's request / acceptance / payment surfaces + /// in near real time; we relax to the standard interval when the tab + /// is backgrounded so an idle app isn't sweeping every few seconds. + private static let foregroundSyncSeconds: UInt64 = 4 + private static let backgroundSyncSeconds: UInt64 = 15 + + init(network: Network, selectedTab: Binding) { + self.network = network + _selectedTab = selectedTab + let raw = network.rawValue + _identities = Query( + filter: #Predicate { $0.networkRaw == raw }, + sort: [SortDescriptor(\PersistentIdentity.createdAt)] + ) + } + + /// Identities the DashPay tab can act as: on-network (not + /// local-only) and backed by a wallet that's currently loaded in + /// the manager — every DashPay FFI call resolves through that + /// wallet handle. + private var eligibleIdentities: [PersistentIdentity] { + identities.filter { identity in + guard !identity.isLocal, + let walletId = identity.wallet?.walletId else { return false } + return walletManager.wallet(for: walletId) != nil + } + } + + /// Stale-id fallback: stored selection wins when still + /// eligible, else the first eligible identity. + private var activeIdentity: PersistentIdentity? { + if let match = eligibleIdentities.first(where: { + $0.identityIdBase58 == storedIdentityId + }) { + return match + } + return eligibleIdentities.first + } + + var body: some View { + NavigationStack { + content + .navigationTitle("DashPay") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + refresh() + } label: { + Image(systemName: "arrow.clockwise") + .symbolEffect( + .rotate, + options: .nonRepeating, + isActive: walletManager.dashPaySyncIsSyncing + ) + } + .disabled(walletManager.dashPaySyncIsSyncing) + .accessibilityIdentifier("dashpay.refresh") + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showAddContact = true + } label: { + Image(systemName: "person.badge.plus") + } + .disabled(activeIdentity == nil) + .accessibilityIdentifier("dashpay.addContact") + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showAddViaQR = true + } label: { + Image(systemName: "qrcode.viewfinder") + } + .disabled(activeIdentity == nil) + .accessibilityIdentifier("dashpay.addViaQR") + } + ToolbarItem(placement: .navigationBarLeading) { + if let identity = activeIdentity { + NavigationLink { + IgnoredContactsView(identity: identity) + .environmentObject(walletManager) + } label: { + Image(systemName: "person.crop.circle.badge.xmark") + } + .accessibilityIdentifier("dashpay.openIgnored") + } + } + } + .sheet(isPresented: $showAddViaQR) { + if let identity = activeIdentity { + AddViaQRSheet(identity: identity) + .environmentObject(walletManager) + } + } + .sheet(isPresented: $showAddContact) { + if let identity = activeIdentity { + AddContactView( + identity: identity, + onSent: { recipientId, dpnsName in + optimisticSentIds.insert(recipientId) + if let dpnsName { + contactMeta.setDpnsHint( + dpnsName, + network: identity.network, + owner: identity.identityId, + contact: recipientId + ) + } + } + ) + .environmentObject(walletManager) + } + } + .sheet( + isPresented: $showProfileView, + onDismiss: { + if pendingEditorAfterProfileView { + pendingEditorAfterProfileView = false + showProfileEditor = true + } + } + ) { + if let identity = activeIdentity { + DashPayProfileView( + identity: identity, + profile: ownProfile, + onEdit: { + pendingEditorAfterProfileView = true + showProfileView = false + } + ) + .environmentObject(walletManager) + } + } + .sheet(isPresented: $showProfileEditor) { + if let identity = activeIdentity { + DashPayProfileEditorView( + identityId: identity.identityId, + walletId: identity.wallet?.walletId, + existing: ownProfile, + onSaved: { saved in + ownProfile = saved + } + ) + .environmentObject(walletManager) + } + } + .sheet(isPresented: $showRegisterName) { + if let identity = activeIdentity { + // RegisterNameView brings its own NavigationView + + // Cancel toolbar. On success, persist the new name onto + // the identity's scalar `dpnsName` and drop it from the + // resolved set so this prompt hides on the next render. + // The Rust register path only upserts `PersistentDPNSName` + // relationship rows — not the scalar `dpnsName` / + // `mainDpnsName` this prompt (and the header) read — so + // without this the CTA would linger until the next + // identity switch or app-foreground re-check. + RegisterNameView(identity: identity, onRegistered: { label in + PersistentIdentity.updateDpnsName( + in: modelContext, + identityId: identity.identityId, + dpnsName: label + ) + usernameResolvedIds.remove(identity.identityId) + try? modelContext.save() + }) + .environmentObject(walletManager) + .environmentObject(appState) + } + } + // Value-based push for the hidden-contacts recovery + // screen. Declared on the stack root (not on ContactsView) + // so the destination builds only on navigate — a + // closure-based link inside the frequently-syncing + // Contacts list would rebuild it on every @Query + // re-render. + .navigationDestination(for: DashPayHiddenContactsRoute.self) { route in + if let identity = eligibleIdentities.first(where: { + $0.identityId == route.ownerIdentityId + }) { + HiddenContactsView(identity: identity) + .environmentObject(walletManager) + } + } + } + .environmentObject(contactMeta) + .onAppear { tabVisible = true; refreshSyncCadence() } + .onDisappear { tabVisible = false; refreshSyncCadence() } + .onChange(of: scenePhase) { _, phase in + refreshSyncCadence() + // Re-confirm the username on app-foreground: `.task` already + // re-runs on tab re-appearance, but a name registered on + // another device while the user sits on this tab would + // otherwise keep the "register a username" prompt up until + // the next tab switch. (A name registered in-app hides the + // prompt immediately via @Query.) + if phase == .active { + Task { await resolveUsernameIfNeeded(for: activeIdentity) } + } + } + .alert( + "Couldn't finish setup", + isPresented: Binding( + get: { unlockError != nil }, + set: { if !$0 { unlockError = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(unlockError ?? "") + } + .task(id: activeIdentity?.identityId) { + loadOwnProfileFromCache() + // Kick one sweep so a fresh launch shows current data + // without waiting for the background loop's next tick. + // The Rust manager dedupes — an in-flight pass makes + // this a no-op sentinel return. + _ = try? await walletManager.dashPaySyncNow() + loadOwnProfileFromCache() + await resolveUsernameIfNeeded(for: activeIdentity) + } + .onChange(of: walletManager.dashPaySyncIsSyncing) { _, syncing in + // Re-read the own-profile cache after every completed + // sync pass — the background loop may have refreshed it. + if !syncing { + loadOwnProfileFromCache() + } + } + .onChange(of: storedIdentityId) { _, _ in + // The optimistic pending-sent overlay is per-identity + // state — without this reset, a send from identity A + // ghosts as an outgoing row under identity B after a + // picker switch. + optimisticSentIds.removeAll() + } + } + + // MARK: - Content states (§6.4 identity picker) + + @ViewBuilder + private var content: some View { + if walletManager.wallets.isEmpty { + // State 1: no wallet loaded. + DashPayEmptyStateView( + icon: "wallet.pass", + title: "No wallet loaded", + message: "Load or create a wallet to use DashPay.", + buttonTitle: "Open Wallets", + buttonIdentifier: "dashpay.openWallets", + action: { selectedTab = .wallets } + ) + } else if eligibleIdentities.isEmpty { + // State 2: wallet present, zero usable identities. + DashPayEmptyStateView( + icon: "person.crop.circle.badge.questionmark", + title: "No identities yet", + message: "Register an identity to start using DashPay.", + buttonTitle: "Open Identities", + buttonIdentifier: "dashpay.openIdentities", + action: { selectedTab = .identities } + ) + } else if let identity = activeIdentity { + // State 3: ≥1 identity → picker (hidden when exactly one). + VStack(spacing: 0) { + if eligibleIdentities.count > 1 { + identityPicker(active: identity) + } + + profileHeaderCard(identity: identity) + + usernamePromptCard(identity: identity) + + dashPayBalanceRow(identity: identity) + + dashPayUnlockBanner(identity: identity) + + Picker("Section", selection: $segment) { + Text("Contacts").tag(DashPaySegment.contacts) + Text("Requests").tag(DashPaySegment.requests) + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.bottom, 6) + .accessibilityIdentifier("dashpay.segment") + + switch segment { + case .contacts: + ContactsView(identity: identity) + .id(identity.identityId) + case .requests: + ContactRequestsView( + identity: identity, + optimisticSentIds: $optimisticSentIds + ) + .id(identity.identityId) + } + } + } + } + + // MARK: - Needs-unlock / verify-failed banner + + /// Surfaces the DashPay needs-unlock / verify-failed signal for the active + /// identity's wallet. Priority: a seed mismatch (signing disabled) supersedes + /// everything; an in-flight drain shows a non-actionable "finishing" state; + /// otherwise a pending account-build backlog offers Unlock. Nothing renders + /// on a healthy wallet. The count is wallet-scoped (may include sibling + /// identities on the same wallet), so the copy says "waiting," not a promise. + @ViewBuilder + private func dashPayUnlockBanner(identity: PersistentIdentity) -> some View { + if let walletId = identity.wallet?.walletId, + let status = walletManager.dashPayUnlockStatus[walletId], + status.hasSignal { + if status.seedMismatch { + unlockBannerRow( + text: "Seed verification failed — this wallet's Keychain seed " + + "doesn't match. DashPay signing is disabled.", + systemImage: "exclamationmark.triangle.fill", + tint: .red, + action: nil + ) + } else if status.draining { + unlockBannerRow( + text: "Finishing contact setup…", + systemImage: "hourglass", + tint: .orange, + action: nil + ) + } else if status.pendingAccountBuilds > 0 { + let n = Int(status.pendingAccountBuilds) + unlockBannerRow( + text: "\(n) contact\(n == 1 ? "" : "s") waiting to finish setup", + systemImage: "lock.fill", + tint: .orange, + action: walletManager.wallet(for: walletId).map { wallet in + { + // Don't swallow: a wrong-seed mismatch throws and a + // watch-only wallet returns false — both must tell + // the user why tapping "finish setup" did nothing. + do { + let unlocked = try walletManager.unlockWalletFromKeychain(wallet) + if !unlocked { + unlockError = "This wallet is watch-only on this device " + + "(no mnemonic in the Keychain), so contact setup " + + "can't be finished here." + } + } catch { + unlockError = error.localizedDescription + } + } + } + ) + } + } + } + + /// One banner row: an icon + message tinted by severity, with an optional + /// trailing Unlock button. Mirrors the inline `paymentChannelBroken` warning + /// styling (icon + `.orange`/`.red`), promoted to a tappable container. + private func unlockBannerRow( + text: String, + systemImage: String, + tint: Color, + action: (() -> Void)? + ) -> some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .foregroundColor(tint) + Text(text) + .font(.caption) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + if let action { + Button("Unlock", action: action) + .font(.caption.bold()) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(10) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + .padding(.bottom, 6) + .accessibilityIdentifier("dashpay.unlockBanner") + } + + // MARK: - Identity picker + + private func identityPicker(active: PersistentIdentity) -> some View { + Menu { + ForEach(eligibleIdentities, id: \.identityId) { identity in + Button { + storedIdentityId = identity.identityIdBase58 + } label: { + if identity.identityId == active.identityId { + Label(pickerLabel(for: identity), systemImage: "checkmark") + } else { + Text(pickerLabel(for: identity)) + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "person.crop.circle") + Text(pickerLabel(for: active)) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + } + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } + .padding(.horizontal) + .padding(.top, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("dashpay.identityPicker") + } + + /// Menu rows show "DPNS name → truncated id". + private func pickerLabel(for identity: PersistentIdentity) -> String { + if let name = identity.mainDpnsName ?? identity.dpnsName, !name.isEmpty { + return name + } + if let alias = identity.alias, !alias.isEmpty { + return alias + } + return String(identity.identityIdBase58.prefix(12)) + "…" + } + + // MARK: - Profile header card + + @ViewBuilder + private func profileHeaderCard(identity: PersistentIdentity) -> some View { + if let profile = ownProfile { + Button { + showProfileView = true + } label: { + HStack(spacing: 12) { + DashPayAvatarView( + avatarUrl: profile.avatarUrl, + displayName: headerDisplayName(identity: identity, profile: profile), + size: 48 + ) + VStack(alignment: .leading, spacing: 2) { + Text(headerDisplayName(identity: identity, profile: profile)) + .font(.headline) + .foregroundColor(.primary) + if let dpns = identity.mainDpnsName ?? identity.dpnsName { + Text(dpns) + .font(.caption) + .foregroundColor(.secondary) + } + if let msg = profile.publicMessage? + .trimmingCharacters(in: .whitespacesAndNewlines), + !msg.isEmpty { + Text(msg) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(Color.blue.opacity(0.06)) + .cornerRadius(12) + } + .buttonStyle(.plain) + .padding(.horizontal) + .padding(.vertical, 8) + .accessibilityIdentifier("dashpay.profileHeader") + } else { + // Empty state → CTA straight into the editor sheet + // (same target as "Edit"). + Button { + showProfileEditor = true + } label: { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle.dashed") + .font(.title2) + .foregroundColor(.blue) + VStack(alignment: .leading, spacing: 2) { + Text("Set up your DashPay profile") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(Color.blue.opacity(0.06)) + .cornerRadius(12) + } + .buttonStyle(.plain) + .padding(.horizontal) + .padding(.vertical, 8) + .accessibilityIdentifier("dashpay.profileHeader.setup") + } + } + + // MARK: - Username (DPNS) prompt + + /// Prompt to register a DPNS username — the searchable handle other + /// users type to find and add you. Shown only once an on-chain check + /// has confirmed the active identity has no name (see + /// `resolveUsernameIfNeeded`), so an identity that already has one is + /// never nagged. Distinct from the profile card: the profile's + /// display name is cosmetic and not searchable. + @ViewBuilder + private func usernamePromptCard(identity: PersistentIdentity) -> some View { + let hasName = (identity.mainDpnsName ?? identity.dpnsName) + .map { !$0.isEmpty } ?? false + if !hasName, usernameResolvedIds.contains(identity.identityId) { + Button { + showRegisterName = true + } label: { + HStack(spacing: 12) { + Image(systemName: "at.badge.plus") + .font(.title2) + .foregroundColor(.blue) + VStack(alignment: .leading, spacing: 2) { + Text("Register a username") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + Text( + "Without a username, people can't find you by name to send " + + "a request. Your profile name is just a display name — " + + "it isn't searchable." + ) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(Color.blue.opacity(0.06)) + .cornerRadius(12) + } + .buttonStyle(.plain) + .padding(.horizontal) + .padding(.vertical, 8) + .accessibilityIdentifier("dashpay.usernamePrompt") + } + } + + // MARK: - DashPay balance + + /// Funds received from contacts — the sum of this identity's + /// `DashpayReceivingFunds` account balances (type tag 12), read + /// lock-free from Rust's in-memory account state (same call the + /// wallet account list uses). These coins already count toward + /// the wallet's Core Balance; this row answers the + /// DashPay-specific question "how much have contacts sent me". + private func dashPayBalanceRow(identity: PersistentIdentity) -> some View { + let duffs: UInt64 = { + guard let walletId = identity.wallet?.walletId else { return 0 } + return walletManager.accountBalances(for: walletId) + .filter { $0.typeTag == 12 && $0.userIdentityId == identity.identityId } + .reduce(0) { $0 + $1.confirmed + $1.unconfirmed } + }() + return HStack { + Label("Received from contacts", systemImage: "arrow.down.left.circle") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(String(format: "%.8f DASH", Double(duffs) / 100_000_000)) + .font(.caption) + .fontWeight(.medium) + } + .padding(.horizontal) + .padding(.bottom, 6) + .accessibilityIdentifier("dashpay.receivedBalance") + } + + private func headerDisplayName( + identity: PersistentIdentity, + profile: DashPayProfile + ) -> String { + if let name = profile.displayName? + .trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty { + return name + } + if let dpns = identity.mainDpnsName ?? identity.dpnsName { + return dpns + } + return String(identity.identityIdBase58.prefix(12)) + "…" + } + + // MARK: - Actions + + /// Resolve the active identity's DashPay profile. Prefers the live + /// wallet-handle cache (freshest), but falls back to the PERSISTED + /// profile so an identity that already has a profile never shows the + /// "set up profile" CTA just because its profile hasn't been synced + /// into the in-memory cache this session — which happens on cold + /// restore or right after switching the picker among many identities. + /// `PersistentIdentity.dashpayProfile` is the source of truth for + /// "does this identity have a profile"; the persister only writes it + /// after a profile has been created/synced. Lock-free; no network. + private func loadOwnProfileFromCache() { + guard let identity = activeIdentity else { + ownProfile = nil + return + } + if let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId), + let managed = try? wallet.managedIdentity(identityId: identity.identityId), + let cached = try? managed.getDashPayProfile() { + ownProfile = cached + return + } + ownProfile = identity.dashpayProfile.map { persisted in + DashPayProfile( + displayName: persisted.displayName, + publicMessage: persisted.publicMessage, + avatarUrl: persisted.avatarUrl, + avatarHash: persisted.avatarHash, + avatarFingerprint: persisted.avatarFingerprint + ) + } + } + + /// Confirm whether the active identity has a DPNS username, so the + /// "register a username" prompt only nags identities that genuinely + /// have none. If a name exists on-chain but wasn't cached, persist it + /// (the prompt stays hidden); if the lookup definitively returns + /// none, mark the id resolved so the prompt can show. A thrown error + /// (offline / transient) leaves it unresolved to retry on a later + /// pass — we never prompt on an unverified guess. Mirrors the lazy + /// DPNS fetch in `IdentitiesView`. + private func resolveUsernameIfNeeded(for identity: PersistentIdentity?) async { + guard let identity else { return } + if let name = identity.mainDpnsName ?? identity.dpnsName, !name.isEmpty { + return + } + guard let sdk = appState.sdk else { return } + do { + let usernames = try await sdk.dpnsGetUsername( + identityId: identity.identityIdBase58, + limit: 1 + ) + if let label = usernames.first?["label"] as? String, !label.isEmpty { + PersistentIdentity.updateDpnsName( + in: modelContext, + identityId: identity.identityId, + dpnsName: label + ) + // updateDpnsName leaves persistence to the caller (matches + // IdentitiesView's lazy fetch) — flush so a name we just + // re-discovered survives a kill before the next autosave. + try? modelContext.save() + } else { + usernameResolvedIds.insert(identity.identityId) + } + } catch { + // Unverified (offline / transient) — leave unresolved so a + // later pass retries rather than nagging on a guess. + } + } + + /// Toolbar refresh — fires one sweep through the manager. The + /// Rust side skips if a pass is already in flight (§6.4 single + /// sync-in-progress signal), and the button is disabled while + /// `dashPaySyncIsSyncing` anyway. + private func refresh() { + Task { @MainActor in + _ = try? await walletManager.dashPaySyncNow() + } + } + + /// Tune the background sync loop's cadence to *effective foreground* = + /// the tab is on screen AND the app is active. Fast (4s) while the + /// user is actually looking, relaxed (15s) otherwise — so neither a + /// tab switch nor app-backgrounding leaves an idle app sweeping every + /// few seconds. Driven from the NavigationStack's appear/disappear + /// (so drilling into a child screen or presenting a sheet, which don't + /// fire the stack's `onDisappear`, keep the fast cadence) plus + /// `scenePhase`. + /// + /// On entering the foreground we also kick one sweep: + /// `setDashPaySyncInterval` only takes effect on the loop's *next* + /// sleep, so without the kick a tab re-entry could wait out a leftover + /// long sleep before the first fast tick. Acts only on transitions, so + /// the kick fires at most once per entry. Best-effort — a + /// not-yet-configured manager keeps its interval, and the kick no-ops + /// when a pass is already in flight. + private func refreshSyncCadence() { + let foreground = tabVisible && scenePhase == .active + guard foreground != syncForeground else { return } + syncForeground = foreground + try? walletManager.setDashPaySyncInterval( + seconds: foreground ? Self.foregroundSyncSeconds : Self.backgroundSyncSeconds + ) + if foreground { + kickDashPaySync(walletManager) + } + } +} + +// MARK: - Empty-state helper + +/// Shared empty-state body for the picker states: icon, title, +/// message, and a single CTA that deep-links to another tab. +struct DashPayEmptyStateView: View { + let icon: String + let title: String + let message: String + let buttonTitle: String + let buttonIdentifier: String + let action: () -> Void + + var body: some View { + VStack(spacing: 16) { + Spacer() + Image(systemName: icon) + .font(.system(size: 50)) + .foregroundColor(.gray) + Text(title) + .font(.title3) + .fontWeight(.medium) + Text(message) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + Button(buttonTitle, action: action) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier(buttonIdentifier) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + +/// Send a contact request from a DIP-15 auto-accept QR URI. The user pastes the +/// `dash:?du=…&dapk=…` URI (from another user's "Add me" QR; a camera scan would +/// produce the same string); the Rust side resolves the username, signs the +/// proof, and broadcasts, so the QR owner auto-accepts. +private struct AddViaQRSheet: View { + let identity: PersistentIdentity + + @EnvironmentObject private var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var uri = "" + @State private var isSending = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + Form { + Section { + TextField("dash:?du=…&dapk=…", text: $uri, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .lineLimit(2...4) + .accessibilityIdentifier("dashpay.qr.uriField") + } header: { + Text("Paste an auto-accept QR URI") + } footer: { + Text( + "From another user's “Add me (DIP-15 QR)”. They auto-accept " + + "your request for as long as their QR is valid." + ) + } + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + .navigationTitle("Add via QR") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if isSending { + ProgressView() + } else { + Button("Send") { send() } + .disabled(uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("dashpay.qr.send") + } + } + } + } + } + + private func send() { + let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + isSending = true + errorMessage = nil + Task { @MainActor in + defer { isSending = false } + do { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + errorMessage = "No wallet loaded for this identity." + return + } + let signer = KeychainSigner(modelContainer: modelContext.container) + _ = try await wallet.sendContactRequestFromQR( + senderIdentityId: identity.identityId, + uri: trimmed, + signer: signer + ) + kickDashPaySync(walletManager) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/HiddenContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/HiddenContactsView.swift new file mode 100644 index 00000000000..0f95ea486aa --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/HiddenContactsView.swift @@ -0,0 +1,240 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Value-based navigation route to `HiddenContactsView`. Declared as a +/// `.navigationDestination(for:)` on the DashPay tab's stack root so the +/// push from the Contacts list builds the destination only on navigate — +/// a closure-based `NavigationLink` would rebuild it on every `@Query` +/// re-render of the frequently-syncing Contacts list. +struct DashPayHiddenContactsRoute: Hashable { + let ownerIdentityId: Data +} + +/// The "Hidden" screen for the DashPay tab. +/// +/// Lists every established contact this identity has hidden +/// (`contactInfo.displayHidden`, reversible, synced cross-device) with +/// an **Unhide** action. Hidden ≠ removed — these contacts stay +/// established and payable but leave the main Contacts list; this +/// screen makes them recoverable (without it, hiding a contact removes +/// the only row that links to `ContactDetailView`, where the hide +/// toggle lives, so hiding would be a one-way trip). +/// +/// `@Query`-driven off `PersistentDashpayContactRequest` (both +/// directions), grouped into established pairs the same way +/// `ContactsView` does. Name + avatar resolve through the shared +/// `dashPayCachedProfile` cache the Contacts list uses. +struct HiddenContactsView: View { + let identity: PersistentIdentity + + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var contactMeta: DashPayContactMetaStore + @Environment(\.modelContext) private var modelContext + + /// Every contact-request row owned by this identity, both + /// directions. Grouped into established pairs in `hiddenContacts`. + @Query private var requestRows: [PersistentDashpayContactRequest] + + /// Contact ids with an unhide currently in flight — the row's + /// button is replaced by a spinner while the round-trip runs. + @State private var inFlightIds: Set = [] + + /// Optimistic removal overlay: an unhidden contact stops rendering + /// immediately (the persister clears the flag shortly after). + @State private var removedOverlayIds: Set = [] + + /// Per-row inline errors. + @State private var rowErrors: [Data: String] = [:] + + init(identity: PersistentIdentity) { + self.identity = identity + _requestRows = Query( + filter: PersistentDashpayContactRequest.predicate( + ownerIdentityId: identity.identityId + ) + ) + } + + /// One row per established, hidden contact — the exact + /// complement of `ContactsView.contacts` (which drops these). + private var hiddenContacts: [HiddenContactItem] { + _ = contactMeta.version + let byContact = Dictionary(grouping: requestRows, by: \.contactIdentityId) + return byContact.compactMap { contactId, rows -> HiddenContactItem? in + guard rows.contains(where: { $0.isOutgoing }), + rows.contains(where: { !$0.isOutgoing }), + rows.contains(where: \.contactHidden), + !removedOverlayIds.contains(contactId) else { + return nil + } + let profile = cachedProfile(contactId) + let dpnsHint = contactMeta.dpnsHint( + network: identity.network, + owner: identity.identityId, + contact: contactId + ) + let alias = rows.compactMap(\.contactAlias).first + let name = dashPayContactDisplayName( + contactId: contactId, + alias: alias, + profileDisplayName: profile?.displayName, + dpnsLabel: dpnsHint + ) + return HiddenContactItem( + contactId: contactId, + displayName: name, + avatarUrl: profile?.avatarUrl, + alias: alias, + note: rows.compactMap(\.contactNote).first + ) + } + .sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) + == .orderedAscending + } + } + + var body: some View { + // `SwiftUI.Group` — unqualified `Group` resolves to the Codable + // DPP type from SwiftDashSDK. + SwiftUI.Group { + if hiddenContacts.isEmpty { + List { + DashPayListEmptyRow( + icon: "eye", + title: "No hidden contacts", + message: "Contacts you hide stay payable but leave your Contacts list, and are listed here so you can unhide them." + ) + } + .listStyle(.insetGrouped) + } else { + List { + Section { + ForEach(hiddenContacts) { contact in + HiddenContactRow( + displayName: contact.displayName, + avatarUrl: contact.avatarUrl, + isInFlight: inFlightIds.contains(contact.contactId), + errorMessage: rowErrors[contact.contactId], + onUnhide: { unhide(contact) } + ) + } + } header: { + Text("Hidden (\(hiddenContacts.count))") + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("Hidden") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Actions + + private func requireWallet() throws -> ManagedPlatformWallet { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + throw PlatformWalletError.walletOperation( + "No loaded wallet for identity \(identity.identityIdBase58)" + ) + } + return wallet + } + + /// Republish the contact's `contactInfo` with `hidden = false`, + /// preserving the existing alias/note so unhiding doesn't wipe + /// them. Same pipeline `ContactDetailView`'s hide toggle uses. + private func unhide(_ contact: HiddenContactItem) { + rowErrors[contact.contactId] = nil + inFlightIds.insert(contact.contactId) + Task { @MainActor in + defer { inFlightIds.remove(contact.contactId) } + do { + let wallet = try requireWallet() + let signer = KeychainSigner(modelContainer: modelContext.container) + _ = try await wallet.setDashPayContactInfo( + identityId: identity.identityId, + contactId: contact.contactId, + alias: contact.alias, + note: contact.note, + hidden: false, + signer: signer + ) + // Optimistic removal — the persister clears the flag on + // the rows shortly after and the contact returns to the + // main Contacts list. + removedOverlayIds.insert(contact.contactId) + kickDashPaySync(walletManager) + } catch { + rowErrors[contact.contactId] = "Unhide failed: \(error.localizedDescription)" + } + } + } + + // MARK: - Display helpers + + /// Cache-only profile read off the wallet handle (no network). A + /// miss is common — falls back to the truncated id. + private func cachedProfile(_ contactId: Data) -> DashPayProfile? { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + return nil + } + return dashPayCachedProfile( + wallet: wallet, + ownerIdentityId: identity.identityId, + contactId: contactId + ) + } +} + +// MARK: - Row model + view + +/// UI model for one hidden contact row. Carries the current alias/note +/// so unhide can republish `contactInfo` without dropping them. +struct HiddenContactItem: Identifiable { + let contactId: Data + let displayName: String + let avatarUrl: String? + let alias: String? + let note: String? + + var id: Data { contactId } +} + +struct HiddenContactRow: View { + let displayName: String + let avatarUrl: String? + let isInFlight: Bool + let errorMessage: String? + let onUnhide: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + DashPayAvatarView(avatarUrl: avatarUrl, displayName: displayName) + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.headline) + } + Spacer() + if isInFlight { + ProgressView() + } else { + Button("Unhide", action: onUnhide) + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("dashpay.hidden.unhide") + } + } + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + .padding(.vertical, 4) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/IgnoredContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/IgnoredContactsView.swift new file mode 100644 index 00000000000..91d9f25e64e --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/IgnoredContactsView.swift @@ -0,0 +1,181 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// The "Ignored" screen for the DashPay tab. +/// +/// Lists every sender this identity has ignored (per-sender mute, = block, +/// reversible, local-only) with an **Un-ignore** action. Ignored ≠ +/// invisible — these senders are just hidden from the main pending list; +/// this screen makes them recoverable. +/// +/// `@Query`-driven off `PersistentDashpayIgnoredSender` (the SwiftData +/// mirror of the Rust `ignored_senders` set). Name + avatar resolve through +/// the same `getContactProfile` cache the Contacts list uses (falling back +/// to the truncated id when the contact's profile hasn't been fetched). +struct IgnoredContactsView: View { + let identity: PersistentIdentity + + @EnvironmentObject var walletManager: PlatformWalletManager + + /// Every ignored-sender row owned by this identity. + @Query private var ignoredRows: [PersistentDashpayIgnoredSender] + + /// Sender ids with an un-ignore currently in flight — the row's + /// button is replaced by a spinner while the round-trip runs. + @State private var inFlightIds: Set = [] + + /// Optimistic removal overlay: an un-ignored sender stops rendering + /// immediately (the persister deletes the row shortly after). + @State private var removedOverlayIds: Set = [] + + /// Per-row inline errors. + @State private var rowErrors: [Data: String] = [:] + + init(identity: PersistentIdentity) { + self.identity = identity + _ignoredRows = Query( + filter: PersistentDashpayIgnoredSender.predicate( + ownerIdentityId: identity.identityId + ), + sort: \PersistentDashpayIgnoredSender.ignoredAt, + order: .reverse + ) + } + + private var visibleRows: [PersistentDashpayIgnoredSender] { + ignoredRows.filter { !removedOverlayIds.contains($0.ignoredSenderId) } + } + + var body: some View { + // `SwiftUI.Group` — unqualified `Group` resolves to the Codable + // DPP type from SwiftDashSDK. + SwiftUI.Group { + if visibleRows.isEmpty { + List { + DashPayListEmptyRow( + icon: "person.crop.circle.badge.checkmark", + title: "No ignored contacts", + message: "Senders you ignore are hidden from your pending requests and listed here so you can un-ignore them." + ) + } + .listStyle(.insetGrouped) + } else { + List { + Section { + ForEach(visibleRows, id: \.ignoredSenderId) { row in + IgnoredSenderRow( + displayName: displayName(for: row.ignoredSenderId), + avatarUrl: cachedProfile(row.ignoredSenderId)?.avatarUrl, + isInFlight: inFlightIds.contains(row.ignoredSenderId), + errorMessage: rowErrors[row.ignoredSenderId], + onUnignore: { unignore(senderId: row.ignoredSenderId) } + ) + } + } header: { + Text("Ignored (\(visibleRows.count))") + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("Ignored") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Actions + + private func requireWallet() throws -> ManagedPlatformWallet { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + throw PlatformWalletError.walletOperation( + "No loaded wallet for identity \(identity.identityIdBase58)" + ) + } + return wallet + } + + private func unignore(senderId: Data) { + rowErrors[senderId] = nil + inFlightIds.insert(senderId) + Task { @MainActor in + defer { inFlightIds.remove(senderId) } + do { + let wallet = try requireWallet() + try await wallet.unignoreContactSender( + ourIdentityId: identity.identityId, + contactIdentityId: senderId + ) + // Optimistic removal — the persister deletes the row and + // the Rust side rewinds the cursor so the sender's + // requests re-fetch on the next sweep. + removedOverlayIds.insert(senderId) + } catch { + rowErrors[senderId] = "Un-ignore failed: \(error.localizedDescription)" + } + } + } + + // MARK: - Display helpers + + private func displayName(for contactId: Data) -> String { + dashPayContactDisplayName( + contactId: contactId, + alias: nil, + profileDisplayName: cachedProfile(contactId)?.displayName, + dpnsLabel: nil + ) + } + + /// Cache-only profile read off the wallet handle (no network). A miss + /// is common — falls back to the truncated id via + /// `dashPayContactDisplayName`. + private func cachedProfile(_ contactId: Data) -> DashPayProfile? { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + return nil + } + return dashPayCachedProfile( + wallet: wallet, + ownerIdentityId: identity.identityId, + contactId: contactId + ) + } +} + +// MARK: - Row + +struct IgnoredSenderRow: View { + let displayName: String + let avatarUrl: String? + let isInFlight: Bool + let errorMessage: String? + let onUnignore: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + DashPayAvatarView(avatarUrl: avatarUrl, displayName: displayName) + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.headline) + } + Spacer() + if isInFlight { + ProgressView() + } else { + Button("Un-ignore", action: onUnignore) + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("dashpay.ignored.unignore") + } + } + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + .padding(.vertical, 4) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/SendDashPayPaymentSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/SendDashPayPaymentSheet.swift new file mode 100644 index 00000000000..23d1af6e8e5 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/SendDashPayPaymentSheet.swift @@ -0,0 +1,386 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Lightweight UI model for an established DashPay contact row, +/// consumed by `ContactDetailView` + `SendDashPayPaymentSheet`. +/// The cached DashPay profile fields are resolved separately via +/// `wallet.getDashPayProfile(identityId:)`. +struct DashPayContact: Identifiable { + let id: Data + let displayName: String + let identityId: Data + let dpnsName: String? + let note: String? + let isHidden: Bool + + init( + id: Data, + displayName: String, + identityId: Data, + dpnsName: String? = nil, + note: String? = nil, + isHidden: Bool = false + ) { + self.id = id + self.displayName = displayName + self.identityId = identityId + self.dpnsName = dpnsName + self.note = note + self.isHidden = isHidden + } +} + +// MARK: - Send payment sheet + +/// Modal sheet for sending a Dash payment to an established DashPay +/// contact via the platform-wallet FFI +/// (`ManagedPlatformWallet.sendDashPayPayment`). Rust handles the +/// address derivation (DIP-14) + Core-chain broadcast + recording +/// a `PaymentEntry` on the sender's `ManagedIdentity`; the +/// identity changeset callback (5f5ac06d6) forwards the state +/// update to SwiftData. +struct SendDashPayPaymentSheet: View { + let senderIdentity: PersistentIdentity + let contact: DashPayContact + /// Fires with the 32-byte txid once the transaction has been + /// broadcast. Parent uses this to refresh its contact state + /// (recording a payment may auto-establish a contact on the + /// Rust side if a reciprocal request just came in). + let onSent: () -> Void + + @EnvironmentObject var walletManager: PlatformWalletManager + @Environment(\.dismiss) private var dismiss + + /// Input in DASH — we convert to duffs before handing off to + /// the FFI. Keeping the field in DASH makes the units + /// human-readable (a 0.001 DASH payment is `0.001` in the + /// field, not `100000`). + @State private var amountText = "" + @State private var isSending = false + @State private var errorMessage: String? + @State private var successTxid: Data? + + /// Cached recipient profile resolved from the platform-wallet + /// cache on appear. Empty until the first lookup completes; the + /// recipient section falls back to the `DashPayContact` fields + /// until then so the sheet doesn't flicker. + @State private var recipientProfile: DashPayProfile? + @State private var recipientDpnsName: String? + + /// Sender's current Core balance (spendable duffs). Pulled from + /// the Core wallet's lock-free balance on appear so the user + /// can see what they actually have before submitting. `nil` + /// while the async fetch is in flight or if the wallet handle + /// can't be resolved. + @State private var senderBalanceDuffs: UInt64? + + /// DASH → duffs. Returns nil when the input isn't a parseable + /// non-negative decimal or overflows `UInt64`. + private var amountDuffs: UInt64? { + let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let dashValue = Decimal(string: trimmed), dashValue >= 0 else { + return nil + } + // 1 DASH = 100_000_000 duffs. Decimal multiply then snap + // to `UInt64`; a negative or overflowed intermediate + // yields nil. + let duffsDecimal = dashValue * 100_000_000 + // `NSDecimalNumber(decimal:).uint64Value` truncates on + // overflow — detect by re-comparing. + let duffs = NSDecimalNumber(decimal: duffsDecimal).uint64Value + let roundTrip = Decimal(duffs) + return roundTrip == duffsDecimal ? duffs : nil + } + + /// Pretty name to show in the "To" section. Prefers the + /// DashPay profile's display name, then the identity's DPNS + /// label, then the contact's stored display name (truncated + /// hex by default). Recalculated whenever the resolved + /// profile / DPNS changes. + private var recipientDisplayName: String { + if let trimmed = recipientProfile?.displayName? + .trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty { + return trimmed + } + if let dpns = recipientDpnsName, !dpns.isEmpty { + return dpns + } + return contact.displayName + } + + /// Subtitle on the recipient row: public message if present, + /// else DPNS (when the headline is already the profile name), + /// else the truncated hex id. + private var recipientSubtitle: String? { + if let msg = recipientProfile?.publicMessage? + .trimmingCharacters(in: .whitespacesAndNewlines), + !msg.isEmpty { + return msg + } + if let dpns = recipientDpnsName, + recipientProfile?.displayName?.isEmpty == false { + return dpns + } + return String(contact.identityId.toHexString().prefix(20)) + "…" + } + + /// "1.23456789 DASH" — only rendered when we have a balance + /// number to show. + private var senderBalanceText: String? { + guard let duffs = senderBalanceDuffs else { return nil } + let dash = Double(duffs) / 100_000_000 + return String(format: "%.8f DASH", dash) + } + + /// Duffs available for spending, minus the current amount + /// input. `nil` when either the balance hasn't loaded or the + /// amount input doesn't parse. Used to flag over-spends in the + /// validation row + disable the Send button. + private var exceedsBalance: Bool { + guard let balance = senderBalanceDuffs, + let duffs = amountDuffs else { + return false + } + return duffs > balance + } + + var body: some View { + NavigationStack { + Form { + Section("To") { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + if let url = recipientProfile?.avatarUrl + .flatMap({ URL(string: $0) }) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Color.blue.opacity(0.15) + } + } + .frame(width: 32, height: 32) + .clipShape(Circle()) + } else { + Circle() + .fill(Color.blue.opacity(0.2)) + .frame(width: 32, height: 32) + .overlay( + Text(recipientDisplayName.prefix(1).uppercased()) + .font(.headline) + .foregroundColor(.blue) + ) + } + VStack(alignment: .leading, spacing: 2) { + Text(recipientDisplayName) + .font(.headline) + if let sub = recipientSubtitle { + Text(sub) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + } + + Section("Amount (DASH)") { + // Zero-balance state: once the async balance + // load resolves to 0, swap the interactive form + // for an explanation instead of an + // always-disabled field. + if senderBalanceDuffs == 0 { + Text("Your balance is 0 DASH — top up your wallet before sending.") + .font(.caption) + .foregroundColor(.secondary) + } else { + TextField("0.001", text: $amountText) + .keyboardType(.decimalPad) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("dashpay.send.amount") + } + if let balanceText = senderBalanceText { + HStack { + Text("Your balance") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(balanceText) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(exceedsBalance ? .red : .secondary) + } + } + if !amountText.isEmpty, amountDuffs == nil { + Text("Enter a valid decimal Dash amount") + .font(.caption) + .foregroundColor(.red) + } else if exceedsBalance { + Text("Amount exceeds your spendable balance") + .font(.caption) + .foregroundColor(.red) + } + } + + // Memo row intentionally absent. DashPay payments + // are plain Core-chain transactions — there's no + // on-chain memo slot and no DashPay document type + // for per-payment notes — so a memo field here + // would be misleading. `PaymentEntry.memo` on the + // Rust side is a local-only record the sender's + // wallet could populate from elsewhere if needed; + // the payment sheet stays honest by omitting it. + + if let successTxid = successTxid { + Section { + Text("Sent! txid: \(txidDisplayHex(successTxid).prefix(16))…") + .font(.caption) + .foregroundColor(.green) + } + } else if let errorMessage = errorMessage { + Section { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + } + .navigationTitle("Send Dash") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(isSending) + .accessibilityIdentifier("dashpay.send.cancel") + } + ToolbarItem(placement: .navigationBarTrailing) { + if isSending { + ProgressView() + } else { + Button("Send") { send() } + .disabled( + amountDuffs == nil + || (amountDuffs ?? 0) == 0 + || exceedsBalance + || senderBalanceDuffs == 0 + ) + .accessibilityIdentifier("dashpay.send.confirm") + } + } + } + .task { + await loadRecipientMetadata() + await loadSenderBalance() + } + } + } + + /// Resolve the recipient's profile + DPNS label from the + /// platform-wallet cache. Both are in-memory cache reads (no + /// network roundtrips) — the profile came from + /// `syncDashPayProfiles` and the DPNS label from any prior + /// `syncDpnsNames` for that identity. When the cache is empty + /// we fall back to the hex id display in the computed + /// properties above. + /// + /// We do NOT trigger a network sync here — opening a payment + /// sheet for every contact would spam the wallet; recipient + /// profiles refresh via the recurring DashPay sync that feeds + /// the DashPay tab. + private func loadRecipientMetadata() async { + guard let walletId = senderIdentity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + return + } + // Recipient is a contact: read the contact-profile cache first, with + // an own-profile fallback for a recipient that is one of our own + // identities. A miss leaves the fallback hex-id rendering. + recipientProfile = dashPayCachedProfile( + wallet: wallet, + ownerIdentityId: senderIdentity.identityId, + contactId: contact.identityId + ) + do { + let managed = try wallet.managedIdentity(identityId: contact.identityId) + let names = (try? managed.getDpnsNames()) ?? [] + recipientDpnsName = names.first + } catch { + // The recipient isn't a managed identity on this + // wallet (they're a contact, not an owned identity). + // That's the common case; leave DPNS unset. + recipientDpnsName = nil + } + } + + /// Fetch the sender wallet's current Core balance so the + /// amount row can show "spendable: X DASH" and block submits + /// that exceed it. Uses the lock-free balance accessor — + /// atomic reads, no async work. + private func loadSenderBalance() async { + guard let walletId = senderIdentity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + senderBalanceDuffs = nil + return + } + do { + let balance = try wallet.balance() + senderBalanceDuffs = balance.spendable + } catch { + senderBalanceDuffs = nil + } + } + + /// Broadcast the payment via the platform-wallet FFI. Memo is + /// currently passed to the Rust side but the signing path + /// doesn't embed it in the transaction yet — it's recorded on + /// the local `PaymentEntry` so the payment-history UI (when + /// wired) has context. + private func send() { + guard let walletId = senderIdentity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId) else { + errorMessage = "No wallet available for this identity" + return + } + guard let duffs = amountDuffs, duffs > 0 else { + errorMessage = "Amount must be greater than zero" + return + } + + isSending = true + errorMessage = nil + + Task { @MainActor in + defer { isSending = false } + do { + // `memo: nil` — DashPay payments don't carry memos + // on-chain or via a document, so there's nothing + // useful to pass. The Rust-side + // `PaymentEntry.memo` slot stays available for + // future local-note wiring. + let txid = try await wallet.sendDashPayPayment( + fromIdentityId: senderIdentity.identityId, + toContactIdentityId: contact.identityId, + amountDuffs: duffs, + memo: nil + ) + successTxid = txid + onSent() + kickDashPaySync(walletManager) + // Small settle-in before dismissing so the user + // sees the confirmation row. Mirrors the pattern in + // `RegisterNameView`. + try? await Task.sleep(nanoseconds: 1_500_000_000) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift deleted file mode 100644 index 8603946c562..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift +++ /dev/null @@ -1,917 +0,0 @@ -import SwiftUI -import SwiftData -import SwiftDashSDK - -struct FriendsView: View { - /// The identity whose DashPay contacts/requests/friends are being - /// browsed. Always supplied by the parent — the view used to host - /// its own identity picker but that's been removed; the only entry - /// point now is the per-identity drill-in from `IdentityDetailView`. - let identity: PersistentIdentity - - @EnvironmentObject var appState: AppState - @EnvironmentObject var walletManager: PlatformWalletManager - @Environment(\.modelContext) private var modelContext - @State private var contacts: [DashPayContact] = [] - @State private var incomingRequests: [DashPayContactRequest] = [] - @State private var sentRequests: [DashPayContactRequest] = [] - @State private var isLoading = false - @State private var showAddFriend = false - @State private var showIncomingRequests = false - @State private var errorMessage: String? - /// Set to the contact the user tapped to open the send-payment - /// sheet. `.sheet(item:)` presents when non-nil, tears the sheet - /// down when reset to nil. Also convenient because - /// `DashPayContact` is `Identifiable` via its `identityId: Data`. - @State private var paymentTarget: DashPayContact? - - var body: some View { - // No outer NavigationStack — this view is always pushed inside - // the parent's stack (IdentityDetailView's tab NavigationStack). - // Single `List` so the Incoming Requests `Section` actually - // gets sectioned styling — a `Section` directly inside a - // `VStack` is a no-op visually, just rendering its rows - // without a header/separator. - // - // We qualify with `SwiftUI.Group` here because - // `SwiftDashSDK.Group` is a `Codable` DPP type, and an - // unqualified `Group { ... }` resolves to its Codable - // initializer rather than the SwiftUI view builder — Swift - // surfaces that as a "trailing closure passed to parameter - // of type 'any Decoder'" diagnostic. - SwiftUI.Group { - if contacts.isEmpty && !isLoading && incomingRequests.isEmpty { - VStack(spacing: 20) { - Spacer() - - Image(systemName: "person.2.slash") - .font(.system(size: 50)) - .foregroundColor(.gray) - - Text("No Friends Yet") - .font(.title3) - .fontWeight(.medium) - - Text("Add friends to send messages\nand share documents") - .multilineTextAlignment(.center) - .font(.caption) - .foregroundColor(.secondary) - - Button { - showAddFriend = true - } label: { - Label("Add Friend", systemImage: "person.badge.plus") - } - .buttonStyle(.borderedProminent) - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if isLoading && contacts.isEmpty && incomingRequests.isEmpty { - VStack { - Spacer() - ProgressView("Loading contacts...") - Spacer() - } - } else { - List { - if !incomingRequests.isEmpty { - Section { - ForEach(incomingRequests) { request in - ContactRequestRow(request: request, isIncoming: true) { - acceptRequest(request) - } onReject: { - rejectRequest(request) - } - } - } header: { - Text("Incoming Requests (\(incomingRequests.count))") - } - } - - if !contacts.isEmpty { - Section { - ForEach(contacts.filter { !$0.isHidden }) { contact in - Button { - paymentTarget = contact - } label: { - ContactRowView(contact: contact) - } - .buttonStyle(.plain) - } - } header: { - Text("Friends (\(contacts.filter { !$0.isHidden }.count))") - } - } - } - } - } - .navigationTitle("Friends") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showAddFriend = true - } label: { - Image(systemName: "person.badge.plus") - } - } - } - .sheet(isPresented: $showAddFriend) { - AddFriendView( - selectedIdentity: identity, - onSent: { loadFriends() } - ) - .environmentObject(walletManager) - } - .sheet(item: $paymentTarget) { contact in - SendDashPayPaymentSheet( - senderIdentity: identity, - contact: contact, - onSent: { loadFriends() } - ) - .environmentObject(walletManager) - } - .onAppear { - loadFriends() - } - } - - /// Resolve the `ManagedPlatformWallet` anchored to `identity.wallet?.walletId`. - /// Errors when the identity has no wallet association or the - /// wallet isn't currently loaded in the manager. - private func requireWallet( - for identity: PersistentIdentity - ) throws -> ManagedPlatformWallet { - guard let walletId = identity.wallet?.walletId else { - throw PlatformWalletError.walletOperation( - "Identity \(identity.identityIdBase58) has no walletId" - ) - } - guard let wallet = walletManager.wallet(for: walletId) else { - throw PlatformWalletError.walletOperation( - "No ManagedPlatformWallet for this identity's walletId" - ) - } - return wallet - } - - /// Refresh the friends list for this view's identity. - /// - /// Two-stage: - /// 1. `wallet.syncContactRequests()` — fetches incoming - /// contact-request documents from Platform and populates - /// `ManagedIdentity.incoming_contact_requests` (and - /// auto-establishes any bidirectional matches). - /// 2. Re-read local state off the `ManagedIdentity` snapshot - /// (incoming / sent / established ID arrays) and convert - /// to the UI value types. - private func loadFriends() { - let wallet: ManagedPlatformWallet - do { - wallet = try requireWallet(for: identity) - } catch { - errorMessage = error.localizedDescription - return - } - - isLoading = true - Task { @MainActor in - defer { isLoading = false } - - // Stage 1: sync from Platform. Non-fatal — a sync error - // doesn't block reading whatever local state we already - // have. - do { - _ = try await wallet.syncContactRequests() - errorMessage = nil - } catch { - errorMessage = "Contact request sync failed: \(error.localizedDescription)" - } - - // Stage 2: local read via a fresh `ManagedIdentity` - // snapshot. Every sync invalidates the prior snapshot, - // so we grab a new one here rather than holding onto one - // across calls. - do { - let managed = try wallet.managedIdentity(identityId: identity.identityId) - let incomingIds = try managed.getIncomingContactRequestIds() - let sentIds = try managed.getSentContactRequestIds() - let establishedIds = try managed.getEstablishedContactIds() - - incomingRequests = incomingIds.map { senderId in - DashPayContactRequest( - id: "incoming-\(senderId.toHexString())", - senderId: senderId, - recipientId: identity.identityId - ) - } - sentRequests = sentIds.map { recipientId in - DashPayContactRequest( - id: "sent-\(recipientId.toHexString())", - senderId: identity.identityId, - recipientId: recipientId - ) - } - // Resolve display names from the cached DashPay - // profile when available — falls back to a - // truncated hex id for contacts without a profile - // yet (new contacts, contacts whose profile hasn't - // synced, etc.). The lookup is a sync local-cache - // read (no network roundtrip per contact). - contacts = establishedIds.map { contactId in - let profile = (try? wallet.getDashPayProfile(identityId: contactId)) ?? nil - let trimmedName = profile?.displayName? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let displayName = trimmedName.isEmpty - ? (String(contactId.toHexString().prefix(12)) + "…") - : trimmedName - return DashPayContact( - id: contactId, - displayName: displayName, - identityId: contactId - ) - } - } catch { - contacts = [] - incomingRequests = [] - sentRequests = [] - errorMessage = "Failed to read local DashPay state: \(error.localizedDescription)" - } - } - } - - private func acceptRequest(_ request: DashPayContactRequest) { - Task { @MainActor in - do { - let wallet = try requireWallet(for: identity) - let managed = try wallet.managedIdentity(identityId: identity.identityId) - guard let contactRequest = try managed.getIncomingContactRequest( - senderId: request.senderId - ) else { - errorMessage = "Incoming request from \(request.senderId.toHexString().prefix(12))… not in local state" - return - } - let signer = KeychainSigner(modelContainer: modelContext.container) - _ = try await wallet.acceptContactRequest(contactRequest, signer: signer) - errorMessage = nil - loadFriends() - } catch { - errorMessage = "Accept failed: \(error.localizedDescription)" - } - } - } - - private func rejectRequest(_ request: DashPayContactRequest) { - Task { @MainActor in - do { - let wallet = try requireWallet(for: identity) - try await wallet.rejectContactRequest( - ourIdentityId: identity.identityId, - contactIdentityId: request.senderId - ) - errorMessage = nil - loadFriends() - } catch { - errorMessage = "Reject failed: \(error.localizedDescription)" - } - } - } - -} - -// MARK: - UI value types - -/// Lightweight UI model for an established DashPay contact row. -/// Built each time FriendsView reads local state off a fresh -/// ManagedIdentity snapshot — see `loadFriends()`. The cached -/// DashPay profile fields are resolved separately via -/// `wallet.getDashPayProfile(identityId:)`. -struct DashPayContact: Identifiable { - let id: Data - let displayName: String - let identityId: Data - let dpnsName: String? - let note: String? - let isHidden: Bool - - init( - id: Data, - displayName: String, - identityId: Data, - dpnsName: String? = nil, - note: String? = nil, - isHidden: Bool = false - ) { - self.id = id - self.displayName = displayName - self.identityId = identityId - self.dpnsName = dpnsName - self.note = note - self.isHidden = isHidden - } -} - -/// Lightweight UI model for an incoming or outgoing contact request -/// row. `id` is a `"incoming-"` / `"sent-"` discriminator -/// so the same identity pair can appear in both lists without `ForEach` -/// collisions. -struct DashPayContactRequest: Identifiable { - let id: String - let senderId: Data - let recipientId: Data - let createdAt: Date - let senderDisplayName: String? - - init( - id: String, - senderId: Data, - recipientId: Data, - createdAt: Date = Date(), - senderDisplayName: String? = nil - ) { - self.id = id - self.senderId = senderId - self.recipientId = recipientId - self.createdAt = createdAt - self.senderDisplayName = senderDisplayName - } -} - -// MARK: - Contact Row View - -struct ContactRowView: View { - let contact: DashPayContact - - var body: some View { - HStack { - // Avatar - Circle() - .fill(Color.blue.opacity(0.2)) - .frame(width: 40, height: 40) - .overlay( - Text(contact.displayName.prefix(1).uppercased()) - .font(.headline) - .foregroundColor(.blue) - ) - - VStack(alignment: .leading, spacing: 2) { - Text(contact.displayName) - .font(.headline) - - if let dpnsName = contact.dpnsName { - Text(dpnsName) - .font(.caption) - .foregroundColor(.secondary) - } else { - Text(contact.id.toHexString().prefix(12) + "...") - .font(.caption) - .foregroundColor(.secondary) - } - - if let note = contact.note { - Text(note) - .font(.caption2) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - - Spacer() - } - .padding(.vertical, 4) - } -} - -// MARK: - Contact Request Row View - -struct ContactRequestRow: View { - let request: DashPayContactRequest - let isIncoming: Bool - let onAccept: () -> Void - let onReject: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - VStack(alignment: .leading) { - Text(isIncoming ? "From" : "To") - .font(.caption) - .foregroundColor(.secondary) - - Text((isIncoming ? request.senderId : request.recipientId).toHexString().prefix(12) + "...") - .font(.subheadline) - .fontWeight(.medium) - } - - Spacer() - - Text(request.createdAt, style: .relative) - .font(.caption2) - .foregroundColor(.secondary) - } - - if isIncoming { - HStack(spacing: 12) { - Button("Accept") { - onAccept() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - - Button("Reject") { - onReject() - } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(.red) - } - } - } - .padding(.vertical, 4) - } -} - -struct AddFriendView: View { - let selectedIdentity: PersistentIdentity? - /// Fires after a contact request has been successfully broadcast - /// + persisted. The parent re-runs `loadFriends()` to refresh - /// the sent-request list. - let onSent: () -> Void - - @EnvironmentObject var walletManager: PlatformWalletManager - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - @State private var searchText = "" - @State private var searchMethod = 0 // 0: DPNS, 1: Identity ID - @State private var isSending = false - @State private var errorMessage: String? - - var body: some View { - NavigationStack { - VStack { - Picker("Search by", selection: $searchMethod) { - Text("DPNS Name").tag(0) - Text("Identity ID").tag(1) - } - .pickerStyle(.segmented) - .padding() - - Form { - Section { - TextField( - searchMethod == 0 ? "Enter DPNS name" : "Enter Identity ID", - text: $searchText - ) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } header: { - Text(searchMethod == 0 ? "DPNS Name" : "Identity ID") - } footer: { - Text(searchMethod == 0 ? - "Search for friends by their Dash Platform Name Service (DPNS) username" : - "Search for friends by their unique identity identifier (base58)") - } - - Section { - Button { - sendRequest() - } label: { - HStack { - Spacer() - if isSending { - ProgressView() - } else { - Label("Send Friend Request", systemImage: "paperplane") - } - Spacer() - } - } - .disabled( - searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || isSending - || selectedIdentity == nil - ) - } - - if let errorMessage = errorMessage { - Section { - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) - } - } - } - } - .navigationTitle("Add Friend") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - dismiss() - } - .disabled(isSending) - } - } - } - } - - /// Resolve the recipient identity id (via DPNS name lookup or - /// direct base58 parse) and fire `sendContactRequest` against - /// the selected identity's wallet. On success, dismisses the - /// sheet and invokes `onSent` so the parent refreshes. - private func sendRequest() { - guard let identity = selectedIdentity, - let walletId = identity.wallet?.walletId, - let wallet = walletManager.wallet(for: walletId) else { - errorMessage = "No wallet available for this identity" - return - } - let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - isSending = true - errorMessage = nil - - Task { @MainActor in - defer { isSending = false } - do { - // Resolve recipient. DPNS mode goes through - // `resolveDpnsName`; ID mode parses base58 directly. - let recipientId: Identifier - if searchMethod == 0 { - guard let resolved = try await wallet.resolveDpnsName(trimmed) else { - errorMessage = "DPNS name not found" - return - } - recipientId = resolved - } else { - guard let parsed = Data.identifier(fromBase58: trimmed) else { - errorMessage = "Invalid identity id (expected base58)" - return - } - recipientId = parsed - } - - // Construct a fresh `KeychainSigner` and route through - // the platform-wallet - // `IdentityWallet::send_contact_request_with_external_signer` - // path. CAVEAT: the contact-request encryption step - // still derives the sender's ECDH key Rust-side from - // the wallet seed (watch-only wallets fail there) — - // see the docstring on `sendContactRequest(...,signer:)`. - let signer = KeychainSigner(modelContainer: modelContext.container) - _ = try await wallet.sendContactRequest( - senderIdentityId: identity.identityId, - recipientIdentityId: recipientId, - signer: signer - ) - onSent() - dismiss() - } catch { - errorMessage = error.localizedDescription - } - } - } -} - -// MARK: - Send payment sheet - -/// Modal sheet for sending a Dash payment to an established DashPay -/// contact via the platform-wallet FFI -/// (`ManagedPlatformWallet.sendDashPayPayment`). Rust handles the -/// address derivation (DIP-14) + Core-chain broadcast + recording -/// a `PaymentEntry` on the sender's `ManagedIdentity`; the -/// identity changeset callback (5f5ac06d6) forwards the state -/// update to SwiftData. -struct SendDashPayPaymentSheet: View { - let senderIdentity: PersistentIdentity - let contact: DashPayContact - /// Fires with the 32-byte txid once the transaction has been - /// broadcast. Parent uses this to refresh the friends list - /// (recording a payment may auto-establish a contact on the - /// Rust side if a reciprocal request just came in). - let onSent: () -> Void - - @EnvironmentObject var walletManager: PlatformWalletManager - @Environment(\.dismiss) private var dismiss - - /// Input in DASH — we convert to duffs before handing off to - /// the FFI. Keeping the field in DASH makes the units - /// human-readable (a 0.001 DASH payment is `0.001` in the - /// field, not `100000`). - @State private var amountText = "" - @State private var isSending = false - @State private var errorMessage: String? - @State private var successTxid: Data? - - /// Cached recipient profile resolved from the platform-wallet - /// cache on appear. Empty until the first lookup completes; the - /// recipient section falls back to the `DashPayContact` fields - /// until then so the sheet doesn't flicker. - @State private var recipientProfile: DashPayProfile? - @State private var recipientDpnsName: String? - - /// Sender's current Core balance (spendable duffs). Pulled from - /// the Core wallet's lock-free balance on appear so the user - /// can see what they actually have before submitting. `nil` - /// while the async fetch is in flight or if the wallet handle - /// can't be resolved. - @State private var senderBalanceDuffs: UInt64? - - /// DASH → duffs. Returns nil when the input isn't a parseable - /// non-negative decimal or overflows `UInt64`. - private var amountDuffs: UInt64? { - let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines) - guard let dashValue = Decimal(string: trimmed), dashValue >= 0 else { - return nil - } - // 1 DASH = 100_000_000 duffs. Decimal multiply then snap - // to `UInt64`; a negative or overflowed intermediate - // yields nil. - let duffsDecimal = dashValue * 100_000_000 - // `NSDecimalNumber(decimal:).uint64Value` truncates on - // overflow — detect by re-comparing. - let duffs = NSDecimalNumber(decimal: duffsDecimal).uint64Value - let roundTrip = Decimal(duffs) - return roundTrip == duffsDecimal ? duffs : nil - } - - /// Pretty name to show in the "To" section. Prefers the - /// DashPay profile's display name, then the identity's DPNS - /// label, then the contact's stored display name (truncated - /// hex by default). Recalculated whenever the resolved - /// profile / DPNS changes. - private var recipientDisplayName: String { - if let trimmed = recipientProfile?.displayName? - .trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty { - return trimmed - } - if let dpns = recipientDpnsName, !dpns.isEmpty { - return dpns - } - return contact.displayName - } - - /// Subtitle on the recipient row: public message if present, - /// else DPNS (when the headline is already the profile name), - /// else the truncated hex id. - private var recipientSubtitle: String? { - if let msg = recipientProfile?.publicMessage? - .trimmingCharacters(in: .whitespacesAndNewlines), - !msg.isEmpty { - return msg - } - if let dpns = recipientDpnsName, - recipientProfile?.displayName?.isEmpty == false { - return dpns - } - return String(contact.identityId.toHexString().prefix(20)) + "…" - } - - /// "1.23456789 DASH" — only rendered when we have a balance - /// number to show. - private var senderBalanceText: String? { - guard let duffs = senderBalanceDuffs else { return nil } - let dash = Double(duffs) / 100_000_000 - return String(format: "%.8f DASH", dash) - } - - /// Duffs available for spending, minus the current amount - /// input. `nil` when either the balance hasn't loaded or the - /// amount input doesn't parse. Used to flag over-spends in the - /// validation row + disable the Send button. - private var exceedsBalance: Bool { - guard let balance = senderBalanceDuffs, - let duffs = amountDuffs else { - return false - } - return duffs > balance - } - - var body: some View { - NavigationStack { - Form { - Section("To") { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 10) { - if let url = recipientProfile?.avatarUrl - .flatMap({ URL(string: $0) }) { - AsyncImage(url: url) { phase in - if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - } else { - Color.blue.opacity(0.15) - } - } - .frame(width: 32, height: 32) - .clipShape(Circle()) - } else { - Circle() - .fill(Color.blue.opacity(0.2)) - .frame(width: 32, height: 32) - .overlay( - Text(recipientDisplayName.prefix(1).uppercased()) - .font(.headline) - .foregroundColor(.blue) - ) - } - VStack(alignment: .leading, spacing: 2) { - Text(recipientDisplayName) - .font(.headline) - if let sub = recipientSubtitle { - Text(sub) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } - } - } - } - - Section("Amount (DASH)") { - TextField("0.001", text: $amountText) - .keyboardType(.decimalPad) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - if let balanceText = senderBalanceText { - HStack { - Text("Your balance") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text(balanceText) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(exceedsBalance ? .red : .secondary) - } - } - if !amountText.isEmpty, amountDuffs == nil { - Text("Enter a valid decimal Dash amount") - .font(.caption) - .foregroundColor(.red) - } else if exceedsBalance { - Text("Amount exceeds your spendable balance") - .font(.caption) - .foregroundColor(.red) - } - } - - // Memo row intentionally absent. DashPay payments - // are plain Core-chain transactions — there's no - // on-chain memo slot and no DashPay document type - // for per-payment notes — so a memo field here - // would be misleading. `PaymentEntry.memo` on the - // Rust side is a local-only record the sender's - // wallet could populate from elsewhere if needed; - // the payment sheet stays honest by omitting it. - - if let successTxid = successTxid { - Section { - Text("Sent! txid: \(successTxid.toHexString().prefix(16))…") - .font(.caption) - .foregroundColor(.green) - } - } else if let errorMessage = errorMessage { - Section { - Text(errorMessage) - .font(.caption) - .foregroundColor(.red) - } - } - } - .navigationTitle("Send Dash") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { dismiss() } - .disabled(isSending) - } - ToolbarItem(placement: .navigationBarTrailing) { - if isSending { - ProgressView() - } else { - Button("Send") { send() } - .disabled( - amountDuffs == nil - || (amountDuffs ?? 0) == 0 - || exceedsBalance - ) - } - } - } - .task { - await loadRecipientMetadata() - await loadSenderBalance() - } - } - } - - /// Resolve the recipient's profile + DPNS label from the - /// platform-wallet cache. Both are in-memory cache reads (no - /// network roundtrips) — the profile came from - /// `syncDashPayProfiles` and the DPNS label from any prior - /// `syncDpnsNames` for that identity. When the cache is empty - /// we fall back to the hex id display in the computed - /// properties above. - /// - /// We do NOT trigger a network sync here — opening a payment - /// sheet for every contact would spam the wallet; recipient - /// profiles refresh whenever the parent `FriendsView` runs its - /// own sync on appear. - private func loadRecipientMetadata() async { - guard let walletId = senderIdentity.wallet?.walletId, - let wallet = walletManager.wallet(for: walletId) else { - return - } - do { - recipientProfile = try wallet.getDashPayProfile(identityId: contact.identityId) - } catch { - // Profile isn't cached — stay with fallback rendering. - recipientProfile = nil - } - do { - let managed = try wallet.managedIdentity(identityId: contact.identityId) - let names = (try? managed.getDpnsNames()) ?? [] - recipientDpnsName = names.first - } catch { - // The recipient isn't a managed identity on this - // wallet (they're a contact, not an owned identity). - // That's the common case; leave DPNS unset. - recipientDpnsName = nil - } - } - - /// Fetch the sender wallet's current Core balance so the - /// amount row can show "spendable: X DASH" and block submits - /// that exceed it. Uses the lock-free balance accessor — - /// atomic reads, no async work. - private func loadSenderBalance() async { - guard let walletId = senderIdentity.wallet?.walletId, - let wallet = walletManager.wallet(for: walletId) else { - senderBalanceDuffs = nil - return - } - do { - let balance = try wallet.balance() - senderBalanceDuffs = balance.spendable - } catch { - senderBalanceDuffs = nil - } - } - - /// Broadcast the payment via the platform-wallet FFI. Memo is - /// currently passed to the Rust side but the signing path - /// doesn't embed it in the transaction yet — it's recorded on - /// the local `PaymentEntry` so the payment-history UI (when - /// wired) has context. - private func send() { - guard let walletId = senderIdentity.wallet?.walletId, - let wallet = walletManager.wallet(for: walletId) else { - errorMessage = "No wallet available for this identity" - return - } - guard let duffs = amountDuffs, duffs > 0 else { - errorMessage = "Amount must be greater than zero" - return - } - - isSending = true - errorMessage = nil - - Task { @MainActor in - defer { isSending = false } - do { - // `memo: nil` — DashPay payments don't carry memos - // on-chain or via a document, so there's nothing - // useful to pass. The Rust-side - // `PaymentEntry.memo` slot stays available for - // future local-note wiring. - let txid = try await wallet.sendDashPayPayment( - fromIdentityId: senderIdentity.identityId, - toContactIdentityId: contact.identityId, - amountDuffs: duffs, - memo: nil - ) - successTxid = txid - onSent() - // Small settle-in before dismissing so the user - // sees the confirmation row. Mirrors the pattern in - // `RegisterNameView`. - try? await Task.sleep(nanoseconds: 1_500_000_000) - dismiss() - } catch { - errorMessage = error.localizedDescription - } - } - } -} - -// #Preview omitted — FriendsView now requires a live -// `PersistentIdentity`, which isn't easy to fabricate in a preview -// context. Exercise via the IdentityDetailView -> Friends drill-in. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index eee138bbf2e..6cc436a7732 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -228,28 +228,6 @@ struct IdentityDetailView: View { } } - // DashPay Section — drill-in to the per-identity Friends - // screen. Sits up here next to "Identity Information" - // because it's the entry point to *this identity's* - // contacts; the richer "DashPay Profile" section further - // down still owns profile reads/edits separately. - // - // Hidden when the identity isn't backed by a loaded - // local wallet — `FriendsView.requireWallet` throws on - // every action there, and the failure is swallowed into - // a `@State errorMessage` that the body never renders. - // No-wallet identities (network-only fetches) would - // otherwise land on the empty placeholder with no path - // forward. - if let walletId = identity.wallet?.walletId, - walletManager.wallet(for: walletId) != nil { - Section("DashPay") { - NavigationLink(destination: FriendsView(identity: identity)) { - Label("Friends", systemImage: "person.2") - } - } - } - // DPNS Names Section if !dpnsNames.isEmpty || !contestedDpnsNames.isEmpty || !identity.isLocal { Section("DPNS Names") { @@ -1182,17 +1160,43 @@ struct DashPayProfileEditorView: View { private var isCreating: Bool { existing == nil } + /// DashPay `profile` contract limits — live counters below gate + /// Save instead of failing at broadcast time. + private static let displayNameLimit = 25 + private static let publicMessageLimit = 140 + + private var overLimit: Bool { + displayName.count > Self.displayNameLimit + || publicMessage.count > Self.publicMessageLimit + } + var body: some View { NavigationView { Form { - Section("Display name") { + Section { TextField("e.g. Alice", text: $displayName) .textInputAutocapitalization(.words) + .accessibilityIdentifier("dashpay.profile.displayName") + } header: { + Text("Display name") + } footer: { + Text("\(displayName.count)/\(Self.displayNameLimit)") + .foregroundColor( + displayName.count > Self.displayNameLimit ? .red : .secondary + ) } - Section("Public message") { + Section { TextField("A short bio that contacts can see", text: $publicMessage, axis: .vertical) .lineLimit(3, reservesSpace: true) + .accessibilityIdentifier("dashpay.profile.publicMessage") + } header: { + Text("Public message") + } footer: { + Text("\(publicMessage.count)/\(Self.publicMessageLimit)") + .foregroundColor( + publicMessage.count > Self.publicMessageLimit ? .red : .secondary + ) } Section("Avatar URL") { @@ -1200,6 +1204,7 @@ struct DashPayProfileEditorView: View { .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .accessibilityIdentifier("dashpay.profile.avatarUrl") Text("Paste an HTTPS image URL. SHA-256 + dHash " + "are computed client-side when you save — see " + "DIP-15.") @@ -1221,12 +1226,18 @@ struct DashPayProfileEditorView: View { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } .disabled(isSaving) + .accessibilityIdentifier("dashpay.profile.cancel") } ToolbarItem(placement: .navigationBarTrailing) { + // Save flow: Save replaced by a ProgressView + // while in flight; success dismisses; failure + // re-enables with the red caption in the form. if isSaving { ProgressView() } else { Button(isCreating ? "Create" : "Save") { save() } + .disabled(overLimit) + .accessibilityIdentifier("dashpay.profile.save") } } } @@ -1260,6 +1271,18 @@ struct DashPayProfileEditorView: View { let cleanedMsg = publicMessage.trimmingCharacters(in: .whitespacesAndNewlines) let cleanedUrl = avatarUrl.trimmingCharacters(in: .whitespacesAndNewlines) + // Enforce the HTTPS-only rule the form promises, in code: the DIP-15 + // avatar pipeline fetches the image to compute its integrity hashes, + // and a plaintext-http (or non-http scheme) URL is both a privacy + // leak and not reliably fetchable. Reject it here rather than relying + // on the helper text alone. Scheme-parse (not a prefix check) so + // "HTTPS://" and odd casings are handled. + if !cleanedUrl.isEmpty, + URL(string: cleanedUrl)?.scheme?.lowercased() != "https" { + errorMessage = "Avatar URL must be an https:// link." + return + } + // Did the user set/change the avatar URL? If so we need to // fetch bytes so Rust can compute the DIP-15 integrity hashes. // On update + same URL, we skip — Rust preserves the existing diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 00fb99520fe..e562a3d90cd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -9,7 +9,6 @@ struct OptionsView: View { @EnvironmentObject var shieldedService: ShieldedService @State private var showingDataManagement = false @State private var showingAbout = false - @State private var showingContracts = false @State private var isSwitchingNetwork = false @State private var sdkStatus: SDKStatus? @State private var isLoadingStatus = false @@ -428,6 +427,19 @@ struct OptionsView: View { } Section(header: Text("Platform")) { + // Contracts moved here from its own root tab. Pushed + // without its own NavigationStack so it attaches to + // this Settings stack (native back button), matching + // the other rows in this screen. + NavigationLink( + destination: ContractsTabView( + network: appState.currentNetwork, + embedsOwnNavigationStack: false + ) + ) { + Label("Contracts", systemImage: "doc.text") + } + NavigationLink(destination: PlatformQueriesView()) { Label("Queries", systemImage: "magnifyingglass") } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index fd2e4ecc805..0363c6d3978 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -48,6 +48,27 @@ struct StorageExplorerView: View { ) { DashpayContactRequestStorageListView(network: network) } + modelRow( + "Contact Profiles", + icon: "person.crop.circle", + type: PersistentDashpayContactProfile.self + ) { + DashpayContactProfileStorageListView(network: network) + } + modelRow( + "DashPay Payments", + icon: "arrow.left.arrow.right.circle", + type: PersistentDashpayPayment.self + ) { + DashpayPaymentStorageListView(network: network) + } + modelRow( + "Ignored Senders", + icon: "person.crop.circle.badge.xmark", + type: PersistentDashpayIgnoredSender.self + ) { + DashpayIgnoredSenderStorageListView(network: network) + } modelRow("Documents", icon: "doc.text", type: PersistentDocument.self) { DocumentStorageListView(network: network) } @@ -239,6 +260,9 @@ struct StorageExplorerView: View { directCount(PersistentDPNSName.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayProfile.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayContactRequest.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentDashpayContactProfile.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentDashpayPayment.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentDashpayIgnoredSender.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDocument.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDataContract.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentTokenBalance.self, predicate: #Predicate { $0.networkRaw == raw }) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 719f5b502e3..f9a377598b0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -429,6 +429,43 @@ struct DashpayProfileStorageListView: View { } } +// MARK: - PersistentDashpayContactProfile + +/// Storage-explorer list of every cached contact profile (a counterparty's +/// DashPay profile). One row per (owner, contact). Newest update first. +struct DashpayContactProfileStorageListView: View { + let network: Network + @Query(sort: \PersistentDashpayContactProfile.lastUpdated, order: .reverse) + private var records: [PersistentDashpayContactProfile] + + private var filtered: [PersistentDashpayContactProfile] { + records.filter { $0.networkRaw == network.rawValue } + } + + var body: some View { + let visible = filtered + List(visible) { record in + NavigationLink(destination: DashpayContactProfileStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.displayName ?? "(no display name)") + .font(.body).lineLimit(1) + Text(record.contactIdentityId.toHexString()) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .navigationTitle("Contact Profiles (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Records", systemImage: "person.crop.circle") + } + } + } +} + // MARK: - PersistentDashpayContactRequest /// Storage-explorer list of every DashPay contact-request row. @@ -517,6 +554,93 @@ struct DashpayContactRequestStorageListView: View { } } +// MARK: - PersistentDashpayPayment + +struct DashpayPaymentStorageListView: View { + let network: Network + @Query(sort: \PersistentDashpayPayment.createdAt, order: .reverse) + private var records: [PersistentDashpayPayment] + + private var scoped: [PersistentDashpayPayment] { + records.filter { $0.networkRaw == network.rawValue } + } + + var body: some View { + let visible = scoped + List(visible) { record in + NavigationLink(destination: DashpayPaymentStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(record.direction == .sent ? "Sent" : "Received") + .font(.body) + Spacer() + Text(String(format: "%.8f DASH", Double(record.amountDuffs) / 100_000_000)) + .font(.system(.caption, design: .monospaced)) + } + Text(record.txid) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .navigationTitle("DashPay Payments (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView( + "No Records", + systemImage: "arrow.left.arrow.right.circle" + ) + } + } + } +} + +// MARK: - PersistentDashpayIgnoredSender + +struct DashpayIgnoredSenderStorageListView: View { + let network: Network + @Query(sort: \PersistentDashpayIgnoredSender.ignoredAt, order: .reverse) + private var records: [PersistentDashpayIgnoredSender] + + private var scoped: [PersistentDashpayIgnoredSender] { + records.filter { $0.networkRaw == network.rawValue } + } + + var body: some View { + let visible = scoped + List(visible) { record in + NavigationLink(destination: DashpayIgnoredSenderStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("ignored sender") + .font(.body) + Spacer() + Text(record.ignoredAt, style: .date) + .font(.caption) + .foregroundColor(.secondary) + } + Text(record.ignoredSenderId.toHexString()) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .navigationTitle("Ignored Senders (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView( + "No Records", + systemImage: "person.crop.circle.badge.xmark" + ) + } + } + } +} + // MARK: - PersistentToken struct TokenStorageListView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index a3c5fe7ab1c..d1ad06b82f6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -246,6 +246,130 @@ struct DashpayProfileStorageDetailView: View { } } +// MARK: - PersistentDashpayContactProfile + +/// Detail view for one cached contact profile — a counterparty's DashPay +/// profile as seen by an owner identity. One row per (owner, contact). +/// Optional fields render as "—" when nil so partial profiles stay visible. +struct DashpayContactProfileStorageDetailView: View { + let record: PersistentDashpayContactProfile + + var body: some View { + Form { + Section("Core") { + FieldRow(label: "Display Name", value: record.displayName ?? "—") + FieldRow(label: "Public Message", value: record.publicMessage ?? "—") + FieldRow(label: "Bio", value: record.bio ?? "—") + FieldRow(label: "Network", value: record.network.displayName) + } + Section("Avatar") { + FieldRow(label: "URL", value: record.avatarUrl ?? "—") + FieldRow( + label: "Hash (32 B)", + value: record.avatarHash.map { hexString($0) } ?? "—" + ) + FieldRow( + label: "Fingerprint (8 B)", + value: record.avatarFingerprint.map { hexString($0) } ?? "—" + ) + } + Section("Relationships") { + NavigationLink(destination: IdentityStorageDetailView(record: record.owner)) { + FieldRow( + label: "Owner Identity", + value: record.owner.identityIdBase58 + ) + } + FieldRow(label: "Owner ID (Hex)", value: hexString(record.ownerIdentityId)) + FieldRow(label: "Contact ID (Hex)", value: hexString(record.contactIdentityId)) + } + Section("Timestamps") { + FieldRow(label: "Checked At (ms)", value: String(record.checkedAtMs)) + FieldRow(label: "Created", value: dateString(record.createdAt)) + FieldRow(label: "Updated", value: dateString(record.lastUpdated)) + } + } + .navigationTitle("Contact Profile") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - PersistentDashpayPayment + +/// Detail view for one DashPay payment-history row. Read-only dump +/// of every column the persister bridge writes, mirroring the other +/// storage detail views. +struct DashpayPaymentStorageDetailView: View { + let record: PersistentDashpayPayment + + var body: some View { + Form { + Section("Core") { + FieldRow( + label: "Direction", + value: record.direction == .sent ? "Sent" : "Received" + ) + FieldRow(label: "Status", value: statusText) + FieldRow( + label: "Amount", + value: String(format: "%.8f DASH", Double(record.amountDuffs) / 100_000_000) + ) + FieldRow(label: "Amount (duffs)", value: "\(record.amountDuffs)") + FieldRow(label: "Network", value: record.network.displayName) + FieldRow(label: "Memo", value: record.memo ?? "—") + } + Section("Transaction") { + FieldRow(label: "Txid", value: record.txid) + } + Section("Identities") { + FieldRow(label: "Owner", value: record.ownerIdentityId.map { String(format: "%02x", $0) }.joined()) + FieldRow( + label: "Counterparty", + value: record.counterpartyIdentityId.map { String(format: "%02x", $0) }.joined() + ) + } + Section("Timestamps") { + FieldRow(label: "Created", value: AppDate.formatted(record.createdAt, dateStyle: .abbreviated, timeStyle: .standard)) + FieldRow(label: "Updated", value: AppDate.formatted(record.lastUpdated, dateStyle: .abbreviated, timeStyle: .standard)) + } + } + .navigationTitle("DashPay Payment") + .navigationBarTitleDisplayMode(.inline) + } + + private var statusText: String { + switch record.status { + case .pending: return "Pending" + case .confirmed: return "Confirmed" + case .failed: return "Failed" + } + } +} + +// MARK: - PersistentDashpayIgnoredSender + +/// Detail view for one DashPay ignored sender (per-sender mute, +/// local-only). Read-only dump of every column, mirroring the other +/// storage detail views. +struct DashpayIgnoredSenderStorageDetailView: View { + let record: PersistentDashpayIgnoredSender + + var body: some View { + Form { + Section("Suppression key") { + FieldRow(label: "Owner", value: record.ownerIdentityId.toHexString()) + FieldRow(label: "Ignored sender", value: record.ignoredSenderId.toHexString()) + FieldRow(label: "Network", value: record.network.displayName) + } + Section("Audit") { + FieldRow(label: "Ignored", value: AppDate.formatted(record.ignoredAt, dateStyle: .abbreviated, timeStyle: .standard)) + } + } + .navigationTitle("Ignored Sender") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentDashpayContactRequest /// Detail view for one DashPay contact-request row. Surfaces every @@ -287,6 +411,10 @@ struct DashpayContactRequestStorageDetailView: View { label: "Encrypted Account Label", value: record.encryptedAccountLabel.map { "\($0.count) bytes" } ?? "—" ) + FieldRow( + label: "Account Label (decrypted)", + value: record.contactAccountLabel ?? "—" + ) FieldRow( label: "Auto-Accept Proof", value: record.autoAcceptProof.map { "\($0.count) bytes" } ?? "—" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift index c1b3544fc89..0efbc8eb07a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WalletMemoryExplorerView.swift @@ -1459,6 +1459,7 @@ struct WalletMemoryIdentityDetailView: View { @State private var dashpayProfile: DashPayProfile? @State private var dashpayProfileMissing: Bool = false + @State private var dashpaySyncState: ManagedIdentity.DashPaySyncState? @State private var tokenSyncSnapshot: IdentityTokenSyncSnapshot? @@ -1474,6 +1475,7 @@ struct WalletMemoryIdentityDetailView: View { dpnsSection contestedDpnsSection dashpayProfileSection + dashpaySyncStateSection } if let loadError { Section { @@ -1663,6 +1665,39 @@ struct WalletMemoryIdentityDetailView: View { } } + /// Live in-memory DashPay sync state — the counts let you compare against + /// the persisted SwiftData rows (Storage Explorer), and the high-water + /// cursors are visible NOWHERE else (they aren't persisted; they reset to + /// "not advanced" on every cold restart). + private var dashpaySyncStateSection: some View { + Section("DashPay Sync State (live)") { + if let s = dashpaySyncState { + KVRow(label: "Established contacts", value: "\(s.establishedContacts)") + KVRow(label: "Incoming requests", value: "\(s.incomingRequests)") + KVRow(label: "Sent requests", value: "\(s.sentRequests)") + KVRow(label: "Ignored senders", value: "\(s.ignoredSenders)") + KVRow( + label: "Contact profiles", + value: "\(s.presentContactProfiles) present / \(s.contactProfiles) cached" + ) + KVRow(label: "Payments", value: "\(s.dashpayPayments)") + KVRow(label: "Own profile", value: s.hasDashPayProfile ? "yes" : "no") + KVRow(label: "High-water received", value: cursorLabel(s.highWaterReceivedMs)) + KVRow(label: "High-water sent", value: cursorLabel(s.highWaterSentMs)) + } else { + Text("—") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private func cursorLabel(_ ms: UInt64?) -> String { + guard let ms else { return "not advanced" } + let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000) + return "\(ms) (\(date.formatted(date: .abbreviated, time: .standard)))" + } + // MARK: - Helpers private func sectionHeader(_ title: String, count: Int) -> some View { @@ -1715,6 +1750,7 @@ struct WalletMemoryIdentityDetailView: View { } catch { errors.append("DashPay profile: \(error.localizedDescription)") } + dashpaySyncState = try? mi.getDashPaySyncState() } catch { errors.append( "Identity \(shortBase58(identityId)) not found in wallet: " diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swift new file mode 100644 index 00000000000..127a49744b0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swift @@ -0,0 +1,215 @@ +import XCTest + +/// DashPay tab smoke tests. +/// +/// Network-free: these assert only the identity-picker states the +/// DashPay tab renders from local state — no wallet, no funded +/// identity, no testnet round-trips. They are the launch-and-render +/// gate for the tab, keyed on the `dashpay.*` accessibility ids. +/// +/// TODO (gated on a funded testnet wallet): the full +/// add → approve → pay XCUITest — AddContact by DPNS → +/// request appears in Outgoing → (peer accepts) → appears in Contacts +/// → open contact → Send Dash → confirm txid — needs two funded +/// testnet identities (one driven out-of-band to accept), so it is +/// deliberately NOT implemented here. +final class DashPayTabUITests: XCTestCase { + + private enum Identifier { + /// On the tab *content* view (same pattern as + /// `rootTab.wallets`); the tab-bar button itself may only be + /// reachable by its "DashPay" label. + static let dashpayTab = "dashpay.tab" + static let openWalletsButton = "dashpay.openWallets" + static let openIdentitiesButton = "dashpay.openIdentities" + static let identityPicker = "dashpay.identityPicker" + static let segment = "dashpay.segment" + static let addContactButton = "dashpay.addContact" + static let refreshButton = "dashpay.refresh" + static let openIgnoredButton = "dashpay.openIgnored" + static let openHiddenLink = "dashpay.openHidden" + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + /// Launch → open the DashPay tab → the tab must render exactly one + /// of the picker states: + /// 1. no wallet → "Open Wallets" CTA + /// 2. wallet, no identity → "Open Identities" CTA + /// 3. ≥1 eligible identity → segmented [Contacts | Requests] + /// On a fresh simulator state 1 is what we expect, but the test + /// accepts any of the three so it stays valid on a machine with + /// leftover local wallets — the invariant is "the tab renders a + /// recognized state", not "the simulator is fresh". + @MainActor + func testDashPayTabRendersAPickerState() throws { + let app = XCUIApplication() + app.launch() + + openDashPayTab(in: app) + + let openWallets = app.buttons + .matching(identifier: Identifier.openWalletsButton).firstMatch + let openIdentities = app.buttons + .matching(identifier: Identifier.openIdentitiesButton).firstMatch + let segment = app.descendants(matching: .any) + .matching(identifier: Identifier.segment).firstMatch + + let landed = waitForAny( + [openWallets, openIdentities, segment], + timeout: 30 + ) + XCTAssertTrue( + landed, + "DashPay tab must render one of the §6.4 states: no-wallet CTA, " + + "no-identity CTA, or the Contacts/Requests segment." + ) + + // The toolbar AddContact entry point exists in every state + // (disabled until an identity is active) — its presence is the + // contract the add→approve→pay flow will key on. + let addContact = app.buttons + .matching(identifier: Identifier.addContactButton).firstMatch + XCTAssertTrue( + addContact.waitForExistence(timeout: 10), + "dashpay.addContact toolbar button must exist on the DashPay tab." + ) + + if openWallets.exists || openIdentities.exists { + // States 1–2: no active identity ⇒ AddContact is disabled. + XCTAssertFalse( + addContact.isEnabled, + "AddContact must be disabled while no identity is active." + ) + } else { + // State 3: an identity is active — the Contacts segment is + // reachable and AddContact is live. + XCTAssertTrue( + addContact.isEnabled, + "AddContact must be enabled once an identity is active." + ) + let refresh = app.buttons + .matching(identifier: Identifier.refreshButton).firstMatch + XCTAssertTrue( + refresh.waitForExistence(timeout: 5), + "dashpay.refresh toolbar button must exist alongside the segment." + ) + } + } + + /// Hidden-contact recovery is gated on there being a hidden + /// contact: the "Hidden contacts" link (`dashpay.openHidden`) must + /// NOT appear when the active identity has none, while the Ignored + /// entry point (`dashpay.openIgnored`) is always reachable. This is + /// the network-free half of F16 — it proves the affordance is wired + /// and correctly gated; the full hide → recover round-trip needs two + /// funded, established testnet contacts and is covered manually (see + /// the funded-wallet TODO above). + @MainActor + func testHiddenRecoveryAffordanceIsGated() throws { + let app = XCUIApplication() + app.launch() + + openDashPayTab(in: app) + + let segment = app.descendants(matching: .any) + .matching(identifier: Identifier.segment).firstMatch + guard segment.waitForExistence(timeout: 30) else { + throw XCTSkip( + "No eligible identity on this simulator — the Contacts segment " + + "is unreachable, so the Hidden affordance can't be asserted." + ) + } + + // The Ignored entry point is always present once an identity is + // active (toolbar), mirroring where Hidden recovery belongs. + let openIgnored = app.descendants(matching: .any) + .matching(identifier: Identifier.openIgnoredButton).firstMatch + XCTAssertTrue( + openIgnored.waitForExistence(timeout: 5), + "dashpay.openIgnored must be reachable whenever an identity is active." + ) + + // With no hidden contact in local state, the recovery link is + // gated off — it appears only once a contact is hidden. + let openHidden = app.descendants(matching: .any) + .matching(identifier: Identifier.openHiddenLink).firstMatch + XCTAssertFalse( + openHidden.exists, + "dashpay.openHidden must be hidden until the identity has a hidden contact." + ) + } + + /// State-1 deep link: with no wallet loaded, the "Open Wallets" + /// CTA must switch the root tab to Wallets. Skipped (not failed) + /// when local wallets exist, because then state 1 is unreachable. + @MainActor + func testNoWalletStateDeepLinksToWalletsTab() throws { + let app = XCUIApplication() + app.launch() + + openDashPayTab(in: app) + + let openWallets = app.buttons + .matching(identifier: Identifier.openWalletsButton).firstMatch + guard openWallets.waitForExistence(timeout: 30) else { + throw XCTSkip( + "Simulator has local wallets — the §6.4 no-wallet state is " + + "unreachable; covered manually / on fresh simulators." + ) + } + openWallets.tap() + + let walletsScreen = app.descendants(matching: .any) + .matching(identifier: "wallets.screen").firstMatch + XCTAssertTrue( + walletsScreen.waitForExistence(timeout: 10) + || app.navigationBars["Wallets"].waitForExistence(timeout: 2), + "Open Wallets CTA must land on the Wallets tab." + ) + } + + // MARK: - Helpers + + @MainActor + private func openDashPayTab(in app: XCUIApplication) { + let tabBar = app.tabBars.firstMatch + XCTAssertTrue( + tabBar.waitForExistence(timeout: 60), + "Expected root tab bar to appear after app initialization." + ) + + let identifiedTab = app.tabBars.buttons + .matching(identifier: Identifier.dashpayTab).firstMatch + if identifiedTab.waitForExistence(timeout: 2) { + identifiedTab.tap() + return + } + let labeledTab = app.tabBars.buttons["DashPay"] + XCTAssertTrue( + labeledTab.waitForExistence(timeout: 5), + "Expected DashPay tab button to exist." + ) + labeledTab.tap() + } + + /// Wait until any of `elements` exists, polling as one predicate + /// expectation so the total wait stays bounded by `timeout`. + @MainActor + private func waitForAny( + _ elements: [XCUIElement], + timeout: TimeInterval + ) -> Bool { + let predicate = NSPredicate { object, _ in + guard let elements = object as? [XCUIElement] else { return false } + return elements.contains { $0.exists } + } + let expectation = XCTNSPredicateExpectation( + predicate: predicate, + object: elements + ) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 2df756b88d8..0c501ea61be 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -31,7 +31,7 @@ A row's **primary** category is the §4 section it lives in. Some tests are **cr |---|---|---| | "test Essential, Platform-only" | `Tier=Essential AND Layer=Platform` | `ID-02, ID-03, ID-04, DPNS-01, DPNS-02, DPNS-03, DPNS-04` | | "test all Essential" | `Tier=Essential` | the core experience: `CORE-01..07`, `ID-01/02/03/04`, `DPNS-01/02/03/04`, `SH-01..06` | -| "list the manual tests" | `Tier=Manual` | `CORE-08` (skip in automation; run on a physical device) | +| "list the manual tests" | `Tier=Manual` | `CORE-08`, `DP-10` (skip in automation; run on a physical device) | | "smoke test the wallet" | `Category=Core AND Status=✅` | `CORE-01..CORE-09` | | "test all non-Uncommon Token tests" | `Category=Token AND Tier≠Uncommon` | `TOK-01..07`, `MW-02` (via §6 index) | | "exercise every token admin action" | `Category=Token AND Tier=Uncommon` | `TOK-08..TOK-16` | @@ -265,12 +265,16 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus | ID | Action | Layer | Tier | Status | Entry point & test notes | |---|---|---|---|---|---| -| DP-01 | Send contact request | Platform | Common | ✅ | `FriendsView` (AddFriendView) → `platform_wallet_send_contact_request_with_signer`. | -| DP-02 | Accept contact request | Platform | Common | ✅ | `FriendsView` → `platform_wallet_accept_contact_request_with_signer`. | -| DP-03 | Send DashPay payment to a contact | Platform | Common | ✅ | `FriendsView` → `platform_wallet_send_dashpay_payment`. | -| DP-04 | Create / update DashPay profile | Platform | Common | ✅ | `IdentityDetailView` profile editor → `platform_wallet_create_or_update_dashpay_profile_with_signer`. | -| DP-05 | View profile / contacts / requests | Platform | Common | ✅ | `FriendsView`, `EstablishedContact` (SwiftData). | -| DP-06 | Reject contact request | Platform | Thorough | ✅ | `FriendsView` → `wallet.rejectContactRequest`. | +| DP-01 | Send contact request | Platform | Common | ✅ | DashPay tab → **Add Contact** (toolbar button `dashpay.addContact`) → `AddContactView` (mode toggle `dashpay.addContact.mode`): by Identity ID (base58, 32-byte gated) or DPNS username (≥2-char live prefix search; not-found offers clear-and-retry `dashpay.addContact.retry`) → `platform_wallet_send_contact_request_with_signer`. Optimistic-send overlay until the request appears. | +| DP-02 | Accept contact request | Platform | Common | ✅ | `ContactRequestsView` (Incoming) → **Accept** (`dashpay.request.accept`) → `platform_wallet_accept_contact_request_with_signer`. Contact moves to `ContactsView`; the bidirectional contact persists (both request-direction rows present for the pair). | +| DP-03 | Send DashPay payment to a contact | Platform | Common | ✅ | `ContactDetailView` → **Send Dash** (`dashpay.detail.sendDash`) → `SendDashPayPaymentSheet` → `platform_wallet_send_dashpay_payment`. A DashPay payment is an **L1 transaction**, so it requires the **Core SPV client running** (`CORE-07`) — with SPV stopped the broadcast fails with "SPV error: SPV Client not started". Payment appears in the contact's Payments (txid). **Verify both directions** — once a contact is established the channel is symmetric, so each party can pay the other (A→B *and* B→A); the recipient derives the sender's payment address from the xpubs exchanged at establishment. Each sender needs its own funded Core wallet + running SPV, and both endpoints must be on the **same network**. Send is disabled while the payment channel is broken — flagged on a permanent channel failure (e.g. the contact rotated their payment keys/addresses, or a request decrypt/validation failure) — showing "ask the contact to send a new request"; it re-enables when a fresh request arrives. | +| DP-04 | Create / update DashPay profile | Platform | Common | ✅ | `DashPayProfileView` → **Edit** (`dashpay.profile.edit`) → `DashPayProfileEditorView` (`dashpay.profile.displayName` / `.publicMessage` / `.avatarUrl`) → `platform_wallet_create_or_update_dashpay_profile_with_signer`. Non-destructive update; avatar renders via `DashPayAvatarView` and the re-fetched profile carries the computed `avatarHash` + 8-byte dHash `avatarFingerprint`. | +| DP-05 | View profile / contacts / requests | Platform | Common | ✅ | DashPay tab: `ContactsView` (established + search `dashpay.search`), `ContactRequestsView` (incoming/outgoing), `ContactDetailView`, `DashPayProfileView` — backed by `PersistentDashpayContactRequest` (SwiftData); established contacts are derived in-memory by joining each pair's incoming + outgoing request rows. | +| DP-06 | Ignore a contact request (reversible local mute) | Platform | Thorough | ✅ | `ContactRequestsView` → **Ignore** (`dashpay.request.ignore`) → `wallet.ignoreContactSender`. The sender leaves the requests list and appears in `IgnoredContactsView` (un-ignore `dashpay.ignored.unignore` reverses it). Local-only, no on-chain artifact (R1 privacy); persists across relaunch. | +| DP-07 | Attach `encryptedAccountLabel`; see contact's "Their account" on receive | Platform | Common | ✅ | DIP-15 §8.5. Send: `AddContactView` → **Account label** (`dashpay.addContact.accountLabel`) carried into `sendContactRequest(…accountLabel:)`. Receive: the counterparty's `ContactDetailView` shows a read-only **"Their account"** block (assert on visible text — no a11y id yet). Verify on a two-wallet loop (cf. `MW-03`): the ingested request carries the encrypted bytes, but the plaintext is decrypted **on accept** (the signer-bearing register step) and shown on the **incoming row only** (direction-specific). | +| DP-08 | QR auto-accept (build "Add me" QR + add via pasted URI) | Platform | Thorough | ✅ | DIP-15 §8.13. Build: `DashPayProfileView` → **Add me (DIP-15 QR)** (`dashpay.profile.qrURI`, `du=…&dapk=…`, 1h validity — `AUTO_ACCEPT_TTL_SECS=3600`) via `buildAutoAcceptQR`. Add: DashPay tab → **Add via QR** (`dashpay.addViaQR`) → `AddViaQRSheet` (`dashpay.qr.uriField` / `dashpay.qr.send`) → `sendContactRequestFromQR`. Two-wallet: A builds the QR, B pastes the URI → the request is auto-accepted by A without A manually accepting (a distinct acceptance path from `DP-02`). **A's reciprocal is signer-backed**, so it only fires once A's wallet is **unlocked** (the "N contacts waiting to finish setup → Unlock" drain); the request + auto-accept proof reach A immediately, but the established reciprocal lands after unlock. The paste path is simulator-drivable; a camera scan yields the same string (Manual variant). | +| DP-09 | Publish encrypted on-chain `contactInfo` (private contact metadata) | Platform | Thorough | ✅ | DIP-15 §10. `ContactDetailView` → edit **Alias** / **Note** / **Hide contact** (`dashpay.detail.aliasEdit` / `dashpay.detail.noteEdit` / `dashpay.detail.hideToggle`) → `saveContactInfo` → `platform_wallet_set_dashpay_contact_info_with_signer` (ECB `encToUserId` + CBC `privateData`). These fields are locally cached **and** published encrypted to Platform once the identity has **≥2 established contacts** (stated in the in-app footer) → outcomes `.published` / `.deferredUntilTwoContacts` / `.skippedWatchOnly`. | +| DP-10 | Incoming-payment backfill rescan (restore-from-seed / pre-watch window) | Cross | Manual | ✅ | DIP-15 §8.7 / §12.6 (on the DIP-16 SPV base). No UI trigger — automatic in DashPay sync: `reconcile_dashpay_rescan` lowers SPV `synced_height` to `min($coreHeightCreatedAt)` across new receival contacts so the filter manager backfills. Pass: a DashPay payment that landed on a contact's address **before** it was watched (restore-from-seed / second device / the offline-accept→pay window) appears after restore + SPV sync. Environment-limited (must construct the skew window); the regression pin for the §12.6 payment-loss gap. | ### 4.11 Group — `Domain=Group` @@ -323,17 +327,17 @@ Counts are of rows reachable in the app (Status `✅`/`🧪`/`⚠️`); `🔌`/` | Tier | Count (approx.) | Automatable? | |---|---|---| | Essential | 21 | yes | -| Common | 31 | yes | -| Thorough | 35 | yes | +| Common | 32 | yes | +| Thorough | 37 | yes | | Uncommon | 25 | yes | -| Manual | 1 (`CORE-08`) | no — physical device | +| Manual | 2 (`CORE-08`, `DP-10`) | no — physical device | **By layer (automatable only):** | Layer | Count (approx.) | |---|---| | Core | 17 | -| Platform | ~72 | +| Platform | ~75 | | Cross | 7 | | Shielded | 16 | @@ -355,7 +359,7 @@ Membership of each feature category across **all** sections (primary section mem - **Document** — `DOC-01..14`, `MW-04` - **Token** — `TOK-01..16`, `MW-02`, `GRP-03` - **Shielded** — `SH-01..13`, `CORE-21`, `MW-06`, `MW-07`, `MW-11` -- **DashPay** — `DP-01..06`, `MW-03` +- **DashPay** — `DP-01..10`, `MW-03` - **Group** — `GRP-01..04`, `TOK-15`, `TOK-16` - **System / Diagnostics** — `SYS-01..06` diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift new file mode 100644 index 00000000000..28c406e1e84 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift @@ -0,0 +1,934 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +// MARK: - DashPay persister-bridge mapping +// +// These tests feed synthetic persister payloads — the same shapes the +// Rust `on_persist_contacts_fn` callback delivers — through +// `PlatformWalletPersistenceHandler` into an in-memory ModelContainer +// and assert the SwiftData effects. No network, no FFI handles: the +// seam under test is the Swift side of the persister bridge. + +final class DashPayContactPersistenceTests: XCTestCase { + + private var container: ModelContainer! + private var handler: PlatformWalletPersistenceHandler! + + // Fixed fixture ids. + private let walletId = Data(repeating: 0xAA, count: 32) + private let ownerId = Data(repeating: 0x01, count: 32) + private let contactId = Data(repeating: 0x02, count: 32) + private let otherSenderId = Data(repeating: 0x03, count: 32) + + override func setUpWithError() throws { + try super.setUpWithError() + container = try DashModelContainer.createInMemory() + handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet + ) + // The contact persister requires the owner identity row to + // already exist (non-optional `owner` relationship + the + // `networkRaw` read come off it) — mirror the production + // ordering where identities apply before contacts. + let context = ModelContext(container) + let owner = PersistentIdentity( + identityId: ownerId, + isLocal: false, + network: .testnet + ) + context.insert(owner) + try context.save() + } + + override func tearDown() { + handler = nil + container = nil + super.tearDown() + } + + // MARK: Fixtures + + private func makeSnapshot( + contactId: Data? = nil, + isOutgoing: Bool, + accountReference: UInt32 = 0, + paymentChannelBroken: Bool = false, + encryptedPublicKey: Data = Data(repeating: 0x11, count: 96), + encryptedAccountLabel: Data? = nil, + contactAcceptedAccounts: [UInt32] = [] + ) -> PlatformWalletPersistenceHandler.ContactRequestSnapshot { + .init( + ownerIdentityId: ownerId, + contactIdentityId: contactId ?? self.contactId, + isOutgoing: isOutgoing, + senderKeyIndex: 2, + recipientKeyIndex: 3, + accountReference: accountReference, + encryptedPublicKey: encryptedPublicKey, + encryptedAccountLabel: encryptedAccountLabel, + autoAcceptProof: nil, + coreHeightCreatedAt: 1_234_567, + createdAtMillis: 1_700_000_000_000, + paymentChannelBroken: paymentChannelBroken, + contactAlias: nil, + contactNote: nil, + contactHidden: false, + contactAccountLabel: nil, + contactAcceptedAccounts: contactAcceptedAccounts + ) + } + + /// Apply one persister round the way the FFI does: bracketed by + /// `beginChangeset` / `endChangeset(success: true)` so the writes + /// land in the store atomically. + private func applyContacts( + upserts: [PlatformWalletPersistenceHandler.ContactRequestSnapshot] = [], + removedSent: [PlatformWalletPersistenceHandler.ContactRequestRemovalSnapshot] = [], + removedIncoming: [PlatformWalletPersistenceHandler.ContactRequestRemovalSnapshot] = [], + ignored: [PlatformWalletPersistenceHandler.ContactIgnoredSenderSnapshot] = [] + ) { + handler.beginChangeset(walletId: walletId) + handler.persistContacts( + walletId: walletId, + upserts: upserts, + removedSent: removedSent, + removedIncoming: removedIncoming, + ignored: ignored + ) + handler.endChangeset(walletId: walletId, success: true) + } + + /// Read every contact-request row back through a fresh context so + /// the assertions only see committed state. + private func fetchContactRows() throws -> [PersistentDashpayContactRequest] { + let context = ModelContext(container) + return try context.fetch( + FetchDescriptor() + ) + } + + private func fetchContactProfileRows() throws -> [PersistentDashpayContactProfile] { + let context = ModelContext(container) + return try context.fetch( + FetchDescriptor() + ) + } + + /// Apply one identity persister round carrying only the given contact + /// profiles for the fixture owner — the seam `upsertDashpayContactProfiles` + /// runs under. + private func applyContactProfiles( + _ profiles: [PlatformWalletPersistenceHandler.ContactProfileSnapshot] + ) { + // Bracket the round like the FFI does: `endChangeset` is the only + // atomic `save()`, so a bare `persistIdentities` would stage the writes + // without committing them. + handler.beginChangeset(walletId: walletId) + handler.persistIdentities( + walletId: walletId, + upserts: [ + PlatformWalletPersistenceHandler.IdentityEntrySnapshot( + identityId: ownerId, + balance: 0, + revision: 0, + identityIndex: nil, + label: nil, + status: 0, + walletId: walletId, + dpnsNames: [], + dashpayProfile: nil, + contactProfiles: profiles + ) + ], + removed: [] + ) + handler.endChangeset(walletId: walletId, success: true) + } + + // MARK: Contact-profile tombstone delete + + /// A present profile upserts a row; a later `isPresent == false` tombstone + /// for the same contact DELETEs it — so a contact who removed their on-chain + /// DashPay profile can't leave a stale name/avatar behind. + func testContactProfileTombstoneDeletesPersistedRow() throws { + applyContactProfiles([ + .init( + contactIdentityId: contactId, + isPresent: true, + displayName: "Carol", + bio: nil, + publicMessage: nil, + avatarUrl: nil, + avatarHash: nil, + avatarFingerprint: nil, + checkedAtMs: 111 + ) + ]) + var rows = try fetchContactProfileRows() + XCTAssertEqual(rows.count, 1, "a present profile persists one row") + XCTAssertEqual(rows.first?.displayName, "Carol") + + applyContactProfiles([ + .init( + contactIdentityId: contactId, + isPresent: false, + displayName: nil, + bio: nil, + publicMessage: nil, + avatarUrl: nil, + avatarHash: nil, + avatarFingerprint: nil, + checkedAtMs: 222 + ) + ]) + rows = try fetchContactProfileRows() + XCTAssertEqual(rows.count, 0, "a tombstone deletes the contact's stale profile row") + } + + /// A tombstone for a contact that was never persisted is a clean no-op. + func testContactProfileTombstoneForUnknownContactIsNoop() throws { + applyContactProfiles([ + .init( + contactIdentityId: contactId, + isPresent: false, + displayName: nil, + bio: nil, + publicMessage: nil, + avatarUrl: nil, + avatarHash: nil, + avatarFingerprint: nil, + checkedAtMs: 111 + ) + ]) + let rows = try fetchContactProfileRows() + XCTAssertEqual(rows.count, 0, "deleting a never-persisted profile is a no-op") + } + + // MARK: Upsert mapping + + func testUpsertInsertsRowWithAllFieldsMapped() throws { + let key = Data((0..<96).map { UInt8($0) }) + let label = Data([0xDE, 0xAD, 0xBE, 0xEF]) + applyContacts(upserts: [ + makeSnapshot( + isOutgoing: true, + accountReference: 42, + encryptedPublicKey: key, + encryptedAccountLabel: label, + contactAcceptedAccounts: [7, 42] + ) + ]) + + let rows = try fetchContactRows() + XCTAssertEqual(rows.count, 1) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row.ownerIdentityId, ownerId) + XCTAssertEqual(row.contactIdentityId, contactId) + XCTAssertTrue(row.isOutgoing) + XCTAssertEqual(row.senderKeyIndex, 2) + XCTAssertEqual(row.recipientKeyIndex, 3) + XCTAssertEqual(row.accountReference, 42) + XCTAssertEqual(row.encryptedPublicKey, key) + XCTAssertEqual(row.encryptedAccountLabel, label) + XCTAssertNil(row.autoAcceptProof) + XCTAssertEqual(row.coreHeightCreatedAt, 1_234_567) + XCTAssertEqual(row.createdAtMillis, 1_700_000_000_000) + XCTAssertFalse(row.paymentChannelBroken) + XCTAssertEqual( + row.contactAcceptedAccounts, [7, 42], + "DIP-15 accepted-account acceptances must round-trip through the persist path" + ) + XCTAssertEqual(row.network, .testnet) + XCTAssertEqual(row.owner.identityId, ownerId) + } + + /// G1c: `payment_channel_broken` is a property of the established + /// relationship, so the Rust projection stamps it on BOTH direction + /// rows of the pair — the UI must be able to disable "Send Dash" + /// regardless of which direction row it happens to read. + func testPaymentChannelBrokenLandsOnBothDirectionRows() throws { + applyContacts(upserts: [ + makeSnapshot(isOutgoing: true, paymentChannelBroken: true), + makeSnapshot(isOutgoing: false, paymentChannelBroken: true), + ]) + + let rows = try fetchContactRows() + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(Set(rows.map(\.isOutgoing)), [true, false]) + for row in rows { + XCTAssertTrue( + row.paymentChannelBroken, + "broken-channel flag must land on the \(row.isOutgoing ? "outgoing" : "incoming") row too" + ) + } + } + + /// The `established` promotion re-uses the same + /// `(network, owner, contact, direction)` unique key as the prior + /// pending row — the upsert must refresh in place, not grow a + /// duplicate, and a later flush can flip the broken flag on. + func testReupsertPromotesPendingRowInPlaceWithoutDuplicate() throws { + applyContacts(upserts: [ + makeSnapshot(isOutgoing: false, paymentChannelBroken: false) + ]) + applyContacts(upserts: [ + makeSnapshot(isOutgoing: false, paymentChannelBroken: true) + ]) + + let rows = try fetchContactRows() + XCTAssertEqual(rows.count, 1, "re-upsert of the same direction row must not duplicate") + XCTAssertTrue(try XCTUnwrap(rows.first).paymentChannelBroken) + } + + /// A contact upsert whose owner identity Swift hasn't seen yet is + /// skipped (next sync round replays it) — it must not crash or + /// insert an orphan row. + func testUpsertSkipsUnknownOwnerIdentity() throws { + let unknownOwner = Data(repeating: 0x77, count: 32) + var snapshot = makeSnapshot(isOutgoing: false) + snapshot = .init( + ownerIdentityId: unknownOwner, + contactIdentityId: snapshot.contactIdentityId, + isOutgoing: snapshot.isOutgoing, + senderKeyIndex: snapshot.senderKeyIndex, + recipientKeyIndex: snapshot.recipientKeyIndex, + accountReference: snapshot.accountReference, + encryptedPublicKey: snapshot.encryptedPublicKey, + encryptedAccountLabel: snapshot.encryptedAccountLabel, + autoAcceptProof: snapshot.autoAcceptProof, + coreHeightCreatedAt: snapshot.coreHeightCreatedAt, + createdAtMillis: snapshot.createdAtMillis, + paymentChannelBroken: snapshot.paymentChannelBroken, + contactAlias: snapshot.contactAlias, + contactNote: snapshot.contactNote, + contactHidden: snapshot.contactHidden, + contactAccountLabel: snapshot.contactAccountLabel, + contactAcceptedAccounts: snapshot.contactAcceptedAccounts + ) + applyContacts(upserts: [snapshot]) + + XCTAssertEqual(try fetchContactRows().count, 0) + } + + // MARK: Ignored senders (per-sender mute, local-only) + + /// Ignore is **per-sender** — bare sender id, no accountReference. ALL + /// of the ignored sender's incoming rows go (including a rotated, + /// bumped-accountReference one), while a DIFFERENT sender's rows are + /// never touched. (This is the deliberate semantic change from the old + /// per-(sender, accountReference) reject.) + func testIgnoreDeletesAllIncomingRowsFromTheSender() throws { + applyContacts(upserts: [ + makeSnapshot(isOutgoing: false, accountReference: 7), + makeSnapshot(contactId: otherSenderId, isOutgoing: false, accountReference: 9), + ]) + XCTAssertEqual(try fetchContactRows().count, 2) + + // Ignore the sender — its incoming row(s) go regardless of + // accountReference; the OTHER sender's row stays. A durable + // PersistentDashpayIgnoredSender row is written. + applyContacts(ignored: [ + .init(ownerIdentityId: ownerId, senderIdentityId: contactId, isIgnored: true) + ]) + let rows = try fetchContactRows() + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(try XCTUnwrap(rows.first).contactIdentityId, otherSenderId) + XCTAssertEqual(try XCTUnwrap(rows.first).accountReference, 9) + + // The durable ignored-sender row exists for the ignored sender only. + let ignoredRows = try fetchIgnoredRows() + XCTAssertEqual(ignoredRows.count, 1) + XCTAssertEqual(try XCTUnwrap(ignoredRows.first).ignoredSenderId, contactId) + } + + /// Ignore only suppresses the *incoming* direction — an outgoing + /// request the owner sent to the same identity is unrelated state and + /// must survive. + func testIgnoreLeavesOutgoingRowIntact() throws { + applyContacts(upserts: [ + makeSnapshot(isOutgoing: true, accountReference: 7), + makeSnapshot(isOutgoing: false, accountReference: 7), + ]) + + applyContacts(ignored: [ + .init(ownerIdentityId: ownerId, senderIdentityId: contactId, isIgnored: true) + ]) + + let rows = try fetchContactRows() + XCTAssertEqual(rows.count, 1) + XCTAssertTrue( + try XCTUnwrap(rows.first).isOutgoing, + "ignore must delete the incoming row only" + ) + } + + /// Un-ignore (an `ignored` row with `isIgnored == false`) deletes the + /// durable ignored-sender row so the sender resurfaces on the next + /// sweep. + func testUnignoreDeletesTheIgnoredSenderRow() throws { + applyContacts(ignored: [ + .init(ownerIdentityId: ownerId, senderIdentityId: contactId, isIgnored: true) + ]) + XCTAssertEqual(try fetchIgnoredRows().count, 1) + + applyContacts(ignored: [ + .init(ownerIdentityId: ownerId, senderIdentityId: contactId, isIgnored: false) + ]) + XCTAssertEqual( + try fetchIgnoredRows().count, 0, + "un-ignore must delete the durable ignored-sender row" + ) + } + + /// Read every ignored-sender row back through a fresh context. + private func fetchIgnoredRows() throws -> [PersistentDashpayIgnoredSender] { + let context = ModelContext(container) + return try context.fetch( + FetchDescriptor() + ) + } + + // MARK: Removal tombstones + + /// `removed_sent` / `removed_incoming` arrive in separate FFI + /// arrays and each must delete only its own direction row. + func testRemovalTombstonesAreDirectionScoped() throws { + applyContacts(upserts: [ + makeSnapshot(isOutgoing: true), + makeSnapshot(isOutgoing: false), + ]) + + applyContacts(removedSent: [ + .init(ownerIdentityId: ownerId, contactIdentityId: contactId) + ]) + var rows = try fetchContactRows() + XCTAssertEqual(rows.count, 1) + XCTAssertFalse(try XCTUnwrap(rows.first).isOutgoing) + + applyContacts(removedIncoming: [ + .init(ownerIdentityId: ownerId, contactIdentityId: contactId) + ]) + rows = try fetchContactRows() + XCTAssertEqual(rows.count, 0) + } + + // MARK: Full 10-arg C callback round-trip + + /// Drives the *real* `on_persist_contacts_fn` C trampoline (the + /// 10-argument callback the Rust persister invokes) with synthetic + /// `ContactRequestFFI` / `ContactIgnoredSenderFFI` payloads — + /// pinning the FFI-struct marshalling layer (32-byte tuple copies, + /// heap byte-buffer copies, `payment_channel_broken` projection) + /// on top of the snapshot path the other tests exercise. + func testPersistContactsCallbackMarshalsTenArgPayload() throws { + let callbacks = handler.makeCallbacks() + let beginFn = try XCTUnwrap(callbacks.on_changeset_begin_fn) + let contactsFn = try XCTUnwrap(callbacks.on_persist_contacts_fn) + let endFn = try XCTUnwrap(callbacks.on_changeset_end_fn) + + let encryptedKey = Data((0..<96).map { UInt8($0 ^ 0x5A) }) + let label = Data([0x01, 0x02, 0x03]) + let accepted: [UInt32] = [7, 42] + + walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) in + guard let wid = widRaw.bindMemory(to: UInt8.self).baseAddress else { + XCTFail("wallet-id buffer must bind") + return + } + _ = beginFn(callbacks.context, wid) + + encryptedKey.withUnsafeBytes { (keyRaw: UnsafeRawBufferPointer) in + label.withUnsafeBytes { (labelRaw: UnsafeRawBufferPointer) in + // Keep the accepted-accounts buffer alive for the whole + // call — Rust owns it in production, but here the test + // owns it and must outlive `contactsFn`. + accepted.withUnsafeBufferPointer { acceptedPtr in + let keyPtr = keyRaw.bindMemory(to: UInt8.self).baseAddress + let labelPtr = labelRaw.bindMemory(to: UInt8.self).baseAddress + + var outgoing = ContactRequestFFI() + outgoing.owner_id = Self.tuple32(ownerId) + outgoing.contact_id = Self.tuple32(contactId) + outgoing.is_outgoing = true + outgoing.sender_key_index = 5 + outgoing.recipient_key_index = 6 + outgoing.account_reference = 11 + outgoing.encrypted_public_key = keyPtr + outgoing.encrypted_public_key_len = UInt(encryptedKey.count) + outgoing.encrypted_account_label = labelPtr + outgoing.encrypted_account_label_len = UInt(label.count) + outgoing.core_height_created_at = 99 + outgoing.created_at = 1_700_000_000_123 + outgoing.payment_channel_broken = true + outgoing.accepted_accounts = acceptedPtr.baseAddress + outgoing.accepted_accounts_len = UInt(accepted.count) + + var incoming = outgoing + incoming.is_outgoing = false + incoming.encrypted_account_label = nil + incoming.encrypted_account_label_len = 0 + // A null/0 accepted-accounts pointer must map to an + // empty array (not a crash). + incoming.accepted_accounts = nil + incoming.accepted_accounts_len = 0 + + let rows = [outgoing, incoming] + rows.withUnsafeBufferPointer { rowsPtr in + let rc = contactsFn( + callbacks.context, + wid, + rowsPtr.baseAddress, + UInt(rows.count), + nil, 0, + nil, 0, + nil, 0 + ) + XCTAssertEqual(rc, 0) + } + } + } + } + + _ = endFn(callbacks.context, wid, true) + } + + let rows = try fetchContactRows() + XCTAssertEqual(rows.count, 2) + for row in rows { + XCTAssertEqual(row.ownerIdentityId, ownerId) + XCTAssertEqual(row.contactIdentityId, contactId) + XCTAssertEqual(row.senderKeyIndex, 5) + XCTAssertEqual(row.recipientKeyIndex, 6) + XCTAssertEqual(row.accountReference, 11) + XCTAssertEqual(row.encryptedPublicKey, encryptedKey) + XCTAssertEqual(row.coreHeightCreatedAt, 99) + XCTAssertEqual(row.createdAtMillis, 1_700_000_000_123) + XCTAssertTrue(row.paymentChannelBroken) + } + let outgoingRow = try XCTUnwrap(rows.first(where: \.isOutgoing)) + let incomingRow = try XCTUnwrap(rows.first(where: { !$0.isOutgoing })) + XCTAssertEqual(outgoingRow.encryptedAccountLabel, label) + XCTAssertNil( + incomingRow.encryptedAccountLabel, + "null label pointer must map to nil, not empty Data" + ) + XCTAssertEqual( + outgoingRow.contactAcceptedAccounts, [7, 42], + "non-null accepted-accounts pointer must marshal into an owned [UInt32]" + ) + XCTAssertEqual( + incomingRow.contactAcceptedAccounts, [], + "null/0 accepted-accounts pointer must map to an empty array" + ) + + // Ignore leg of the same callback: ignore the sender (drop the + // incoming row + write the durable ignored-sender row) through the + // C signature too. + walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) in + guard let wid = widRaw.bindMemory(to: UInt8.self).baseAddress else { + XCTFail("wallet-id buffer must bind") + return + } + _ = beginFn(callbacks.context, wid) + var ignore = ContactIgnoredSenderFFI() + ignore.owner_id = Self.tuple32(ownerId) + ignore.sender_id = Self.tuple32(contactId) + ignore.is_ignored = true + withUnsafePointer(to: &ignore) { ignPtr in + let rc = contactsFn( + callbacks.context, + wid, + nil, 0, + nil, 0, + nil, 0, + ignPtr, 1 + ) + XCTAssertEqual(rc, 0) + } + _ = endFn(callbacks.context, wid, true) + } + + let afterIgnore = try fetchContactRows() + XCTAssertEqual(afterIgnore.count, 1) + XCTAssertTrue(try XCTUnwrap(afterIgnore.first).isOutgoing) + } + + // MARK: accepted_accounts round-trip (F11) + + /// An established contact's DIP-15 `accepted_accounts` must survive + /// the persist path — the FFI carries them as a `(u32*, len)` pair + /// replicated onto both direction rows, and the handler stores them + /// on `contactAcceptedAccounts`. Against the unfixed handler (which + /// ignored the field) the persisted rows come back empty. + func testPersistPreservesAcceptedAccounts() throws { + applyContacts(upserts: [ + makeSnapshot(isOutgoing: true, contactAcceptedAccounts: [7, 42]), + makeSnapshot(isOutgoing: false, contactAcceptedAccounts: [7, 42]), + ]) + + let rows = try fetchContactRows() + XCTAssertEqual(rows.count, 2) + for row in rows { + XCTAssertEqual( + row.contactAcceptedAccounts, [7, 42], + "both direction rows must carry the relationship's accepted accounts" + ) + } + } + + /// The full relaunch restore: persist an established contact with + /// `accepted_accounts = [7, 42]`, then drive `loadWalletList()` (the + /// FFI load path the app runs on cold start) and assert the rebuilt + /// `ContactRequestFFI` rows carry them back. Against the unfixed + /// restore path (which rebuilt rows via `EstablishedContact::new` + /// and never set `accepted_accounts`) the rebuilt rows come back + /// empty, silently resetting the value every launch. + func testRestoreRebuildsAcceptedAccounts() throws { + // A restorable wallet needs at least one account carrying a + // non-empty extended pubkey, plus the owner identity linked to + // the wallet so `loadWalletList` walks its contact rows. + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount( + wallet: wallet, + accountType: 0, + accountIndex: 0, + accountTypeName: "standard" + ) + account.accountExtendedPubKeyBytes = Data(repeating: 0xEE, count: 78) + context.insert(account) + let target = ownerId + let ownerDescriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == target } + ) + let owner = try XCTUnwrap(try context.fetch(ownerDescriptor).first) + owner.wallet = wallet + try context.save() + + // Persist an established contact carrying the accepted accounts. + applyContacts(upserts: [ + makeSnapshot(isOutgoing: true, contactAcceptedAccounts: [7, 42]), + makeSnapshot(isOutgoing: false, contactAcceptedAccounts: [7, 42]), + ]) + + let (entries, count, errored) = handler.loadWalletList() + XCTAssertFalse(errored) + XCTAssertEqual(count, 1) + let entriesPtr = try XCTUnwrap(entries) + defer { handler.loadWalletListFree(entries: UnsafeRawPointer(entriesPtr)) } + + var seen: [[UInt32]] = [] + let walletEntry = entriesPtr[0] + XCTAssertGreaterThan(walletEntry.identities_count, 0) + for iIdx in 0.. 0 { + seen.append(Array( + UnsafeBufferPointer( + start: acceptedPtr, + count: Int(contact.accepted_accounts_len) + ) + )) + } else { + seen.append([]) + } + } + } + + XCTAssertEqual(seen.count, 2, "both direction rows must be rebuilt") + for accepted in seen { + XCTAssertEqual( + accepted, [7, 42], + "restore must rebuild accepted_accounts, not reset them to empty" + ) + } + } + + // MARK: Changeset atomicity vs app-facing writers + + /// Regression: an app-facing `persistDashpayPayments` refresh that + /// lands while a Rust persister round is open (between + /// `beginChangeset` and `endChangeset`) must NOT commit the + /// round's half-applied writes early. Every other app-facing + /// writer in the handler guards its immediate save with + /// `if !inChangeset`; without that guard here, a payments + /// pull-to-refresh racing a sync round breaks the documented + /// "each Rust store() is one atomic transaction" invariant — a + /// failed round could no longer roll back cleanly. + func testPaymentRefreshDoesNotCommitAnOpenChangesetRound() throws { + // Open a round and stage an (uncommitted) contact write. + handler.beginChangeset(walletId: walletId) + handler.persistContacts( + walletId: walletId, + upserts: [makeSnapshot(isOutgoing: false)], + removedSent: [], + removedIncoming: [], + ignored: [] + ) + + // App-facing payment refresh lands mid-round. + handler.persistDashpayPayments( + ownerIdentityId: ownerId, + payments: [ + DashPayPayment( + counterpartyId: contactId, + amountDuffs: 1_000, + direction: .sent, + status: .pending, + txid: "0011223344556677" + ) + ] + ) + + // The open round's writes must not be visible to other + // contexts yet. + XCTAssertEqual( + try fetchContactRows().count, 0, + "a mid-round payment refresh must not flush the open changeset early" + ) + + // Fail the round — everything staged since begin (contact row + // AND the payment row that rode the round) must roll back. + handler.endChangeset(walletId: walletId, success: false) + XCTAssertEqual(try fetchContactRows().count, 0) + } + + /// Copy a 32-byte `Data` into the C fixed-array tuple shape the + /// FFI structs use for ids. + private static func tuple32(_ data: Data) -> FFIByteTuple32 { + precondition(data.count == 32) + var tuple: FFIByteTuple32 = ( + 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 + ) + withUnsafeMutableBytes(of: &tuple) { $0.copyBytes(from: data) } + return tuple + } +} + +// MARK: - DashPay payment-history persistence + +final class DashPayPaymentPersistenceTests: XCTestCase { + + private var container: ModelContainer! + private var handler: PlatformWalletPersistenceHandler! + + private let ownerId = Data(repeating: 0x01, count: 32) + private let secondOwnerId = Data(repeating: 0x04, count: 32) + private let counterpartyId = Data(repeating: 0x02, count: 32) + private let txid = "f0e1d2c3b4a5968778695a4b3c2d1e0f00112233445566778899aabbccddeeff" + + override func setUpWithError() throws { + try super.setUpWithError() + container = try DashModelContainer.createInMemory() + handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet + ) + let context = ModelContext(container) + context.insert( + PersistentIdentity(identityId: ownerId, isLocal: false, network: .testnet) + ) + context.insert( + PersistentIdentity(identityId: secondOwnerId, isLocal: false, network: .testnet) + ) + try context.save() + } + + override func tearDown() { + handler = nil + container = nil + super.tearDown() + } + + private func fetchPaymentRows() throws -> [PersistentDashpayPayment] { + let context = ModelContext(container) + return try context.fetch(FetchDescriptor()) + } + + private func makePayment( + status: DashPayPaymentStatus = .pending, + direction: DashPayPaymentDirection = .sent, + memo: String? = nil + ) -> DashPayPayment { + DashPayPayment( + counterpartyId: counterpartyId, + amountDuffs: 250_000, + direction: direction, + status: status, + txid: txid, + memo: memo + ) + } + + /// The refresh path re-reads the whole Rust `dashpay_payments` map + /// on every call — re-upserting the same txid must refresh the row + /// in place (status is the field that actually moves) and never + /// grow a duplicate. + func testReupsertSameTxidUpdatesInPlaceWithoutDuplicate() throws { + handler.persistDashpayPayments( + ownerIdentityId: ownerId, + payments: [makePayment(status: .pending)] + ) + handler.persistDashpayPayments( + ownerIdentityId: ownerId, + payments: [makePayment(status: .confirmed, memo: "lunch")] + ) + + let rows = try fetchPaymentRows() + XCTAssertEqual(rows.count, 1, "same (owner, txid) must upsert, not duplicate") + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row.status, .confirmed) + XCTAssertEqual(row.memo, "lunch") + XCTAssertEqual(row.amountDuffs, 250_000) + XCTAssertEqual(row.direction, .sent) + XCTAssertEqual(row.txid, txid) + XCTAssertEqual(row.counterpartyIdentityId, counterpartyId) + XCTAssertEqual(row.ownerIdentityId, ownerId) + XCTAssertEqual(row.network, .testnet) + } + + /// The unique key is `(network, owner, txid)` — the same txid seen + /// from two wallet-managed identities (e.g. an in-wallet transfer + /// between own identities) is two distinct history rows. + func testSameTxidAcrossDifferentOwnersCreatesSeparateRows() throws { + handler.persistDashpayPayments( + ownerIdentityId: ownerId, + payments: [makePayment(direction: .sent)] + ) + handler.persistDashpayPayments( + ownerIdentityId: secondOwnerId, + payments: [makePayment(direction: .received)] + ) + + let rows = try fetchPaymentRows() + XCTAssertEqual(rows.count, 2) + XCTAssertEqual( + Set(rows.map(\.ownerIdentityId)), + [ownerId, secondOwnerId] + ) + + // The owner-scoped predicate the views query through must + // partition the rows. + let context = ModelContext(container) + let ownerRows = try context.fetch( + FetchDescriptor( + predicate: PersistentDashpayPayment.predicate(ownerIdentityId: ownerId) + ) + ) + XCTAssertEqual(ownerRows.count, 1) + XCTAssertEqual(try XCTUnwrap(ownerRows.first).direction, .sent) + } + + /// Defensive paths: an empty txid (degraded FFI row) is skipped, + /// and an owner identity Swift doesn't know yet means the whole + /// batch is deferred to the next refresh — neither may crash or + /// write partial rows. + func testSkipsEmptyTxidAndUnknownOwner() throws { + let emptyTxid = DashPayPayment( + counterpartyId: counterpartyId, + amountDuffs: 1, + direction: .sent, + status: .pending, + txid: "", + memo: nil + ) + handler.persistDashpayPayments(ownerIdentityId: ownerId, payments: [emptyTxid]) + XCTAssertEqual(try fetchPaymentRows().count, 0) + + let unknownOwner = Data(repeating: 0x99, count: 32) + handler.persistDashpayPayments( + ownerIdentityId: unknownOwner, + payments: [makePayment()] + ) + XCTAssertEqual(try fetchPaymentRows().count, 0) + } +} + +// MARK: - DashPayPayment FFI value-struct marshalling + +final class DashPayPaymentFFIMarshallingTests: XCTestCase { + + private let counterpartyId = Data((0..<32).map { UInt8($0 + 1) }) + + private static func tuple32(_ data: Data) -> FFIByteTuple32 { + precondition(data.count == 32) + var tuple: FFIByteTuple32 = ( + 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 + ) + withUnsafeMutableBytes(of: &tuple) { $0.copyBytes(from: data) } + return tuple + } + + func testInitFromFFICopiesAllFields() throws { + let txidCString = strdup("ab12cd34") + let memoCString = strdup("coffee ☕") + defer { + free(txidCString) + free(memoCString) + } + + var ffi = DashpayPaymentFFI() + ffi.counterparty_id = Self.tuple32(counterpartyId) + ffi.amount_duffs = 123_456_789 + ffi.direction = DashPayPaymentDirection.received.rawValue + ffi.status = DashPayPaymentStatus.confirmed.rawValue + ffi.txid = txidCString + ffi.memo = memoCString + + let payment = DashPayPayment(ffi: ffi) + XCTAssertEqual(payment.counterpartyId, counterpartyId) + XCTAssertEqual(payment.amountDuffs, 123_456_789) + XCTAssertEqual(payment.direction, .received) + XCTAssertEqual(payment.status, .confirmed) + XCTAssertEqual(payment.txid, "ab12cd34") + XCTAssertEqual(payment.memo, "coffee ☕") + } + + /// Optional memo: a null pointer mirrors the Rust `Option::None` + /// and must come through as `nil`, not an empty string. + func testNullMemoMapsToNil() throws { + let txidCString = strdup("ff00") + defer { free(txidCString) } + + var ffi = DashpayPaymentFFI() + ffi.counterparty_id = Self.tuple32(counterpartyId) + ffi.amount_duffs = 1 + ffi.direction = DashPayPaymentDirection.sent.rawValue + ffi.status = DashPayPaymentStatus.pending.rawValue + ffi.txid = txidCString + ffi.memo = nil + + let payment = DashPayPayment(ffi: ffi) + XCTAssertNil(payment.memo) + XCTAssertEqual(payment.txid, "ff00") + } + + /// Forward compatibility: unknown direction / status discriminants + /// from a newer Rust enum must degrade to the documented fallbacks + /// (`.sent` / `.pending`) instead of making history unreadable; a + /// (contract-violating) null txid degrades to "" instead of + /// trapping. + func testUnknownDiscriminantsAndNullTxidDegradeGracefully() throws { + var ffi = DashpayPaymentFFI() + ffi.counterparty_id = Self.tuple32(counterpartyId) + ffi.amount_duffs = 42 + ffi.direction = 99 + ffi.status = 99 + ffi.txid = nil + ffi.memo = nil + + let payment = DashPayPayment(ffi: ffi) + XCTAssertEqual(payment.direction, .sent) + XCTAssertEqual(payment.status, .pending) + XCTAssertEqual(payment.txid, "") + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyBreadcrumbTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyBreadcrumbTests.swift new file mode 100644 index 00000000000..4135ef603ea --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyBreadcrumbTests.swift @@ -0,0 +1,324 @@ +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Coverage for the derive-sign-destroy migration pieces that the funded UAT +/// could not exercise: the Keychain-driven breadcrumb backfill's three +/// outcomes (the `failed` count is the signal the scalar-deletion gate reads), +/// and the signer's `(walletId, derivationPath)` resolution incl. its +/// `wid.count == 32` guard. +@MainActor +final class IdentityKeyBreadcrumbTests: XCTestCase { + + private let walletId = Data(repeating: 0xAB, count: 32) + + private func makeRow( + keyId: Int32, + publicKeyData: Data, + identityId: String, + walletId: Data? = nil, + derivationPath: String? = nil, + keychainId: String? = nil, + keyType: KeyType = .ecdsaSecp256k1 + ) -> PersistentPublicKey { + let row = PersistentPublicKey( + keyId: keyId, + purpose: .authentication, + securityLevel: .high, + keyType: keyType, + publicKeyData: publicKeyData, + identityId: identityId + ) + row.walletId = walletId + row.identityDerivationPath = derivationPath + row.privateKeyKeychainIdentifier = keychainId + return row + } + + private func meta( + keyId: UInt32, + publicKey: Data, + identityIndex: UInt32, + derivationPath: String + ) -> KeychainManager.IdentityPrivateKeyMetadata { + KeychainManager.IdentityPrivateKeyMetadata( + identityId: "id1", + keyId: keyId, + walletId: walletId.toHexString(), + identityIndex: identityIndex, + keyIndex: keyId, + derivationPath: derivationPath, + publicKey: publicKey.toHexString(), + publicKeyHash: "", + keyType: 0, + purpose: 0, + securityLevel: 2 + ) + } + + // MARK: - Backfill outcomes (the deletion-gate signal) + + /// A metadata item whose stored path equals the canonical DIP-9 path for + /// its indices populates the breadcrumb columns and counts as `written`. + func testBackfillWritesBreadcrumbWhenPathIsCanonical() throws { + let container = try DashModelContainer.createInMemory() + let seed = ModelContext(container) + let pubKey = Data(repeating: 0x11, count: 33) + seed.insert(makeRow(keyId: 0, publicKeyData: pubKey, identityId: "id1", + keychainId: "identity_privkey.x")) + try seed.save() + + // No PersistentWallet row → handler resolves network to .testnet, so + // build the canonical path on .testnet to match. + let canonical = try KeyDerivation.getIdentityAuthenticationPath( + network: .testnet, identityIndex: 3, keyIndex: 0) + + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let result = handler.backfillIdentityKeyBreadcrumbs( + walletId: walletId, + items: [meta(keyId: 0, publicKey: pubKey, identityIndex: 3, derivationPath: canonical)] + ) + + XCTAssertEqual(result.written, 1) + XCTAssertEqual(result.failed, 0) + XCTAssertEqual(result.skipped, 0) + + let fresh = ModelContext(container) + let row = try XCTUnwrap(fresh.fetch(FetchDescriptor()).first) + XCTAssertEqual(row.identityDerivationPath, canonical) + XCTAssertEqual(row.walletId, walletId) + } + + /// A path that is NOT the canonical DIP-9 path for its indices fails the + /// self-check: the column is left nil and the row is counted `failed` — + /// this is exactly the count the deletion gate must read as zero. + func testBackfillCountsFailedAndLeavesColumnNilOnPathDrift() throws { + let container = try DashModelContainer.createInMemory() + let seed = ModelContext(container) + let pubKey = Data(repeating: 0x22, count: 33) + seed.insert(makeRow(keyId: 0, publicKeyData: pubKey, identityId: "id1", + keychainId: "identity_privkey.x")) + try seed.save() + + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let result = handler.backfillIdentityKeyBreadcrumbs( + walletId: walletId, + items: [meta(keyId: 0, publicKey: pubKey, identityIndex: 3, + derivationPath: "m/9'/1'/5'/0'/0'/999'/0'")] // not canonical for index 3 + ) + + XCTAssertEqual(result.failed, 1) + XCTAssertEqual(result.written, 0) + + let fresh = ModelContext(container) + let row = try XCTUnwrap(fresh.fetch(FetchDescriptor()).first) + XCTAssertNil(row.identityDerivationPath, "a drifted path must not be written") + } + + /// Regression: a breadcrumb backfill that lands while a Rust persister + /// round is open (between `beginChangeset` and `endChangeset`) must NOT + /// commit the round's half-applied writes early — the backfill's own + /// `save()` would otherwise flush the staged (uncommitted) round rows, + /// so a later `endChangeset(success: false)` could no longer roll the + /// round back cleanly. The deferred write must still complete once the + /// round closes, so the breadcrumb is not silently dropped. + func testBackfillDefersDuringOpenChangesetRoundAndCompletesAfter() throws { + let container = try DashModelContainer.createInMemory() + let seed = ModelContext(container) + // Owner identity so the mid-round `persistDashpayPayments` write has a + // row to attach to; the backfill target key is matched by pubkey. + let ownerId = Data(repeating: 0x01, count: 32) + let counterpartyId = Data(repeating: 0x02, count: 32) + seed.insert(PersistentIdentity(identityId: ownerId, isLocal: false, network: .testnet)) + let pubKey = Data(repeating: 0x77, count: 33) + seed.insert(makeRow(keyId: 0, publicKeyData: pubKey, identityId: "id1", + keychainId: "identity_privkey.x")) + try seed.save() + + let canonical = try KeyDerivation.getIdentityAuthenticationPath( + network: .testnet, identityIndex: 3, keyIndex: 0) + + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + + // Open a round and stage an (uncommitted) payment write. + handler.beginChangeset(walletId: walletId) + handler.persistDashpayPayments( + ownerIdentityId: ownerId, + payments: [ + DashPayPayment( + counterpartyId: counterpartyId, + amountDuffs: 1_000, + direction: .sent, + status: .pending, + txid: "0011223344556677" + ) + ] + ) + + // Backfill lands mid-round. It must defer: no breadcrumb written yet, + // and — critically — nothing flushed to disk. + let midRound = handler.backfillIdentityKeyBreadcrumbs( + walletId: walletId, + items: [meta(keyId: 0, publicKey: pubKey, identityIndex: 3, derivationPath: canonical)] + ) + XCTAssertEqual(midRound.written, 0, "a mid-round backfill must defer, not write") + + // Neither the round's staged payment nor the backfill's breadcrumb may + // be visible from another context while the round is open. + let midContext = ModelContext(container) + XCTAssertEqual( + try midContext.fetch(FetchDescriptor()).count, 0, + "a mid-round backfill must not flush the open changeset early" + ) + let midRow = try XCTUnwrap( + midContext.fetch(FetchDescriptor()).first + ) + XCTAssertNil( + midRow.identityDerivationPath, + "the backfill breadcrumb must not be written during the open round" + ) + + // Fail the round — the staged payment must roll back, yet the deferred + // backfill (which never rode the round's transaction) must still + // complete once the round closes. + handler.endChangeset(walletId: walletId, success: false) + + let afterContext = ModelContext(container) + XCTAssertEqual( + try afterContext.fetch(FetchDescriptor()).count, 0, + "the failed round's staged payment must roll back" + ) + let afterRow = try XCTUnwrap( + afterContext.fetch(FetchDescriptor()).first + ) + XCTAssertEqual( + afterRow.identityDerivationPath, canonical, + "the deferred backfill must still complete after the round closes" + ) + XCTAssertEqual(afterRow.walletId, walletId) + } + + /// A row that already carries a path is skipped (idempotent), not rewritten + /// or counted as failed. + func testBackfillIsIdempotentForAlreadyMigratedRow() throws { + let container = try DashModelContainer.createInMemory() + let seed = ModelContext(container) + let pubKey = Data(repeating: 0x33, count: 33) + let existing = "m/9'/1'/5'/0'/0'/3'/0'" + seed.insert(makeRow(keyId: 0, publicKeyData: pubKey, identityId: "id1", + walletId: walletId, derivationPath: existing, + keychainId: "identity_privkey.x")) + try seed.save() + + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let result = handler.backfillIdentityKeyBreadcrumbs( + walletId: walletId, + items: [meta(keyId: 0, publicKey: pubKey, identityIndex: 3, derivationPath: existing)] + ) + + XCTAssertEqual(result.skipped, 1) + XCTAssertEqual(result.written, 0) + XCTAssertEqual(result.failed, 0) + } + + // MARK: - Signer context resolution + + /// A row with a 32-byte walletId + a non-empty path resolves to that + /// breadcrumb; one without a breadcrumb, and one with a non-32-byte + /// walletId, both resolve to nil (so the signer falls back to the scalar). + func testResolveIdentityKeyContextHonorsBreadcrumbAndWidGuard() throws { + let container = try DashModelContainer.createInMemory() + let seed = ModelContext(container) + let path = "m/9'/1'/5'/0'/0'/0'/0'" + + let good = Data(repeating: 0x44, count: 33) + seed.insert(makeRow(keyId: 0, publicKeyData: good, identityId: "id1", + walletId: walletId, derivationPath: path)) + let noCrumb = Data(repeating: 0x55, count: 33) + seed.insert(makeRow(keyId: 1, publicKeyData: noCrumb, identityId: "id1")) + let badWid = Data(repeating: 0x66, count: 33) + seed.insert(makeRow(keyId: 2, publicKeyData: badWid, identityId: "id1", + walletId: Data(repeating: 0x01, count: 16), derivationPath: path)) + try seed.save() + + let signer = KeychainSigner(modelContainer: container, network: .testnet) + + let resolved = signer.resolveIdentityKeyContext(publicKey: good) + XCTAssertEqual(resolved?.walletId, walletId) + XCTAssertEqual(resolved?.derivationPath, path) + + XCTAssertNil(signer.resolveIdentityKeyContext(publicKey: noCrumb), + "no breadcrumb → nil → caller falls back to the stored scalar") + XCTAssertNil(signer.resolveIdentityKeyContext(publicKey: badWid), + "non-32-byte walletId must not resolve (FFI reads 32 bytes)") + } + + // MARK: - canSign preflight ⇄ sign-time consistency + + /// A breadcrumb-only row (no stored scalar) whose key type the resolver + /// does NOT derive-sign must NOT be reported signable: at sign time the + /// resolver returns `UNSUPPORTED_KEY_TYPE`, routing to the (absent) stored + /// scalar and failing with `publicKeyNotFound`. Preflight must agree. + func testCanSignRejectsBreadcrumbOnlyNonEcdsaKeyType() throws { + let container = try DashModelContainer.createInMemory() + let seed = ModelContext(container) + let path = "m/9'/1'/5'/0'/0'/0'/0'" + // Breadcrumb present, no `privateKeyKeychainIdentifier` (derive-only). + let pubKey = Data(repeating: 0x88, count: 33) + seed.insert(makeRow(keyId: 0, publicKeyData: pubKey, identityId: "id1", + walletId: walletId, derivationPath: path, + keyType: .eddsa25519Hash160)) + try seed.save() + + let signer = KeychainSigner(modelContainer: container, network: .testnet) + + // Non-ECDSA type: the resolver-derivable gate short-circuits before the + // Keychain mnemonic read, so this is deterministic without a mnemonic. + XCTAssertFalse( + signer.canSign(publicKey: pubKey, keyType: KeyType.eddsa25519Hash160.rawValue), + "a breadcrumb-only non-ECDSA key must not preflight as signable" + ) + XCTAssertFalse( + signer.canSign(publicKey: pubKey, keyType: KeyType.bls12_381.rawValue), + "a breadcrumb-only BLS key must not preflight as signable" + ) + } + + /// The stored-scalar branch is key-type independent (the scalar signs via + /// `ffiSign` regardless of the declared type), so a row carrying a keychain + /// identifier preflights as signable for any key type — the resolver gate + /// only governs the breadcrumb-only path. Deterministic: no mnemonic read. + func testCanSignAcceptsStoredScalarRegardlessOfKeyType() throws { + let container = try DashModelContainer.createInMemory() + let seed = ModelContext(container) + let pubKey = Data(repeating: 0x99, count: 33) + seed.insert(makeRow(keyId: 0, publicKeyData: pubKey, identityId: "id1", + keychainId: "identity_privkey.x", + keyType: .eddsa25519Hash160)) + try seed.save() + + let signer = KeychainSigner(modelContainer: container, network: .testnet) + + XCTAssertTrue( + signer.canSign(publicKey: pubKey, keyType: KeyType.ecdsaSecp256k1.rawValue), + "a stored-scalar row is signable for an ECDSA type" + ) + XCTAssertTrue( + signer.canSign(publicKey: pubKey, keyType: KeyType.eddsa25519Hash160.rawValue), + "a stored-scalar row is signable regardless of key type" + ) + } + + /// The Swift preflight wrapper forwards to the resolver's FFI predicate + /// `dash_sdk_resolver_supports_key_type`, which reports exactly the + /// wallet-derivable ECDSA key types (`ECDSA_SECP256K1 = 0`, + /// `ECDSA_HASH160 = 2`). + func testResolverCanDeriveSignMatchesRustSupportedSet() { + XCTAssertTrue(KeychainSigner.resolverCanDeriveSign(keyType: 0)) // ECDSA_SECP256K1 + XCTAssertTrue(KeychainSigner.resolverCanDeriveSign(keyType: 2)) // ECDSA_HASH160 + XCTAssertFalse(KeychainSigner.resolverCanDeriveSign(keyType: 1)) // BLS12_381 + XCTAssertFalse(KeychainSigner.resolverCanDeriveSign(keyType: 3)) // BIP13_SCRIPT_HASH + XCTAssertFalse(KeychainSigner.resolverCanDeriveSign(keyType: 4)) // EDDSA_25519_HASH160 + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityResolverSignIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityResolverSignIntegrationTests.swift new file mode 100644 index 00000000000..40a5b882a7b --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityResolverSignIntegrationTests.swift @@ -0,0 +1,100 @@ +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// End-to-end coverage of the load-bearing path the funded sim UAT was meant to +/// exercise: `KeychainSigner.signIdentityKeyOnDemand` resolves a breadcrumb row, +/// the mnemonic resolver fetches the seed from `WalletStorage`, the key is +/// derived on demand at the DIP-9 path, the MF-2 binding confirms it reproduces +/// the row's public key, and a signature is produced — entirely in-process, no +/// network, no stored scalar. This is the derive-sign-destroy path that replaces +/// the carried scalar. +@MainActor +final class IdentityResolverSignIntegrationTests: XCTestCase { + + // Canonical BIP-39 test vector (all-zero entropy). + private let mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + /// A consistent breadcrumb (the path derives to the row's pubkey) signs via + /// the resolver: the seed is read from the Keychain, the key is derived on + /// demand, the binding accepts it, and a 65-byte ECDSA signature comes back — + /// proving the resolver path carries a real signature with no stored scalar. + func testResolverSignsForConsistentBreadcrumb() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 0x7A, count: 32) + + // Derive the identity-auth pubkey at (identity 3, key 2) from the seed so + // the seeded row matches what the resolver derives at sign time. + let wallet = try Wallet(mnemonic: mnemonic, network: .testnet) + let path = try KeyDerivation.getIdentityAuthenticationPath( + network: .testnet, identityIndex: 3, keyIndex: 2) + let pubkey = try XCTUnwrap(Data(hexString: wallet.derivePublicKey(path: path))) + XCTAssertEqual(pubkey.count, 33, "compressed secp256k1 pubkey") + + // Seed the resolver's source: the mnemonic in WalletStorage, keyed by walletId. + let storage = WalletStorage() + try storage.storeMnemonic(mnemonic, for: walletId) + defer { try? storage.deleteMnemonic(for: walletId) } + + // Seed the breadcrumb row (no stored scalar — only the path + walletId). + let ctx = ModelContext(container) + let row = PersistentPublicKey( + keyId: 2, purpose: .authentication, securityLevel: .high, + keyType: .ecdsaSecp256k1, publicKeyData: pubkey, identityId: "id1") + row.walletId = walletId + row.identityDerivationPath = path + ctx.insert(row) + try ctx.save() + + let signer = KeychainSigner(modelContainer: container, network: .testnet) + let message = Data("identity state transition".utf8) + let result = signer.signIdentityKeyOnDemand(publicKey: pubkey, keyType: 0, data: message) + + guard case .success(let signature)? = result else { + XCTFail("expected a resolver signature, got \(String(describing: result))") + return + } + // dashcore compact-recoverable ECDSA signature is 65 bytes. + XCTAssertEqual(signature.count, 65) + } + + /// A breadcrumb whose path does NOT derive to the row's pubkey (a corrupt / + /// stale path) is rejected by the MF-2 binding BEFORE signing — a `.failure` + /// the trampoline falls back from, never a valid-but-wrong-key signature. + func testResolverRejectsWrongPathBeforeSigning() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 0x7B, count: 32) + + let wallet = try Wallet(mnemonic: mnemonic, network: .testnet) + let truePath = try KeyDerivation.getIdentityAuthenticationPath( + network: .testnet, identityIndex: 3, keyIndex: 2) + let pubkey = try XCTUnwrap(Data(hexString: wallet.derivePublicKey(path: truePath))) + // The row stores a DIFFERENT path that derives a different key. + let wrongPath = try KeyDerivation.getIdentityAuthenticationPath( + network: .testnet, identityIndex: 9, keyIndex: 9) + + let storage = WalletStorage() + try storage.storeMnemonic(mnemonic, for: walletId) + defer { try? storage.deleteMnemonic(for: walletId) } + + let ctx = ModelContext(container) + let row = PersistentPublicKey( + keyId: 2, purpose: .authentication, securityLevel: .high, + keyType: .ecdsaSecp256k1, publicKeyData: pubkey, identityId: "id1") + row.walletId = walletId + row.identityDerivationPath = wrongPath + ctx.insert(row) + try ctx.save() + + let signer = KeychainSigner(modelContainer: container, network: .testnet) + let result = signer.signIdentityKeyOnDemand( + publicKey: pubkey, keyType: 0, data: Data("x".utf8)) + + guard case .failure? = result else { + XCTFail("expected binding rejection, got \(String(describing: result))") + return + } + } +}