Skip to content

fix(platform-wallet)!: dashpay sync correctness, mobile interop, payments + DashPay tab (M1+M2)#3841

Open
shumkov wants to merge 23 commits into
v3.1-devfrom
feat/dashpay-m1-sync-correctness
Open

fix(platform-wallet)!: dashpay sync correctness, mobile interop, payments + DashPay tab (M1+M2)#3841
shumkov wants to merge 23 commits into
v3.1-devfrom
feat/dashpay-m1-sync-correctness

Conversation

@shumkov

@shumkov shumkov commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Milestone 1 of the DashPay completion plan (docs/dashpay/SPEC.md, included in this PR with its research base). DashPay's contact-request flow was broken in four independent, previously-unknown ways:

  1. Every send_contact_request was rejected by consensus — the broadcast carried a document id derived from the creation entropy but fresh entropy in the transition; drive-abci recomputes the id and rejects with InvalidDocumentTransitionIdError.
  2. Wrong encryption wire format — we encrypted the 107-byte DIP-14 ExtendedPubKey::encode() form; DIP-15 and both reference mobile clients (iOS dash-shared-core, Android dashj) use the 69-byte compact fingerprint‖chaincode‖pubkey. Our send failed its own 96-byte ciphertext check; our receive couldn't parse mobile payloads.
  3. Key-purpose incompatibility with mobile clients — verified against all 368 contactRequest documents on testnet: the dominant mobile cohort references an ENCRYPTION key for both key indices (mobile identities carry no DECRYPTION key); our send/validation required DECRYPTION and would fail in both directions.
  4. Sync could not establish contacts — the ingest guard dropped reciprocal requests (offline-accept never established), restore-from-seed permanently bricked Accept (duplicate reciprocal vs the platform unique index), and incoming payments were invisible after restore (receiving account never rebuilt).

What was done?

Three logical commits:

  • docs(dashpay) — the 7-agent-reviewed implementation spec (protocol reference, per-layer inventory, gaps G1–G15, 5-milestone plan, Swift UI design, test plan) + 6 research files including the cross-client interop desk-check and the testnet key-purpose census.
  • fix(sdk)! — entropy threading (ContactRequestResult.entropy reused at broadcast), the DIP-15 69-byte compact-xpub codec in platform-encryption + the SDK callback contract switched to it, and the recipient key-purpose assertion relaxed to DECRYPTION-or-ENCRYPTION.
  • fix(platform-wallet) — new recurring DashPaySyncManager (iterates the wallets map, not the token registry; per-identity log-and-continue); ingest-guard relaxation + sent-side reconcile with idempotent, metadata-preserving merge; Accept adopts an existing on-platform reciprocal instead of re-broadcasting; per-sweep account rebuild (external and receiving accounts) with validate-before-ECDH, guard-drop lock ordering, and a transient/permanent failure policy (payment_channel_broken flag, persisted + FFI accessor); rejected-request tombstone keyed (owner, sender, accountReference) so rotated requests still surface; 69-byte compact parsing on receive with address-equality pinned; key-purpose envelope aligned with on-chain reality; DashPaySdkWriter seam making the write paths testable.

How Has This Been Tested?

TDD throughout — every behavioral fix has a test that was red against the unfixed code and green after (red→green evidence recorded in the SPEC.md M1 DONE notes and the three commit messages):

  • platform-wallet: 196 lib + 8 integration tests green (was 170 before this branch; +34 new)
  • dash-sdk (--features mocks,offline-testing): 139 lib tests green (incl. the entropy-id and 69→96-byte pins)
  • platform-encryption: 7/7 (the crate's test target previously failed to compile — fixed dev-deps)
  • cargo check clean on rs-sdk-ffi, platform-wallet-ffi, platform-wallet-storage; clippy clean on touched crates
  • Live e2e (dp_001..dp_006) is specced to ride the e2e framework in test(platform-wallet): e2e framework + full test suite — triage pins, Found-*/PA-* guards, fail-closed persist, Stage-2 merge #3549 and is explicitly not gated on this PR (SPEC.md Part 7.4)

Breaking Changes

  • rs-sdk: the get_extended_public_key callback contract for create_contact_request/send_contact_request is now "return the 69-byte DIP-15 compact form" (was an encoded ExtendedPubKey); validated before encryption. ContactRequestResult gains a public entropy: Bytes32 field. The rs-sdk-ffi C ABI is unchanged (caller doc contract tightened).
  • platform-wallet storage: schema additions (contacts.payment_channel_broken column, rejected_contact_requests table) in the initial migration; ContactChangeSet gains a rejected field.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Recurring & on-demand DashPay sync with start/stop, status, interval and per-pass summaries (FFI + Swift controls)
    • Full DashPay UI: tab, Contacts, Requests, Contact detail, Add Contact, Send sheet, payment history
    • Local persisted DashPay payment history, device-local contact metadata, contact-info sync/publish, and wallet unlock from keychain mnemonic
  • Bug Fixes

    • DIP‑15 compact xpub interoperability and deterministic contact-request IDs
    • Improved key-purpose validation, payment-channel broken flag, and rejected-request tombstones
  • Documentation

    • Comprehensive DashPay spec, research notes, and interop desk‑check added

shumkov and others added 3 commits June 10, 2026 18:51
…earch

Seven-agent reviewed spec for completing the full DashPay flow (sync, contact
requests, payments, profiles) in the platform wallet + SwiftExampleApp:
protocol reference (DIP-9/11/13/14/15), per-layer implementation inventory,
15 prioritized gaps (G1-G15), 5-milestone work plan, Swift UI design with
normative interaction states, and a two-tier test plan aligned with the
unmerged e2e framework (PR #3549). Backed by 6 source-cited research files,
including the cross-client interop desk-check and an on-chain census of all
368 testnet contactRequest documents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ompact xpub, key-purpose interop

Three fixes to the rs-sdk/platform-encryption contact-request layer, each
pinned red-to-green:

1. Entropy mismatch (consensus rejection). send_contact_request generated
   fresh entropy for broadcast while the document id was derived from the
   creation entropy; drive-abci recomputes the id from the broadcast entropy
   and rejected EVERY send with InvalidDocumentTransitionIdError.
   ContactRequestResult now carries the creation entropy and send reuses it.
   Test: contact_request_result_entropy_derives_returned_id (red: field
   inexpressible pre-fix; green after).

2. DIP-15 69-byte compact xpub wire format. We encrypted the 107-byte DIP-14
   ExtendedPubKey::encode() form (failing our own 96-byte ciphertext check);
   DIP-15 and both reference mobile clients use fingerprint||chaincode||pubkey
   = 69 bytes. New compact_xpub_bytes/parse_compact_xpub codec in
   platform-encryption; the get_extended_public_key callback contract is now
   the 69-byte compact, validated before encryption. Test:
   test_encrypt_compact_xpub_is_exactly_96_bytes (+ round-trip and
   wrong-length rejection).

3. Key-purpose alignment with on-chain reality. Verified against all 368
   testnet contactRequests: the dominant mobile cohort references an
   ENCRYPTION key for BOTH indices (mobile identities carry no DECRYPTION
   key). The recipient-key assertion now accepts DECRYPTION or ENCRYPTION.
   Test: recipient_key_purpose_accepts_decryption_and_encryption (red on
   DECRYPTION-only predicate; green after).

BREAKING: the SDK-side get_extended_public_key callback must now return the
69-byte DIP-15 compact form (rs-sdk-ffi C ABI unchanged; caller doc
contract tightened). Also enables dashcore/rand in platform-encryption
dev-deps — the crate's tests previously failed to compile at all.

dash-sdk: 139 lib tests green (mocks,offline-testing); platform-encryption
7/7; rs-sdk-ffi check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…blish/reconcile, account rebuild

Milestone 1 of docs/dashpay/SPEC.md. Makes DashPay sync actually converge to
a payable state, recurring, and restore-safe. Each behavior pinned
red-to-green (see SPEC.md Part 5 M1 DONE notes for the full test list).

- Recurring sync (G12): new DashPaySyncManager (modeled on
  PlatformAddressSyncManager) drives dashpay_sync() per wallet on the shared
  cadence/cancel/quiesce machinery — iterating the wallets map, NOT the
  token registry (which skips zero-token identities). Per-identity
  log-and-continue pushed into sync_contact_requests.
  Test: recurring_pass_syncs_every_wallet_including_zero_token_identities.

- Establish via sync (G1a): the ingest guard dropped reciprocal requests
  whose sender we had already sent to — the offline-accept scenario could
  never establish. Guard relaxed; reciprocals now flow into auto-establish.

- Sent-side reconcile (G13): sync now ingests our own on-platform sent
  requests (idempotent, metadata-preserving merge — naive re-establish wiped
  alias/note every sweep), and Accept adopts an existing reciprocal instead
  of re-broadcasting into the unique-index rejection that permanently bricked
  Accept after restore-from-seed.

- Account rebuild sweep (G1b): every established contact missing accounts
  gets validate-key-indices -> decrypt -> register external account, plus the
  DashpayReceivingFunds account (previously only created on fresh send, so
  restore-from-seed left incoming payments invisible). Candidates collected
  under the write guard, registered after guard drop (tokio RwLock is
  non-reentrant).

- Failure policy (G1c): transient failures retry next sweep; permanent
  decrypt/parse failures set the new EstablishedContact.payment_channel_broken
  flag (persisted; FFI accessor added) and stop retrying. Purpose-validation
  mismatches only log-and-skip.

- Reject tombstone (G5 stage 1): rejected requests are tombstoned by
  (owner, sender, accountReference) — never bare sender, so a rotated
  request with a bumped accountReference still gets through. New
  rejected_contact_requests table + ContactChangeSet.rejected.

- Receive-side compact xpub (G14): register_external_contact_account parses
  the 69-byte DIP-15 compact and reconstructs the contact xpub
  (address-equality pinned by reconstructed_xpub_derives_identical_addresses);
  legacy 78/107 fallback kept.

- Key-purpose envelope (G15, verified on-chain): send prefers the
  recipient's DECRYPTION key and falls back to ENCRYPTION (mobile identities
  have no DECRYPTION key); validate_contact_request gains a recipient
  purpose gate (AUTHENTICATION was silently accepted before) and a
  purpose_mismatch classification.

- Testability seam (G11): DashPaySdkWriter object-safe trait over the SDK
  write paths; fetch paths use the SDK's built-in mock.

platform-wallet: 196 lib + 8 integration tests green (was 170);
storage + FFI checks clean; FFI ABI extended by one accessor
(established_contact_is_payment_channel_broken).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds DashPay SPEC/research docs and implements DIP-15 compact-xpub handling, tightened key-purpose validation, rejected-request tombstones and payment-channel-broken tracking, SDK writer seam, recurring DashPay sync manager, incoming-payment recording/reconciliation, FFI extensions (payments/sync/persistence/seed attach), SwiftData models, and SwiftExampleApp UI and tests.

Changes

DashPay Spec & Research

Layer / File(s) Summary
Spec and research docs
docs/dashpay/SPEC.md, docs/dashpay/research/*
Adds master SPEC and research documents covering DIP, keywallet, rs-platform-wallet, SDK/contract, Swift app, and interop desk-check.

Crypto & SDK

Layer / File(s) Summary
Platform encryption: compact xpub & contact-info
packages/rs-platform-encryption/*
Introduce COMPACT_XPUB_LEN, compact xpub assemble/parse, AES helpers for encToUserId/privateData, and tests.
rs-sdk contact-request contract
packages/rs-sdk/src/platform/dashpay/contact_request.rs, packages/rs-sdk-ffi/src/dashpay/contact_request.rs
Require 69-byte DIP‑15 plaintext, add entropy to ContactRequestResult, enforce sender/recipient purpose rules, reuse entropy when sending, and update docs/tests.
Wallet DIP-14/DIP-15 helpers
packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs
Add compact_xpub serialization, reconstruct_contact_xpub, account-reference changes, and regression tests.

Validation, State & Storage

Layer / File(s) Summary
Contact validation
packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs
Classify purpose mismatches with purpose_mismatch flag; sender must be ENCRYPTION, recipient accepts `ENCRYPTION
Changeset & ManagedIdentity
packages/rs-platform-wallet/src/changeset/*, packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/*
Add RejectedContactRequest, rejected changeset map, rejected_contact_requests field and APIs (record_rejected_contact_request, is_request_rejected), idempotent sent handling, and metadata-preserving re-establish.
SQLite schema & migrations
packages/rs-platform-wallet-storage/migrations/*, packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs
Add payment_channel_broken column and rejected_contact_requests table; writer/reader binding updated and tests adjusted.
Apply path
packages/rs-platform-wallet/src/wallet/apply.rs
Replay rejected tombstones into ManagedIdentity state during changeset apply.

FFI & Persistence

Layer / File(s) Summary
FFI contact persistence ABI
packages/rs-platform-wallet-ffi/src/contact_persistence.rs, packages/rs-platform-wallet-ffi/src/persistence.rs
Add payment_channel_broken to ContactRequestFFI, ContactRequestRejectionFFI, extend OnPersistContactsFn signature, and snapshot handling.
FFI payment history
packages/rs-platform-wallet-ffi/src/dashpay_payment.rs, packages/rs-platform-wallet-ffi/src/lib.rs
Expose managed_identity_get_dashpay_payments and deallocator; add module re-exports.
FFI sync bindings
packages/rs-platform-wallet-ffi/src/dashpay_sync.rs
Expose start/stop/status/set-interval/sync_now FFI for DashPay sync manager with pointer validation and tests.
FFI attach seed from mnemonic
packages/rs-platform-wallet-ffi/src/manager.rs
Add platform_wallet_manager_attach_wallet_seed_from_mnemonic FFI and tests; map SeedMismatch.
Contact info setter FFI
packages/rs-platform-wallet-ffi/src/contact_info.rs
Add platform_wallet_set_dashpay_contact_info_with_signer to publish contactInfo with external signer.

SDK writer seam & wallet integration

Layer / File(s) Summary
SDK writer seam
packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs
Add DashPaySdkWriter trait, parameter structs, SignerRef adapter, and SdkWriter production impl.
IdentityWallet wiring
packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs, packages/rs-platform-wallet/src/wallet/platform_wallet.rs
Inject sdk_writer into IdentityWallet and init with SdkWriter in PlatformWallet::new; profile flows use sdk_writer.put_document.

Contact flow refactor

Layer / File(s) Summary
Send/sync/accept/reject
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs, packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs
Send enforces sender key type/purpose, selects recipient key DECRYPTION-first; derive compact xpub bytes; sync fetches sent+received with log-and-continue, dedup, collects account-build candidates, validates before ECDH/register, persists broken-channel flags; accept adopts vs rebroadcast; reject records tombstones; decoding falls back from compact to legacy.

Payments, reconciliation & event bridge

Layer / File(s) Summary
Incoming payment recording
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs, packages/rs-platform-wallet/src/changeset/core_bridge.rs
Implement record_incoming_dashpay_payments to record Received entries from TransactionDetected, add reconcile_incoming_payments local reconciliation; spawn_wallet_event_adapter invokes recorder.

Recurring sync manager

Layer / File(s) Summary
DashPaySyncManager & manager wiring
packages/rs-platform-wallet/src/manager/dashpay_sync.rs, packages/rs-platform-wallet/src/manager/mod.rs, packages/rs-platform-wallet/src/manager/accessors.rs, packages/rs-platform-wallet/src/lib.rs
New coordinator with re-entrancy guard, quiesce semantics, background thread; wire into PlatformWalletManager, add accessors and crate re-exports; dashpay_sync step independence.

Swift SDK and Example App

Layer / File(s) Summary
Swift persistence & handlers
packages/swift-sdk/Sources/SwiftDashSDK/Persistence/*, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
Add PersistentDashpayPayment, relation on PersistentIdentity, persistDashpayPayments, persistContacts now accepts rejected snapshots and paymentChannelBroken, callback marshalling updated.
Swift PlatformWallet APIs
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/*.swift
Add getDashPayPayments, DashPay sync control APIs, unlockWalletFromKeychain attach-seed flow, and dashPaySyncIsSyncing state.
SwiftExampleApp UI & tests
packages/swift-sdk/SwiftExampleApp/*, packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swift
Add DashPay tab, Contacts/Requests/Add/Detail/Send views, DashPayContactMetaStore, UI wiring to start/stop sync, unit persistence tests, and XCUITests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

ready for final review

Suggested reviewers

  • lklimek
  • llbartekll
  • ZocoLini
  • thepastaclaw

Poem

"🐇 I nibbled through specs and threaded compact keys,

I traced tombstones where broken channels freeze.
I hop through syncs and tests that hum and play,
Payments march in rows, and UIs show the way.
Hooray — small hops, big fixes, now let the builds sway!"

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dashpay-m1-sync-correctness

@thepastaclaw

thepastaclaw commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 9441316)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (1)

806-875: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The accept-adopt check is only local, not platform-aware.

already_reciprocated is derived from local sent_contact_requests / established_contacts, but the sync code above explicitly allows "received loaded, sent fetch failed" by logging and continuing. In that state the reciprocal already exists on Platform while already_reciprocated is still false here, so this path retries the same (ownerId, toUserId, accountReference) write and gets the unique-index rejection instead of adopting. This needs a platform check here, or a duplicate-send fallback that switches to the adopt path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`
around lines 806 - 875, The local-only already_reciprocated check (variable
already_reciprocated) can be stale; change the flow so before attempting
send_contact_request_with_external_signer you either (A) perform a platform
check for an existing reciprocal contact request/relationship (use whatever
network client/query you have for checking platform contact requests for
(ownerId,toUserId,accountReference)) and set already_reciprocated accordingly,
or (B) keep the existing local check but add a duplicate-send fallback: catch
the unique-index conflict/error returned by
send_contact_request_with_external_signer and, on that specific error, log that
the reciprocal exists on Platform and run the adopt path (call
register_contact_account(&our_identity_id, &sender_id, 0) and treat as success).
Reference already_reciprocated, send_contact_request_with_external_signer, and
register_contact_account when implementing either fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/dashpay/research/01-dip-spec.md`:
- Line 131: Several fenced code blocks use plain ``` without a language tag;
update each triple-backtick fence in the document (e.g., the blocks currently
shown as ``` at the indicated locations) to include an explicit language token
(for non-code or prose use `text`, or a specific language like `json`, `bash`,
`markdown` where applicable) so the markdown linter passes; search for all
occurrences of ``` (including the ones noted around 131, 194, 245, 289, 418,
455) and replace them with ```text or the appropriate language identifier.

In `@docs/dashpay/research/02-rust-dashcore-keywallet.md`:
- Line 232: The markdown contains fenced code blocks without language tags;
update the offending triple-backtick fences to include the appropriate language
identifier (e.g., ```rust, ```bash, or ```text) for the code snippets so
markdownlint passes and syntax highlighting works—locate the plain ``` fences in
the document (the blocks referenced in the review) and replace them with
language-tagged fences.

In `@docs/dashpay/research/05-swift-app.md`:
- Line 47: The fenced code block currently uses a bare triple-backtick fence
(```); add a language tag (e.g., ```swift or ```text) immediately after the
opening backticks to satisfy markdownlint and enable proper syntax highlighting
for that block.

In `@docs/dashpay/research/06-interop-desk-check.md`:
- Line 366: The fenced code block uses plain ``` without a language tag; update
the opening fence to include an appropriate language identifier (for example
`http`, `text`, or `bash`) so markdownlint is satisfied and readability
improves—locate the triple-backtick fenced block in the document and add the
language tag immediately after the opening ``` fence.
- Line 24: The table row contains an extra leading column ("2") so it has four
columns while the table header defines three; remove the extra column in the row
that contains "2" (the row with "ECDH shared-key derivation" and the
libsecp256k1 SHA256 expression) so the row matches the 3-column header layout,
keeping the description "ECDH shared-key derivation" and the verdict "**PASS** —
all three stacks compute libsecp256k1-style `SHA256((y[31]&0x1|0x2) ‖ x)`" as
the remaining columns.

In `@docs/dashpay/SPEC.md`:
- Line 111: Several fenced code blocks in SPEC.md are missing language
identifiers; update each triple-backtick fence (``` ) at the noted examples so
they include an appropriate language tag (e.g., change ``` to ```text, ```rust,
or ```swift as appropriate) to satisfy markdownlint and enable correct syntax
highlighting; search for the bare ``` occurrences (including the ones referenced
near the examples) and replace them with language-tagged fences, ensuring
opening and closing fences remain paired.

In `@packages/rs-platform-wallet/src/manager/dashpay_sync.rs`:
- Around line 221-223: The background loop cleanup currently unconditionally
sets this.background_cancel to None (in the block near start()), which can
overwrite a newer token if stop() and start() race; change the logic so the
background thread only clears background_cancel if the stored cancel token it
captured at spawn time still matches the current token in this.background_cancel
(i.e., capture the Arc/ID of the cancel handle when spawning and
compare-before-clearing); apply the same compare-and-clear pattern in the stop()
/ thread-exit cleanup (references: this.background_cancel, start(), stop()) so a
late-exiting old loop cannot null out a replacement token.

In `@packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- Around line 103-118: The sender/recipient key selection currently using
sender_identity.public_keys().iter().find(...) (checking Purpose::ENCRYPTION and
KeyType::ECDSA_SECP256K1) can pick a disabled/rotated key; update the logic to
only consider active/enabled keys (e.g., filter by .enabled() or reuse the
existing enabled-key selection utility used for signing) so
sender_encryption_key and recipient_key_index (the call to
select_recipient_key_index should be updated similarly or replaced) always
reference the current active ENCRYPTION/DECRYPTION ECDSA_SECP256K1 key; ensure
you still call .map(...).ok_or_else(...) and preserve error type
PlatformWalletError::InvalidIdentityData when no active key is found.
- Around line 516-556: collect_account_build_candidates currently skips contacts
when info.core_wallet.accounts.dashpay_external_accounts.contains_key(&key) is
true, which prevents retries if register_contact_account previously failed after
inserting an external entry; remove that gating so contacts with an
incoming_request (incoming.encrypted_public_key and key indices) are always
returned as AccountBuildCandidate (unless payment_channel_broken) to allow
build_contact_accounts -> register_contact_account to retry; specifically, in
collect_account_build_candidates remove or change the has_external
check/continue and rely on contact.incoming_request and payment_channel_broken
to decide inclusion (keep AccountBuildCandidate fields: contact_id,
encrypted_public_key, our_decryption_key_index, contact_encryption_key_index).
- Around line 452-509: parse_contact_request_doc currently only extracts
required fields and drops optional fields encryptedAccountLabel and
autoAcceptProof, causing restores to lose these values; update
parse_contact_request_doc (and thus parse_sent_contact_request_doc which calls
it) to also read props.get("encryptedAccountLabel").and_then(|v: &Value|
v.as_str()).map(|s| s.to_owned()) and props.get("autoAcceptProof").and_then(|v:
&Value| v.as_bytes()).cloned() (or appropriate conversions) and pass them into
ContactRequest::new (or the appropriate constructor/factory) so the
ContactRequest created preserves encryptedAccountLabel and autoAcceptProof
during ingest/reconcile. Ensure the match arm pattern includes these Option
values and the fallback logging remains unchanged.

In
`@packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- Around line 116-130: The code removes an incoming request from
self.incoming_contact_requests but the returned ContactChangeSet only records
cs.rejected, so on replay the incoming entry isn't removed; update the change
set returned by the function to also include the incoming-removal for (owner_id,
*sender_id, account_reference) (i.e., add the corresponding removal entry to the
ContactChangeSet alongside cs.rejected) so that replay will delete the
incoming_contact_requests entry when applying the rejection tombstone.

---

Outside diff comments:
In `@packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- Around line 806-875: The local-only already_reciprocated check (variable
already_reciprocated) can be stale; change the flow so before attempting
send_contact_request_with_external_signer you either (A) perform a platform
check for an existing reciprocal contact request/relationship (use whatever
network client/query you have for checking platform contact requests for
(ownerId,toUserId,accountReference)) and set already_reciprocated accordingly,
or (B) keep the existing local check but add a duplicate-send fallback: catch
the unique-index conflict/error returned by
send_contact_request_with_external_signer and, on that specific error, log that
the reciprocal exists on Platform and run the adopt path (call
register_contact_account(&our_identity_id, &sender_id, 0) and treat as success).
Reference already_reciprocated, send_contact_request_with_external_signer, and
register_contact_account when implementing either fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 596c3a94-3c49-4cc0-869e-b392a37c181e

📥 Commits

Reviewing files that changed from the base of the PR and between ba94110 and 9f770b8.

📒 Files selected for processing (38)
  • docs/dashpay/SPEC.md
  • docs/dashpay/research/01-dip-spec.md
  • docs/dashpay/research/02-rust-dashcore-keywallet.md
  • docs/dashpay/research/03-rs-platform-wallet.md
  • docs/dashpay/research/04-sdk-and-contract.md
  • docs/dashpay/research/05-swift-app.md
  • docs/dashpay/research/06-interop-desk-check.md
  • packages/rs-platform-encryption/Cargo.toml
  • packages/rs-platform-encryption/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/established_contact.rs
  • packages/rs-platform-wallet-storage/migrations/V001__initial.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs
  • packages/rs-platform-wallet/src/changeset/changeset.rs
  • packages/rs-platform-wallet/src/changeset/mod.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/manager/accessors.rs
  • packages/rs-platform-wallet/src/manager/dashpay_sync.rs
  • packages/rs-platform-wallet/src/manager/mod.rs
  • packages/rs-platform-wallet/src/wallet/apply.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/account_labels.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/profile.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-sdk-ffi/src/dashpay/contact_request.rs
  • packages/rs-sdk/src/platform/dashpay/contact_request.rs

`autoAcceptProof` (optional, 38–102 bytes) lets a recipient pre-authorize automatic acceptance.
DIP-0015 defines a **separate** derivation path for the auto-accept proof keys:

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Specify code-fence languages for markdown lint compliance.

Please add explicit language tags to these fenced blocks (at least text where no specific language applies).

Also applies to: 194-194, 245-245, 289-289, 418-418, 455-455

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 131-131: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/dashpay/research/01-dip-spec.md` at line 131, Several fenced code blocks
use plain ``` without a language tag; update each triple-backtick fence in the
document (e.g., the blocks currently shown as ``` at the indicated locations) to
include an explicit language token (for non-code or prose use `text`, or a
specific language like `json`, `bash`, `markdown` where applicable) so the
markdown linter passes; search for all occurrences of ``` (including the ones
noted around 131, 194, 245, 289, 418, 455) and replace them with ```text or the
appropriate language identifier.

Source: Linters/SAST tools


`test_dashpay_vector_1..4` derive against real DashPay-shaped paths, e.g.:

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing language tags on fenced code blocks.

Add language identifiers to keep markdownlint clean and preserve proper syntax highlighting.

Also applies to: 394-394

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 232-232: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/dashpay/research/02-rust-dashcore-keywallet.md` at line 232, The
markdown contains fenced code blocks without language tags; update the offending
triple-backtick fences to include the appropriate language identifier (e.g.,
```rust, ```bash, or ```text) for the code snippets so markdownlint passes and
syntax highlighting works—locate the plain ``` fences in the document (the
blocks referenced in the review) and replace them with language-tagged fences.

Source: Linters/SAST tools


`ContentView.swift` is the root. A 5-tab `TabView` (enum `RootTab`):

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language tag to the fenced block.

This block should declare a language (or text) to satisfy markdownlint.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 47-47: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/dashpay/research/05-swift-app.md` at line 47, The fenced code block
currently uses a bare triple-backtick fence (```); add a language tag (e.g.,
```swift or ```text) immediately after the opening backticks to satisfy
markdownlint and enable proper syntax highlighting for that block.

Source: Linters/SAST tools

| # | 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)`. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix table column mismatch in the verdict summary.

The row includes an extra column relative to the 3-column header, which can drop content in renderers/linters. Align all rows to the header shape.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 24-24: Table column count
Expected: 3; Actual: 4; Too many cells, extra data will be missing

(MD056, table-column-count)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/dashpay/research/06-interop-desk-check.md` at line 24, The table row
contains an extra leading column ("2") so it has four columns while the table
header defines three; remove the extra column in the row that contains "2" (the
row with "ECDH shared-key derivation" and the libsecp256k1 SHA256 expression) so
the row matches the 3-column header layout, keeping the description "ECDH
shared-key derivation" and the verdict "**PASS** — all three stacks compute
libsecp256k1-style `SHA256((y[31]&0x1|0x2) ‖ x)`" as the remaining columns.

Source: Linters/SAST tools

bundle: **`https://testnet.platform-explorer.pshenmic.dev`** (routes in
`pshenmic/platform-explorer` `packages/api/src/routes.js`). Endpoints used:

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Specify the language for the fenced block.

Add a language tag (http, text, etc.) to satisfy markdownlint and improve readability.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 366-366: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/dashpay/research/06-interop-desk-check.md` at line 366, The fenced code
block uses plain ``` without a language tag; update the opening fence to include
an appropriate language identifier (for example `http`, `text`, or `bash`) so
markdownlint is satisfied and readability improves—locate the triple-backtick
fenced block in the document and add the language tag immediately after the
opening ``` fence.

Source: Linters/SAST tools

Comment on lines +221 to +223
if let Ok(mut guard) = this.background_cancel.lock() {
*guard = None;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Prevent old-loop cleanup from clearing a newer loop’s cancel token

Line 221 clears background_cancel unconditionally. If stop() (Line 238) is followed by a quick start(), the old thread can exit later and overwrite the new token with None, leaving the new loop untracked and potentially un-stoppable.

Suggested fix
 pub struct DashPaySyncManager {
     wallets: Arc<RwLock<BTreeMap<WalletId, Arc<PlatformWallet>>>>,
-    background_cancel: StdMutex<Option<CancellationToken>>,
+    background_cancel: StdMutex<Option<(u64, CancellationToken)>>,
+    run_id: AtomicU64,
     interval_secs: AtomicU64,
     is_syncing: AtomicBool,
     quiescing: AtomicBool,
     last_sync_unix: AtomicU64,
 }

 impl DashPaySyncManager {
     pub fn new(wallets: Arc<RwLock<BTreeMap<WalletId, Arc<PlatformWallet>>>>) -> Self {
         Self {
             wallets,
             background_cancel: StdMutex::new(None),
+            run_id: AtomicU64::new(1),
             interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS),
             is_syncing: AtomicBool::new(false),
             quiescing: AtomicBool::new(false),
             last_sync_unix: AtomicU64::new(0),
         }
     }

     pub fn start(self: Arc<Self>) {
         let mut guard = self.background_cancel.lock().expect("bg_cancel poisoned");
         if guard.is_some() {
             return;
         }
         let cancel = CancellationToken::new();
-        *guard = Some(cancel.clone());
+        let run_id = self.run_id.fetch_add(1, Ordering::Relaxed);
+        *guard = Some((run_id, cancel.clone()));
         drop(guard);

         let handle = tokio::runtime::Handle::current();
         let this = self;
         std::thread::Builder::new()
             .name("dashpay-sync".into())
             .spawn(move || {
                 handle.block_on(async move {
@@
-                    if let Ok(mut guard) = this.background_cancel.lock() {
-                        *guard = None;
+                    if let Ok(mut guard) = this.background_cancel.lock() {
+                        if guard.as_ref().map(|(id, _)| *id) == Some(run_id) {
+                            *guard = None;
+                        }
                     }
                 });
             })
             .expect("failed to spawn dashpay-sync thread");
     }

     pub fn stop(&self) {
-        if let Some(token) = self
+        if let Some((_, token)) = self
             .background_cancel
             .lock()
             .expect("bg_cancel poisoned")
             .take()
         {
             token.cancel();
         }
     }
 }

Also applies to: 238-245

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/manager/dashpay_sync.rs` around lines 221 -
223, The background loop cleanup currently unconditionally sets
this.background_cancel to None (in the block near start()), which can overwrite
a newer token if stop() and start() race; change the logic so the background
thread only clears background_cancel if the stored cancel token it captured at
spawn time still matches the current token in this.background_cancel (i.e.,
capture the Arc/ID of the cancel handle when spawning and
compare-before-clearing); apply the same compare-and-clear pattern in the stop()
/ thread-exit cleanup (references: this.background_cancel, start(), stop()) so a
late-exiting old loop cannot null out a replacement token.

Comment on lines +452 to +509
fn parse_contact_request_doc(
doc: &dpp::document::Document,
sender_id: Identifier,
recipient_id: Identifier,
) -> Option<ContactRequest> {
let props = doc.properties();
let sender_key_index = props
.get("senderKeyIndex")
.and_then(|v: &Value| v.to_integer::<u32>().ok());
let recipient_key_index = props
.get("recipientKeyIndex")
.and_then(|v: &Value| v.to_integer::<u32>().ok());
let account_reference = props
.get("accountReference")
.and_then(|v: &Value| v.to_integer::<u32>().ok());
let encrypted_public_key = props
.get("encryptedPublicKey")
.and_then(|v: &Value| v.as_bytes())
.cloned();

match (
sender_key_index,
recipient_key_index,
account_reference,
encrypted_public_key,
) {
(Some(ski), Some(rki), Some(ar), Some(epk)) => Some(ContactRequest::new(
sender_id,
recipient_id,
ski,
rki,
ar,
epk,
doc.created_at_core_block_height().unwrap_or(0),
doc.created_at().unwrap_or(0),
)),
_ => {
tracing::warn!(
sender = %sender_id,
recipient = %recipient_id,
"Skipping contact request document: missing required field"
);
None
}
}
}

/// Parse our own sent `contactRequest` document into a [`ContactRequest`]
/// (owner is us, recipient is `toUserId`).
fn parse_sent_contact_request_doc(
doc: &dpp::document::Document,
owner_id: Identifier,
recipient_id: Identifier,
) -> Option<ContactRequest> {
// 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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sync ingest is dropping optional contact-request payloads.

parse_contact_request_doc() only preserves the required fields, so both the received-side ingest and the sent-side reconcile path lose encryptedAccountLabel and autoAcceptProof even though ContactRequest carries them and sent_contact_requests() parses them later in this file. After a restore/sync sweep those values become None, which breaks metadata round-tripping and any auto-accept behavior that depends on the proof.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`
around lines 452 - 509, parse_contact_request_doc currently only extracts
required fields and drops optional fields encryptedAccountLabel and
autoAcceptProof, causing restores to lose these values; update
parse_contact_request_doc (and thus parse_sent_contact_request_doc which calls
it) to also read props.get("encryptedAccountLabel").and_then(|v: &Value|
v.as_str()).map(|s| s.to_owned()) and props.get("autoAcceptProof").and_then(|v:
&Value| v.as_bytes()).cloned() (or appropriate conversions) and pass them into
ContactRequest::new (or the appropriate constructor/factory) so the
ContactRequest created preserves encryptedAccountLabel and autoAcceptProof
during ingest/reconcile. Ensure the match arm pattern includes these Option
values and the fallback logging remains unchanged.

Comment on lines +116 to +130
self.incoming_contact_requests.remove(sender_id);

let tombstone = RejectedContactRequest {
owner_id,
sender_id: *sender_id,
account_reference,
document_id,
};
self.rejected_contact_requests
.insert((*sender_id, account_reference), tombstone.clone());

let mut cs = ContactChangeSet::default();
cs.rejected
.insert((owner_id, *sender_id, account_reference), tombstone);
cs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Persist the incoming-removal when recording a rejection tombstone.

At Line 116, the method removes the incoming request from in-memory state, but the returned changeset (Line 127-Line 130) only contains rejected. On replay, rejected does not remove incoming entries, so rejected requests can reappear after restore.

Proposed fix
 pub fn record_rejected_contact_request(
     &mut self,
     sender_id: &Identifier,
     account_reference: u32,
     document_id: Option<Identifier>,
 ) -> ContactChangeSet {
     let owner_id = self.id();
-    self.incoming_contact_requests.remove(sender_id);
+    let removed_incoming = self.incoming_contact_requests.remove(sender_id).is_some();

     let tombstone = RejectedContactRequest {
         owner_id,
         sender_id: *sender_id,
         account_reference,
         document_id,
     };
     self.rejected_contact_requests
         .insert((*sender_id, account_reference), tombstone.clone());

     let mut cs = ContactChangeSet::default();
+    if removed_incoming {
+        cs.removed_incoming.insert(ReceivedContactRequestKey {
+            owner_id,
+            sender_id: *sender_id,
+        });
+    }
     cs.rejected
         .insert((owner_id, *sender_id, account_reference), tombstone);
     cs
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`
around lines 116 - 130, The code removes an incoming request from
self.incoming_contact_requests but the returned ContactChangeSet only records
cs.rejected, so on replay the incoming entry isn't removed; update the change
set returned by the function to also include the incoming-removal for (owner_id,
*sender_id, account_reference) (i.e., add the corresponding removal entry to the
ContactChangeSet alongside cs.rejected) so that replay will delete the
incoming_contact_requests entry when applying the rejection tombstone.

@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.22%. Comparing base (f4ed60f) to head (9441316).
⚠️ Report is 21 commits behind head on v3.1-dev.

Files with missing lines Patch % Lines
packages/rs-platform-encryption/src/lib.rs 33.33% 12 Missing ⚠️
...tform-wallet-storage/src/sqlite/schema/contacts.rs 95.00% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##           v3.1-dev    #3841       +/-   ##
=============================================
+ Coverage     70.73%   87.22%   +16.49%     
=============================================
  Files            20     2641     +2621     
  Lines          2788   328569   +325781     
=============================================
+ Hits           1972   286597   +284625     
- Misses          816    41972    +41156     
Components Coverage Δ
dpp 87.69% <87.50%> (∅)
drive 86.14% <90.81%> (∅)
drive-abci 89.45% <99.07%> (∅)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.20% <ø> (∅)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.55% <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

M1 of the DashPay completion plan: the SDK entropy / DIP-15 compact xpub / key-purpose interop fixes are correct, but six in-scope correctness issues block merge. The most concerning is editing V001 in-place (violates the documented append-only migration policy and bricks DB rehydration for the v4.0.0-beta.4 cohort). Additional blockers: the reject path emits an incomplete ChangeSet (no removed_incoming); the new rejected_contact_requests table is written but never read; transient identity fetches in register_external_contact_account are misclassified as permanent and brick the channel; validation.purpose_mismatch is set even when a hard error is also present, masking permanent failures as retryable; and the sync sweep skips superseding requests from established contacts, making the documented payment_channel_broken recovery path unreachable.

🔴 6 blocking | 🟡 2 suggestion(s)

2 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/migrations/V001__initial.rs:186-213: V001 migration edited in-place violates append-only policy and breaks upgrade from v4.0.0-beta.4
  This PR adds `contacts.payment_channel_broken` and a new `rejected_contact_requests` table by editing V001 directly. V001 (without these additions) was already shipped in `v4.0.0-beta.4` (commit da9d3fe84e / schema confirmed via `git show`), and `packages/rs-platform-wallet-storage/README.md:106` explicitly states migrations are append-only and applied by refinery on every `open`. refinery checksums each migration in `refinery_schema_history`; against an existing v4.0.0-beta.4 DB it will either abort with a divergent-checksum error or silently skip V001 (already applied) — in which case neither the new table nor the new column is ever created, and the first runtime write in `contacts.rs:240` (`INSERT INTO rejected_contact_requests …`) or `contacts.rs:194-212` (`payment_channel_broken` column) fails at the SQLite layer. `tc029_migration_fingerprint_stable` does not catch this because it only checks self-stability, not a pinned hash. Add `V002__dashpay_reject_and_broken_channel.rs` doing `ALTER TABLE contacts ADD COLUMN payment_channel_broken INTEGER` and `CREATE TABLE rejected_contact_requests (…)`; the loader at `contacts.rs::load_state` already tolerates NULL `payment_channel_broken`, so a default-less ALTER is compatible.

In `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs:109-131: `record_rejected_contact_request` removes incoming in memory but does not emit `removed_incoming`
  The function calls `self.incoming_contact_requests.remove(sender_id)` and returns a `ContactChangeSet` populated only with `cs.rejected`. The unified-`contacts`-table writer at `rs-platform-wallet-storage/src/sqlite/schema/contacts.rs:182-193` only `DELETE`s when `cs.removed_incoming` is non-empty, so the previously persisted state='received' row (with the `incoming_request` blob) stays in SQLite. Once `persister.load()` (TODO at `sqlite/persister.rs:909`) is wired up, the unified contacts reader rebuckets that row as an incoming request, `apply_changeset` re-inserts it into `incoming_contact_requests`, and the FFI surfaces the explicitly-rejected request back to the UI. The persisted delta is also internally inconsistent with the in-memory mutation — a delta-persistence invariant violation. The in-memory `rejected_tombstone_round_trips_and_respects_account_reference` test does not catch this because it round-trips via `apply_changeset`, not the SQLite reader. Fix by also inserting a matching `ReceivedContactRequestKey { owner_id, sender_id: *sender_id }` into `cs.removed_incoming`.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: `rejected_contact_requests` is written but never read — tombstones lost across restart
  The PR adds a writer (`contacts.rs:240`), a migration row (`V001__initial.rs:203`), and an `apply_changeset` branch that restores `ManagedIdentity.rejected_contact_requests` from `cs.rejected`. But `managed_identity_from_entry` hard-codes `rejected_contact_requests: Default::default()` (line 214), and grep confirms no `load_state` reader for the new table. Once `persister.load()` (TODO at `sqlite/persister.rs:909`) is wired up, the in-memory tombstone map is always empty after restart even though SQLite holds the rows. `is_request_rejected` then returns `false`, the sweep's tombstone-skip in `network/contact_requests.rs:396-404` does not fire, and the recurring DashPay loop (G12) resurrects every rejected request on the first sweep — exactly the M1 failure mode the SPEC.md cites as the reason G5 must land with G12. Add a per-wallet `load_state` for `rejected_contact_requests` and route its output into the `ContactChangeSet.rejected` synthesized during rehydration.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:711-728: Transient identity fetch failures inside account-build are marked as permanent
  `build_contact_accounts` treats any error from `register_external_contact_account` as permanent and calls `mark_contact_channel_broken`. But `register_external_contact_account` (`network/contacts.rs:400-407`) performs another `Identity::fetch` for the same contact and wraps the DAPI/network error as `PlatformWalletError::InvalidIdentityData`. A transient DAPI hiccup after validation therefore permanently disables the payment channel; subsequent sweeps skip the contact via the `payment_channel_broken` filter at line 530, and recovery only fires if a superseding contactRequest happens to arrive — contradicting the policy in the docstring at lines 573-578 ("Transient (identity fetch / network): logged, left for the next sweep to retry. The broken flag stays clear."). The fix is to perform the contact-identity fetch and treat its failure as transient *before* calling `register_external_contact_account` (mirroring the existing fetch at lines 631-655) and to scope the permanent-broken classification to genuinely non-recoverable failures (decrypt/decode, missing-key, key-type mismatch).
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:383-392: Superseding requests from established contacts are skipped — `payment_channel_broken` recovery is unreachable
  The received-side ingest drops every doc whose sender is already in `established_contacts` before consulting `accountReference`. `EstablishedContact::reestablish_preserving_metadata` exists precisely to clear `payment_channel_broken = false` when a fresh request flows in (see `types/dashpay/established_contact.rs:84-104`), and `collect_account_build_candidates` documents the recovery contract at lines 528-529 ("never retry a permanently-broken channel — wait for a superseding request (which clears the flag on re-establish)"). But there is no path that reaches `reestablish_preserving_metadata` for an already-established sender from the sync sweep — `add_incoming_contact_request` is only called for new senders here, and the send-side guard at `state/managed_identity/contact_requests.rs:46-48` similarly returns early for established contacts. Net effect: once `payment_channel_broken` is set, it stays set forever. Either (a) detect a superseding incoming request (new `accountReference` for the same sender) and route it through the reestablish path, or (b) change the broken-channel policy so the next sweep can retry under controlled conditions.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:5733-5749: `select_recipient_key_index` returns disabled keys
  The send-side recipient-key selector iterates `recipient_identity.public_keys()` and returns the first key whose purpose is DECRYPTION (then ENCRYPTION) and whose type is ECDSA_SECP256K1, with no `disabled_at` check. `validate_contact_request` in `crypto/validation.rs` does gate on disabled keys, so if a preferred DECRYPTION key has been rotated/disabled this selector returns it anyway and the broadcast fails downstream with an opaque error instead of falling through to a usable ENCRYPTION key on the same identity. Add `&& k.disabled_at().is_none()` to both branches so selection is consistent with validation.

In `packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs:50-62: `purpose_mismatch` is set even when a non-purpose hard error is also present
  The docstring at lines 19-29 contracts `purpose_mismatch` as `true` *only* when the sole reason for invalidity is a key-purpose mismatch — it is what tells `build_contact_accounts` at `network/contact_requests.rs:689` to treat the failure as a non-permanent skip instead of marking the channel permanently broken. The implementation does not preserve that invariant: `add_purpose_error` unconditionally sets `purpose_mismatch = true`, and `add_error` never clears it. A request whose key has both a wrong key type (hard, permanent error) and a wrong purpose ends up with `is_valid = false, purpose_mismatch = true`, and the caller skips + retries forever instead of marking broken. The mobile testnet census makes this rare in practice today, but the classifier is the load-bearing primitive the recovery policy is built on — fix `add_error` to clear the flag, and fix `add_purpose_error` to only set it if no prior hard error was recorded.

In `packages/rs-platform-wallet/src/manager/dashpay_sync.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/dashpay_sync.rs:192-227: `stop()` followed quickly by `start()` can let the old thread null out the new cancel token
  `stop()` takes the current `background_cancel`, sets it to `None`, then cancels the old token. The spawned thread exits its loop on cancellation and at lines 221-223 re-acquires the guard and writes `*guard = None`. If `start()` runs between `stop()` and the old thread's cleanup block, the old thread's final clear will overwrite the new token just installed by `start()` — leaving `background_cancel` empty while a fresh sync thread is still running, so a subsequent `stop()`/`quiesce()` will be a no-op against that running thread. The normal shutdown path (`quiesce` waits for in-flight passes) does not hit this, but bare `stop()`/`start()` races can. Fix by capturing the token at spawn time and only clearing the guard if it still holds that same token (`if matches!(*guard, Some(t) if Arc::ptr_eq(...)) { *guard = None; }`).

Comment on lines 186 to +213
note TEXT,
is_hidden INTEGER,
accepted_accounts BLOB,
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
);

-- Rejected-request tombstone (G5 stage 1). Keyed by
-- `(wallet_id, owner_id, sender_id, account_reference)` — NOT bare
-- sender id — so a once-rejected sender can still re-request via a
-- bumped accountReference (the DIP-15 rotation mechanism), while a
-- replay of the exact same immutable request stays suppressed. The
-- optional `document_id` records the rejected document's id for audit /
-- exact-match suppression. The sync ingest path consults this table
-- before re-ingesting a received contactRequest.
CREATE TABLE rejected_contact_requests (
wallet_id BLOB NOT NULL,
owner_id BLOB NOT NULL,
sender_id BLOB NOT NULL,
account_reference INTEGER NOT NULL,
document_id BLOB,
rejected_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (wallet_id, owner_id, sender_id, account_reference),
FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: V001 migration edited in-place violates append-only policy and breaks upgrade from v4.0.0-beta.4

This PR adds contacts.payment_channel_broken and a new rejected_contact_requests table by editing V001 directly. V001 (without these additions) was already shipped in v4.0.0-beta.4 (commit da9d3fe / schema confirmed via git show), and packages/rs-platform-wallet-storage/README.md:106 explicitly states migrations are append-only and applied by refinery on every open. refinery checksums each migration in refinery_schema_history; against an existing v4.0.0-beta.4 DB it will either abort with a divergent-checksum error or silently skip V001 (already applied) — in which case neither the new table nor the new column is ever created, and the first runtime write in contacts.rs:240 (INSERT INTO rejected_contact_requests …) or contacts.rs:194-212 (payment_channel_broken column) fails at the SQLite layer. tc029_migration_fingerprint_stable does not catch this because it only checks self-stability, not a pinned hash. Add V002__dashpay_reject_and_broken_channel.rs doing ALTER TABLE contacts ADD COLUMN payment_channel_broken INTEGER and CREATE TABLE rejected_contact_requests (…); the loader at contacts.rs::load_state already tolerates NULL payment_channel_broken, so a default-less ALTER is compatible.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — V001 migration edited in-place violates append-only policy and breaks upgrade from v4.0.0-beta.4 no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +109 to +131
pub fn record_rejected_contact_request(
&mut self,
sender_id: &Identifier,
account_reference: u32,
document_id: Option<Identifier>,
) -> ContactChangeSet {
let owner_id = self.id();
self.incoming_contact_requests.remove(sender_id);

let tombstone = RejectedContactRequest {
owner_id,
sender_id: *sender_id,
account_reference,
document_id,
};
self.rejected_contact_requests
.insert((*sender_id, account_reference), tombstone.clone());

let mut cs = ContactChangeSet::default();
cs.rejected
.insert((owner_id, *sender_id, account_reference), tombstone);
cs
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: record_rejected_contact_request removes incoming in memory but does not emit removed_incoming

The function calls self.incoming_contact_requests.remove(sender_id) and returns a ContactChangeSet populated only with cs.rejected. The unified-contacts-table writer at rs-platform-wallet-storage/src/sqlite/schema/contacts.rs:182-193 only DELETEs when cs.removed_incoming is non-empty, so the previously persisted state='received' row (with the incoming_request blob) stays in SQLite. Once persister.load() (TODO at sqlite/persister.rs:909) is wired up, the unified contacts reader rebuckets that row as an incoming request, apply_changeset re-inserts it into incoming_contact_requests, and the FFI surfaces the explicitly-rejected request back to the UI. The persisted delta is also internally inconsistent with the in-memory mutation — a delta-persistence invariant violation. The in-memory rejected_tombstone_round_trips_and_respects_account_reference test does not catch this because it round-trips via apply_changeset, not the SQLite reader. Fix by also inserting a matching ReceivedContactRequestKey { owner_id, sender_id: *sender_id } into cs.removed_incoming.

Suggested change
pub fn record_rejected_contact_request(
&mut self,
sender_id: &Identifier,
account_reference: u32,
document_id: Option<Identifier>,
) -> ContactChangeSet {
let owner_id = self.id();
self.incoming_contact_requests.remove(sender_id);
let tombstone = RejectedContactRequest {
owner_id,
sender_id: *sender_id,
account_reference,
document_id,
};
self.rejected_contact_requests
.insert((*sender_id, account_reference), tombstone.clone());
let mut cs = ContactChangeSet::default();
cs.rejected
.insert((owner_id, *sender_id, account_reference), tombstone);
cs
}
let owner_id = self.id();
self.incoming_contact_requests.remove(sender_id);
let tombstone = RejectedContactRequest {
owner_id,
sender_id: *sender_id,
account_reference,
document_id,
};
self.rejected_contact_requests
.insert((*sender_id, account_reference), tombstone.clone());
let mut cs = ContactChangeSet::default();
cs.removed_incoming.insert(ReceivedContactRequestKey {
owner_id,
sender_id: *sender_id,
});
cs.rejected
.insert((owner_id, *sender_id, account_reference), tombstone);
cs

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — record_rejected_contact_request removes incoming in memory but does not emit removed_incoming no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +711 to +728
if let Err(e) = self
.register_external_contact_account(
identity_id,
&contact_id,
&candidate.encrypted_public_key,
candidate.our_decryption_key_index,
candidate.contact_encryption_key_index,
)
.await
{
tracing::warn!(
identity = %identity_id,
contact = %contact_id,
error = %e,
"Failed to register DashPay external account; marking payment channel broken (permanent)"
);
self.mark_contact_channel_broken(identity_id, &contact_id)
.await;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Transient identity fetch failures inside account-build are marked as permanent

build_contact_accounts treats any error from register_external_contact_account as permanent and calls mark_contact_channel_broken. But register_external_contact_account (network/contacts.rs:400-407) performs another Identity::fetch for the same contact and wraps the DAPI/network error as PlatformWalletError::InvalidIdentityData. A transient DAPI hiccup after validation therefore permanently disables the payment channel; subsequent sweeps skip the contact via the payment_channel_broken filter at line 530, and recovery only fires if a superseding contactRequest happens to arrive — contradicting the policy in the docstring at lines 573-578 ("Transient (identity fetch / network): logged, left for the next sweep to retry. The broken flag stays clear."). The fix is to perform the contact-identity fetch and treat its failure as transient before calling register_external_contact_account (mirroring the existing fetch at lines 631-655) and to scope the permanent-broken classification to genuinely non-recoverable failures (decrypt/decode, missing-key, key-type mismatch).

source: ['codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Transient identity fetch failures inside account-build are marked as permanent no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines 52 to +62
self.is_valid = false;
}

/// Add a key-PURPOSE error: sets `is_valid = false` AND flags
/// `purpose_mismatch` so callers can downgrade this to a non-permanent
/// skip rather than a permanent broken-channel mark (G15).
pub fn add_purpose_error(&mut self, error: String) {
self.errors.push(error);
self.is_valid = false;
self.purpose_mismatch = true;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: purpose_mismatch is set even when a non-purpose hard error is also present

The docstring at lines 19-29 contracts purpose_mismatch as true only when the sole reason for invalidity is a key-purpose mismatch — it is what tells build_contact_accounts at network/contact_requests.rs:689 to treat the failure as a non-permanent skip instead of marking the channel permanently broken. The implementation does not preserve that invariant: add_purpose_error unconditionally sets purpose_mismatch = true, and add_error never clears it. A request whose key has both a wrong key type (hard, permanent error) and a wrong purpose ends up with is_valid = false, purpose_mismatch = true, and the caller skips + retries forever instead of marking broken. The mobile testnet census makes this rare in practice today, but the classifier is the load-bearing primitive the recovery policy is built on — fix add_error to clear the flag, and fix add_purpose_error to only set it if no prior hard error was recorded.

Suggested change
self.is_valid = false;
}
/// Add a key-PURPOSE error: sets `is_valid = false` AND flags
/// `purpose_mismatch` so callers can downgrade this to a non-permanent
/// skip rather than a permanent broken-channel mark (G15).
pub fn add_purpose_error(&mut self, error: String) {
self.errors.push(error);
self.is_valid = false;
self.purpose_mismatch = true;
}
pub fn add_error(&mut self, error: String) {
self.errors.push(error);
self.is_valid = false;
self.purpose_mismatch = false;
}
/// Add a key-PURPOSE error: sets `is_valid = false` AND flags
/// `purpose_mismatch` so callers can downgrade this to a non-permanent
/// skip rather than a permanent broken-channel mark (G15).
pub fn add_purpose_error(&mut self, error: String) {
let already_has_hard_error = !self.errors.is_empty() && !self.purpose_mismatch;
self.errors.push(error);
self.is_valid = false;
self.purpose_mismatch = !already_has_hard_error;
}

source: ['codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — purpose_mismatch is set even when a non-purpose hard error is also present no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +383 to 392
// G1a: do NOT skip just because the sender is in
// `sent_contact_requests` — that is the reciprocal we
// need to let through to auto-establish. Skip only when
// already tracked as incoming or established (true
// dedup), or suppressed by a rejected-request tombstone.
if managed.incoming_contact_requests.contains_key(&sender_id)
|| managed.established_contacts.contains_key(&sender_id)
{
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Superseding requests from established contacts are skipped — payment_channel_broken recovery is unreachable

The received-side ingest drops every doc whose sender is already in established_contacts before consulting accountReference. EstablishedContact::reestablish_preserving_metadata exists precisely to clear payment_channel_broken = false when a fresh request flows in (see types/dashpay/established_contact.rs:84-104), and collect_account_build_candidates documents the recovery contract at lines 528-529 ("never retry a permanently-broken channel — wait for a superseding request (which clears the flag on re-establish)"). But there is no path that reaches reestablish_preserving_metadata for an already-established sender from the sync sweep — add_incoming_contact_request is only called for new senders here, and the send-side guard at state/managed_identity/contact_requests.rs:46-48 similarly returns early for established contacts. Net effect: once payment_channel_broken is set, it stays set forever. Either (a) detect a superseding incoming request (new accountReference for the same sender) and route it through the reestablish path, or (b) change the broken-channel policy so the next sweep can retry under controlled conditions.

source: ['codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Superseding requests from established contacts are skipped — payment_channel_broken recovery is unreachable no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +192 to +227
pub fn start(self: Arc<Self>) {
let mut guard = self.background_cancel.lock().expect("bg_cancel poisoned");
if guard.is_some() {
return;
}
let cancel = CancellationToken::new();
*guard = Some(cancel.clone());
drop(guard);

let handle = tokio::runtime::Handle::current();
let this = self;
std::thread::Builder::new()
.name("dashpay-sync".into())
.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,
}
}

if let Ok(mut guard) = this.background_cancel.lock() {
*guard = None;
}
});
})
.expect("failed to spawn dashpay-sync thread");
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: stop() followed quickly by start() can let the old thread null out the new cancel token

stop() takes the current background_cancel, sets it to None, then cancels the old token. The spawned thread exits its loop on cancellation and at lines 221-223 re-acquires the guard and writes *guard = None. If start() runs between stop() and the old thread's cleanup block, the old thread's final clear will overwrite the new token just installed by start() — leaving background_cancel empty while a fresh sync thread is still running, so a subsequent stop()/quiesce() will be a no-op against that running thread. The normal shutdown path (quiesce waits for in-flight passes) does not hit this, but bare stop()/start() races can. Fix by capturing the token at spawn time and only clearing the guard if it still holds that same token (if matches!(*guard, Some(t) if Arc::ptr_eq(...)) { *guard = None; }).

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 4e0bffastop() followed quickly by start() can let the old thread null out the new cancel token no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

shumkov added 6 commits June 12, 2026 21:31
…hout order-by

Two devnet-UAT fixes on the rs-sdk side:

- contact_request_queries: add explicit `ORDER BY $createdAt` to both
  fetch_received/fetch_sent queries. Drive answers a bare
  secondary-index equality (toUserId / $ownerId) with a verified
  proof of ABSENCE even when matching documents exist — isolated
  live against devnet with a host-side probe (equality-only: 0 docs;
  with order-by: found). The order-by binds the query to the
  (field, $createdAt) index so results return. Worth a platform
  issue: drive should reject the under-specified query instead of
  proving absence.

- rs-sdk-ffi: 8MB tokio worker stacks. GroveDB document-query proof
  verification (verify_layer_proof_v1) recurses deep enough to
  overflow the platform-default stack (SIGBUS on the stack guard,
  observed on-device).

No test: requires a live drive node answering proofs; pinned by the
on-device UAT flow (docs/dashpay/SPEC.md Part 7 e2e plan covers it
once PR #3549 lands).
…lock

Devnet UAT (2026-06-12) showed the receiver's payment history was
always empty ("Payments (0)") and friendship-account UTXOs were
silently dropped on every relaunch. Three root causes, all fixed:

1. Incoming payments were never recorded: the old
   try_record_incoming_payment had ZERO callers. Replaced with
   record_incoming_dashpay_payments wired into the wallet-event
   adapter (core_bridge) — every TransactionDetected output paying a
   DashpayReceivingFunds address now records a Received PaymentEntry
   on the owning managed identity, idempotent per txid.

2. No recovery for missed/restored payments: new
   reconcile_incoming_payments() derives missing Received entries
   from the receival accounts' UTXO sets; runs as a local-only third
   step of dashpay_sync() each sweep. Never clobbers an existing
   txid entry (e.g. the sender's own Sent record when both
   identities share a wallet).

3. DashPay account registrations were in-memory only:
   register_contact_account / register_external_contact_account now
   persist an AccountRegistrationEntry + initial pool snapshot (same
   round shape as wallet creation), emitted BEFORE the in-memory
   inserts. Without this the accounts vanished on relaunch and the
   UTXO restore dropped their rows (load: dropped_no_account=2
   observed live). register_contact_account also gains the missing
   early-exit and now mirrors the restored shape into the immutable
   wallet.accounts collection.

Tests (red->green demonstrated against the unfixed code):
- register_contact_account_persists_account_registration: FAILED
  before (no store round), passes after.
- reconcile_records_received_payments_from_receival_utxos: FAILED
  before (stub recorded 0), passes after; also pins idempotency.
- reconcile_does_not_clobber_existing_entry_for_same_txid.
204/204 platform-wallet lib tests green.

Also: attach_wallet_seed manager API + FFI
(platform_wallet_manager_attach_wallet_seed_from_mnemonic) — wallets
rehydrate external-signable after relaunch with the mnemonic still
in the host keychain; this upgrades them in place (idempotent,
SeedMismatch-guarded, BIP44-0 xpub-equality fallback for
pre-network-scoped wallet ids). dashpay-sync loop thread gets an
8MB stack (GroveDB proof recursion SIGBUS, observed on-device).
…payment history

SPEC Part 6 ("nice UI") + M2 tasks 7-11, verified end-to-end on a
devnet: profile create, add contact by id, request/accept,
established contacts, send 0.01 DASH with txid in sender history,
received payments on the recipient's side across relaunches.

FFI (rs-platform-wallet-ffi):
- dashpay_sync.rs: 7 platform_wallet_manager_dashpay_sync_* symbols
  (start/stop/sync_now/is_syncing/is_running/interval get+set);
  sync_now runs via block_on_worker (8MB worker — GroveDB proof
  recursion overflows the caller thread's stack).
- dashpay_payment.rs: managed_identity_get_dashpay_payments getter.
- Persister callback arity 8→10: payment_channel_broken +
  contact-request rejection tombstones now cross the boundary.

Swift SDK:
- PersistentDashpayPayment model + persistence bridge;
  PersistentDashpayContactRequest gains rejection fields;
  PersistentIdentity payment relationship.
- PlatformWalletManagerDashPaySync: start/stop/refresh +
  @published dashPaySyncIsSyncing (1 Hz poll, sibling convention).
- Keychain unlock hook in loadFromPersistor: re-attaches the wallet
  seed via attach_wallet_seed so rehydrated wallets can sign.

SwiftExampleApp:
- New DashPay root tab (Views/DashPay/, 7 views): identity picker
  with @AppStorage persistence, profile header + editor, contacts +
  requests segments (incoming accept/reject, outgoing pending),
  add-contact (DPNS search + identity-id modes), contact detail
  (payments history, local alias/note/hide), send sheet. All §6.4
  interaction states; dashpay.* accessibility ids throughout.
- Contacts consolidated into the DashPay tab: legacy FriendsView
  (917 lines) deleted; IdentityDetailView's DashPay section now
  deep-links to the tab with the identity pre-selected (root tab
  selection moved to AppUIState). SendDashPayPaymentSheet +
  DashPayContact moved to Views/DashPay/.
- AddContactView guards partial base58 input (<32-byte decode
  crashed the FFI identifier precondition).

Tests: DashPayPersistenceTests (15 — persister bridge, tombstone
rotation-survival, payments), DashPayTabUITests (smoke).
Marks M2 + the receiver-side payment path as live-verified
(2026-06-12, devnet): account registrations now persisted, incoming
payments recorded live + reconciled after restore. Notes the drive
query-absence behaviour (equality without order-by proves absence)
referenced from the rs-sdk fix.
…detail

Contacts live in the DashPay tab now — the redirect row added during
the consolidation was an extra menu item with no unique function.
The identity screen keeps only identity-owned concerns (keys, DPNS,
balance, profile).
Three placement fixes from UI review:

- Sync page gains a "DashPay Sync Status" section (spinner while a
  pass is in flight, relative last-sync stamp from the FFI,
  Recurring/Stopped state, Sync Now) — the recurring DashPay loop
  was previously invisible there.
- DashPay tab shows "Received from contacts" under the profile
  header: the active identity's DashpayReceivingFunds balances,
  read from the same lock-free account-balance call the wallet
  list uses.
- Wallets account list hides the DashPay friendship accounts
  (tags 12/13): per-contact protocol plumbing that would bloat the
  list as contacts grow, and external accounts watch the contact's
  addresses (not our funds). Totals are unaffected — receiving
  funds already roll into Core Balance (verified live:
  9.39698657 = BIP44 9.37698657 + 0.02 received); the Storage
  Explorer still lists the raw rows.

Verified on-sim: sync section shows "Last sync: 5 secs /
Recurring"; DashPay tab shows 0.02000000 DASH received; no DashPay
rows remain in the Wallets account list.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift (1)

421-465: ⚡ Quick win

Also assert the payment rows roll back in this atomicity test.

The doc comment says a mid-round persistDashpayPayments write must ride the open changeset and roll back with it, but the test only checks PersistentDashpayContactRequest. If payment persistence starts auto-saving again, this still passes. Add a PersistentDashpayPayment fetch before and after endChangeset(..., success: false) so the regression is pinned end-to-end.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift`
around lines 421 - 465, In testPaymentRefreshDoesNotCommitAnOpenChangesetRound,
add assertions that verify the payment row staged by persistDashpayPayments is
not visible mid-round and is rolled back after endChangeset(..., success:
false); specifically, call the existing payment-fetch helper (or add/rename a
fetch function for PersistentDashpayPayment rows) to assert count == 0
immediately after the mid-round persist and again after
handler.endChangeset(..., success: false), mirroring the contact-row assertions
so the test verifies payment atomicity as well as contact atomicity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 1919-1925: persistDashpayPayments is swallowing failures from
backgroundContext.save() via try?, which can silently drop payment-history
updates; change the save to propagate or log errors instead of ignoring them:
replace the try? backgroundContext.save() with a throwing or do/catch path
inside persistDashpayPayments that captures the thrown error from
backgroundContext.save(), records telemetry/logging (or rethrows to the caller)
with context (e.g., include which payment batch or wallet ID), and preserve the
existing inChangeset check (if !self.inChangeset) so the save still only runs
when appropriate; update callers or function signature as needed to handle
propagated errors or ensure telemetry is emitted in the catch.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`:
- Around line 35-40: The optimisticSentIds and ownProfile state are
identity-scoped but currently persist across identity switches; update the
activeIdentity handling (the Task that observes activeIdentity) to reset
identity-scoped UI state at the start of the task: clear optimisticSentIds and
set ownProfile to nil (or otherwise remove cached profile) before loading;
alternatively refactor optimisticSentIds and ownProfile to be keyed by owner
identity (e.g., a dictionary keyed by activeIdentity.id) and read/write via that
key, and ensure loadOwnProfileFromCache() does not retain the previous profile
on read failure for the new identity but returns nil so the UI doesn’t show the
old identity’s data.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift`:
- Around line 1235-1243: The avatar downloader currently accepts any parseable
URL, allowing non-HTTPS schemes; update fetchAvatarBytes to explicitly validate
the URL scheme and reject anything not exactly "https" before creating the
URLRequest, returning an error (or nil) for non-https inputs; locate
fetchAvatarBytes (and the analogous implementation referenced around lines
1390-1410) and add a guard that checks url.scheme?.lowercased() == "https" and
fails early with a clear error to prevent http or other schemes from being
fetched.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swift`:
- Around line 92-97: Replace the immediate existence check on the toolbar
refresh button with a timed wait to avoid flakes: locate the `refresh` query
using `Identifier.refreshButton` in DashPayTabUITests (variable `refresh`) and
change the assertion to call `refresh.waitForExistence(timeout: ...)` instead of
checking `refresh.exists`, keeping the same failure message; choose a reasonable
timeout (e.g., 1–5s) consistent with other tests.

---

Nitpick comments:
In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift`:
- Around line 421-465: In testPaymentRefreshDoesNotCommitAnOpenChangesetRound,
add assertions that verify the payment row staged by persistDashpayPayments is
not visible mid-round and is rolled back after endChangeset(..., success:
false); specifically, call the existing payment-fetch helper (or add/rename a
fetch function for PersistentDashpayPayment rows) to assert count == 0
immediately after the mid-round persist and again after
handler.endChangeset(..., success: false), mirroring the contact-row assertions
so the test verifies payment atomicity as well as contact atomicity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c0b4a7c-c449-41c7-bd16-7979ff30c777

📥 Commits

Reviewing files that changed from the base of the PR and between 9f770b8 and a51606d.

📒 Files selected for processing (42)
  • docs/dashpay/SPEC.md
  • packages/rs-platform-wallet-ffi/src/contact_persistence.rs
  • packages/rs-platform-wallet-ffi/src/dashpay_payment.rs
  • packages/rs-platform-wallet-ffi/src/dashpay_sync.rs
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/manager.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/changeset/core_bridge.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/manager/attach_seed.rs
  • packages/rs-platform-wallet/src/manager/dashpay_sync.rs
  • packages/rs-platform-wallet/src/manager/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
  • packages/rs-sdk-ffi/src/sdk.rs
  • packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayPayment.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDashPaySync.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactDetailView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayContactMeta.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/SendDashPayPaymentSheet.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift
💤 Files with no reviewable changes (1)
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swift
✅ Files skipped from review due to trivial changes (3)
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift
  • docs/dashpay/SPEC.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/manager/mod.rs

Comment on lines +1919 to +1925
// 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.
if !self.inChangeset { try? backgroundContext.save() }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Surface save() failures in DashPay payment persistence.

persistDashpayPayments currently swallows backgroundContext.save() failures via try?, which can silently drop payment-history updates without telemetry or caller visibility.

Suggested fix
-            if !self.inChangeset { try? backgroundContext.save() }
+            if !self.inChangeset {
+                do {
+                    try backgroundContext.save()
+                } catch {
+                    NSLog(
+                        "[persistor:swift] persistDashpayPayments save failed: %@",
+                        String(describing: error)
+                    )
+                }
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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.
if !self.inChangeset { try? backgroundContext.save() }
}
// 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.
if !self.inChangeset {
do {
try backgroundContext.save()
} catch {
NSLog(
"[persistor:swift] persistDashpayPayments save failed: %@",
String(describing: error)
)
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`
around lines 1919 - 1925, persistDashpayPayments is swallowing failures from
backgroundContext.save() via try?, which can silently drop payment-history
updates; change the save to propagate or log errors instead of ignoring them:
replace the try? backgroundContext.save() with a throwing or do/catch path
inside persistDashpayPayments that captures the thrown error from
backgroundContext.save(), records telemetry/logging (or rethrows to the caller)
with context (e.g., include which payment batch or wallet ID), and preserve the
existing inChangeset check (if !self.inChangeset) so the save still only runs
when appropriate; update callers or function signature as needed to handle
propagated errors or ensure telemetry is emitted in the catch.

Comment on lines +35 to +40
/// §6.4 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<Data> = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset identity-scoped UI state when the active identity changes.

optimisticSentIds and ownProfile survive a picker switch, and loadOwnProfileFromCache() explicitly keeps the previous profile on a read failure. That means switching from identity A to B before the persister/cache catches up can render A's pending request overlay or profile header under B. Clear those fields at the start of the activeIdentity task, or key them by owner identity instead of storing one tab-wide value.

Also applies to: 44-48, 169-177, 389-402

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`
around lines 35 - 40, The optimisticSentIds and ownProfile state are
identity-scoped but currently persist across identity switches; update the
activeIdentity handling (the Task that observes activeIdentity) to reset
identity-scoped UI state at the start of the task: clear optimisticSentIds and
set ownProfile to nil (or otherwise remove cached profile) before loading;
alternatively refactor optimisticSentIds and ownProfile to be keyed by owner
identity (e.g., a dictionary keyed by activeIdentity.id) and read/write via that
key, and ensure loadOwnProfileFromCache() does not retain the previous profile
on read failure for the new identity but returns nil so the UI doesn’t show the
old identity’s data.

Comment on lines 1235 to 1243
Section("Avatar URL") {
TextField("https://…", text: $avatarUrl)
.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.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce the HTTPS-only avatar rule in code.

The form tells users to paste an HTTPS avatar URL, but fetchAvatarBytes currently accepts any parseable URL. That lets http: URLs be fetched in cleartext and admits schemes this downloader should never touch. Reject anything whose scheme is not https before building the request.

Suggested fix
     private func fetchAvatarBytes(urlString: String) async throws -> Data {
-        guard let url = URL(string: urlString) else {
+        guard let url = URL(string: urlString),
+              url.scheme?.lowercased() == "https" else {
             throw DashPayProfileEditorError.avatarFetchFailed("Invalid URL: \(urlString)")
         }
         var request = URLRequest(url: url)
         request.timeoutInterval = Self.avatarFetchTimeout

Also applies to: 1390-1410

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift`
around lines 1235 - 1243, The avatar downloader currently accepts any parseable
URL, allowing non-HTTPS schemes; update fetchAvatarBytes to explicitly validate
the URL scheme and reject anything not exactly "https" before creating the
URLRequest, returning an error (or nil) for non-https inputs; locate
fetchAvatarBytes (and the analogous implementation referenced around lines
1390-1410) and add a guard that checks url.scheme?.lowercased() == "https" and
fails early with a clear error to prevent http or other schemes from being
fetched.

Comment on lines +92 to +97
let refresh = app.buttons
.matching(identifier: Identifier.refreshButton).firstMatch
XCTAssertTrue(
refresh.exists,
"dashpay.refresh toolbar button must exist alongside the segment."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Wait for the refresh button instead of checking .exists immediately.

After the DashPay segment appears, the toolbar can still land a moment later on slower simulators. refresh.exists makes this smoke test flaky; use waitForExistence(timeout:) here like the other assertions.

Suggested fix
             let refresh = app.buttons
                 .matching(identifier: Identifier.refreshButton).firstMatch
             XCTAssertTrue(
-                refresh.exists,
+                refresh.waitForExistence(timeout: 10),
                 "dashpay.refresh toolbar button must exist alongside the segment."
             )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let refresh = app.buttons
.matching(identifier: Identifier.refreshButton).firstMatch
XCTAssertTrue(
refresh.exists,
"dashpay.refresh toolbar button must exist alongside the segment."
)
let refresh = app.buttons
.matching(identifier: Identifier.refreshButton).firstMatch
XCTAssertTrue(
refresh.waitForExistence(timeout: 10),
"dashpay.refresh toolbar button must exist alongside the segment."
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swift`
around lines 92 - 97, Replace the immediate existence check on the toolbar
refresh button with a timed wait to avoid flakes: locate the `refresh` query
using `Identifier.refreshButton` in DashPayTabUITests (variable `refresh`) and
change the assertion to call `refresh.waitForExistence(timeout: ...)` instead of
checking `refresh.exists`, keeping the same failure message; choose a reasonable
timeout (e.g., 1–5s) consistent with other tests.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift (1)

23-26: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Scope the persisted active-identity key by network.

dashpay.activeIdentityId is shared across every network, so selecting an identity on testnet/devnet overwrites the remembered choice for mainnet too. When the user switches back, activeIdentity falls back to the first eligible identity instead of restoring the last selection on that network.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`
around lines 23 - 26, The persisted AppStorage key stored in DashPayTabView
(`@AppStorage("dashpay.activeIdentityId") private var storedIdentityId`) is
global across networks; change it to be network-scoped by deriving the key from
the current network identifier (e.g., include network.rawValue or chainId) so
each network has its own stored key. Update DashPayTabView to compute the
AppStorage key at runtime (or use a computed property / wrapper that returns
"dashpay.activeIdentityId.\(networkId)") using the view’s network/environment
value and ensure storedIdentityId is read/written through that network-scoped
key so switching networks preserves separate selections.
♻️ Duplicate comments (1)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift (1)

169-177: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset identity-scoped state before loading the next identity.

optimisticSentIds and ownProfile still survive an identity switch, and loadOwnProfileFromCache() explicitly keeps the previous profile on a read failure. That can render identity A's pending-request overlay or profile header under identity B until the cache catches up. Clear those fields at the start of the .task(id:) block, and don't retain the previous ownProfile in the failure path for the new identity.

Also applies to: 420-433

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`
around lines 169 - 177, The task block keyed by .task(id:
activeIdentity?.identityId) is not resetting identity-scoped state: clear
optimisticSentIds and ownProfile immediately at the top of that task before
calling loadOwnProfileFromCache() and walletManager.dashPaySyncNow(); and update
loadOwnProfileFromCache() so that on a cache read failure for the new identity
it does not retain the previous ownProfile (set ownProfile to nil or replace
with an empty/default value) instead of keeping the old profile. Ensure the
reset refers to the existing properties optimisticSentIds and ownProfile and the
loadOwnProfileFromCache() function so the UI doesn't show the previous identity
while the new identity's cache is loaded.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift`:
- Around line 35-42: The visible-empty-state logic is still checking the raw
accounts collection instead of the filtered/sorted list, causing the UI to hide
the "No Accounts" state when only accountType 12/13 are present; update the
empty-state checks to use orderedAccounts.isEmpty (or introduce a
visibleAccounts computed collection that filters out accountType 12/13 and reuse
it everywhere) and replace any usages of accounts.isEmpty / !accounts.isEmpty in
AccountListView with checks against that filtered collection so the UI matches
the displayed list (keep AccountListView.sortKey as the sorting helper).

---

Outside diff comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`:
- Around line 23-26: The persisted AppStorage key stored in DashPayTabView
(`@AppStorage("dashpay.activeIdentityId") private var storedIdentityId`) is
global across networks; change it to be network-scoped by deriving the key from
the current network identifier (e.g., include network.rawValue or chainId) so
each network has its own stored key. Update DashPayTabView to compute the
AppStorage key at runtime (or use a computed property / wrapper that returns
"dashpay.activeIdentityId.\(networkId)") using the view’s network/environment
value and ensure storedIdentityId is read/written through that network-scoped
key so switching networks preserves separate selections.

---

Duplicate comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift`:
- Around line 169-177: The task block keyed by .task(id:
activeIdentity?.identityId) is not resetting identity-scoped state: clear
optimisticSentIds and ownProfile immediately at the top of that task before
calling loadOwnProfileFromCache() and walletManager.dashPaySyncNow(); and update
loadOwnProfileFromCache() so that on a cache read failure for the new identity
it does not retain the previous ownProfile (set ownProfile to nil or replace
with an empty/default value) instead of keeping the old profile. Ensure the
reset refers to the existing properties optimisticSentIds and ownProfile and the
loadOwnProfileFromCache() function so the UI doesn't show the previous identity
while the new identity's cache is loaded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5cf1916b-35bc-47ca-bb9d-48b3d9493945

📥 Commits

Reviewing files that changed from the base of the PR and between a51606d and a24bb43.

📒 Files selected for processing (4)
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift
💤 Files with no reviewable changes (1)
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift

Comment on lines 35 to +42
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the empty-state checks in sync with the filtered list.

After filtering out account types 12/13 here, the later accounts.isEmpty / !accounts.isEmpty branches still use the unfiltered query. A wallet that only has DashPay friendship accounts will now render a blank list instead of the existing "No Accounts" state. Use orderedAccounts.isEmpty for the visible-account checks, or compute a dedicated visibleAccounts collection and key both branches off that.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift`
around lines 35 - 42, The visible-empty-state logic is still checking the raw
accounts collection instead of the filtered/sorted list, causing the UI to hide
the "No Accounts" state when only accountType 12/13 are present; update the
empty-state checks to use orderedAccounts.isEmpty (or introduce a
visibleAccounts computed collection that filters out accountType 12/13 and reuse
it everywhere) and replace any usages of accounts.isEmpty / !accounts.isEmpty in
AccountListView with checks against that filtered collection so the UI matches
the displayed list (keep AccountListView.sortKey as the sorting helper).

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

All 8 prior findings against 9f770b8 remain STILL VALID at a51606d — verified directly against the worktree (V001 unchanged, record_rejected_contact_request still omits removed_incoming, no reader for rejected_contact_requests, transient identity fetch still permanently breaks channels, purpose_mismatch still sticky, established-contact ingest still skips superseding requests, dashpay_sync cleanup still clobbers cancel token unconditionally, select_recipient_key_index still ignores disabled_at). The M2 delta also introduced one new blocker: Swift wallet deletion does not pre-delete the newly added PersistentDashpayPayment children whose owner inverse is non-optional, mirroring the contact-request pattern that the surrounding comment explicitly calls out as fatal. One FFI suggestion is worth flagging: the new DashpayPaymentFFI derives Copy despite owning two *mut c_char allocations reclaimed by dashpay_payment_array_free. Overflow: 1 valid suggestion dropped (register_external_contact_account persist outside write lock — conf 0.55).

🔴 2 blocking | 🟡 2 suggestion(s)

2 additional finding(s) omitted (not in diff).

6 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: rejected_contact_requests is written but never read — tombstones lost across restart
  Verified at HEAD: managed_identity_from_entry still hard-codes rejected_contact_requests: Default::default() at line 214. The writer at contacts.rs:240 (INSERT INTO rejected_contact_requests) and migration row at V001:203 exist, but there is no load_state reader for the new table and apply_changeset only handles live deltas — restored state is always empty. The recurring DashPay loop's tombstone-skip at network/contact_requests.rs:396-404 never fires after a restart, so a rejected contact request is resurrected on the first sweep. Add a per-wallet load_state for rejected_contact_requests and route its output into the ContactChangeSet.rejected synthesized during rehydration.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:2980-2996: Wallet deletion omits new DashPay payment children before deleting identities — same fatal pattern as contact requests
  Verified: PersistentDashpayPayment.owner is declared as non-optional (`public var owner: PersistentIdentity`), and the new cascade relationship was added on PersistentIdentity.dashpayPayments in the M2 delta. The PHASE 1 pre-delete loop at lines 2986-2996 iterates dpnsNames, dashpayProfile, and contactRequests — but not dashpayPayments. The surrounding comment (lines 2962-2978) explicitly states this phase exists because SwiftData fatals during save() when it must null out a non-optional inverse on a child processed in the same delete batch. dashpayPayments has the exact same shape as contactRequests, so a wallet with persisted DashPay payments can crash or fail to wipe cleanly when deleted. Add a `for payment in Array(identity.dashpayPayments) { backgroundContext.delete(payment) }` loop alongside the existing pre-deletion.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:245-261: select_recipient_key_index returns disabled keys
  Verified at HEAD lines 245-261: the selector iterates recipient_identity.public_keys() and returns the first DECRYPTION (then ENCRYPTION) ECDSA_SECP256K1 key with no disabled_at check. validate_contact_request in crypto/validation.rs does gate on disabled keys, so a recipient with a disabled preferred DECRYPTION key gets returned anyway and the broadcast fails downstream with an opaque error instead of falling through to a usable ENCRYPTION key on the same identity. Beyond reliability, on the send-side this also means the wallet could encrypt the DIP-15 compact xpub to a revoked key whose private half may be compromised. Add `&& k.disabled_at().is_none()` to both branches so selection matches validation.

In `packages/rs-platform-wallet-ffi/src/dashpay_payment.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay_payment.rs:89-108: DashpayPaymentFFI derives Clone, Copy despite owning *mut c_char strings reclaimed by dashpay_payment_array_free
  Verified at HEAD lines 89-108: DashpayPaymentFFI carries two heap-owned C strings (txid, memo — produced via CString::into_raw in cstring_or_null and reclaimed via CString::from_raw in dashpay_payment_array_free), yet the struct is `#[derive(Debug, Clone, Copy)]`. With Copy the compiler will silently shallow-duplicate the struct on any by-value rebinding inside this crate, and a subsequent free walk on the array (or a stray from_raw on the duplicate) would double-free the txid/memo allocations across the FFI boundary. Today's call sites are sound — the struct is built once, moved into Vec → Box<[T]> → Box::into_raw, and reclaimed exactly once — but the Copy derive removes the borrow-checker guardrail that normally prevents this class of bug at refactor time. Sibling FFI types in this crate that own heap pointers (ContactRequestFFI, WalletChangeSetFFI) deliberately omit Copy for exactly this reason. Drop Copy (and Clone if unneeded) on this struct. The cross-boundary contract is unchanged — Swift consumes by raw pointer.

Comment on lines +245 to +261
fn select_recipient_key_index(recipient_identity: &Identity) -> Result<u32, PlatformWalletError> {
// 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
}) {
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
}) {
return Ok(*id);
}
Err(PlatformWalletError::InvalidIdentityData(
"Recipient identity has no ECDSA_SECP256K1 DECRYPTION or ENCRYPTION key".to_string(),
))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: select_recipient_key_index returns disabled keys

Verified at HEAD lines 245-261: the selector iterates recipient_identity.public_keys() and returns the first DECRYPTION (then ENCRYPTION) ECDSA_SECP256K1 key with no disabled_at check. validate_contact_request in crypto/validation.rs does gate on disabled keys, so a recipient with a disabled preferred DECRYPTION key gets returned anyway and the broadcast fails downstream with an opaque error instead of falling through to a usable ENCRYPTION key on the same identity. Beyond reliability, on the send-side this also means the wallet could encrypt the DIP-15 compact xpub to a revoked key whose private half may be compromised. Add && k.disabled_at().is_none() to both branches so selection matches validation.

Suggested change
fn select_recipient_key_index(recipient_identity: &Identity) -> Result<u32, PlatformWalletError> {
// 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
}) {
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
}) {
return Ok(*id);
}
Err(PlatformWalletError::InvalidIdentityData(
"Recipient identity has no ECDSA_SECP256K1 DECRYPTION or ENCRYPTION key".to_string(),
))
}
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);
}
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 usable ECDSA_SECP256K1 DECRYPTION or ENCRYPTION key".to_string(),
))

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — select_recipient_key_index returns disabled keys no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +89 to +108
#[repr(C)]
#[derive(Debug, Clone, Copy)]
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,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*🟡 Suggestion: DashpayPaymentFFI derives Clone, Copy despite owning mut c_char strings reclaimed by dashpay_payment_array_free

Verified at HEAD lines 89-108: DashpayPaymentFFI carries two heap-owned C strings (txid, memo — produced via CString::into_raw in cstring_or_null and reclaimed via CString::from_raw in dashpay_payment_array_free), yet the struct is #[derive(Debug, Clone, Copy)]. With Copy the compiler will silently shallow-duplicate the struct on any by-value rebinding inside this crate, and a subsequent free walk on the array (or a stray from_raw on the duplicate) would double-free the txid/memo allocations across the FFI boundary. Today's call sites are sound — the struct is built once, moved into Vec → Box<[T]> → Box::into_raw, and reclaimed exactly once — but the Copy derive removes the borrow-checker guardrail that normally prevents this class of bug at refactor time. Sibling FFI types in this crate that own heap pointers (ContactRequestFFI, WalletChangeSetFFI) deliberately omit Copy for exactly this reason. Drop Copy (and Clone if unneeded) on this struct. The cross-boundary contract is unchanged — Swift consumes by raw pointer.

Suggested change
#[repr(C)]
#[derive(Debug, Clone, Copy)]
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,
}
#[repr(C)]
#[derive(Debug)]
pub struct DashpayPaymentFFI {
pub counterparty_id: [u8; 32],
pub amount_duffs: u64,
pub direction: DashpayPaymentDirectionFFI,
pub status: DashpayPaymentStatusFFI,
pub txid: *mut c_char,
pub memo: *mut c_char,
}

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — DashpayPaymentFFI derives Clone, Copy despite owning *mut c_char strings reclaimed by dashpay_payment_array_free no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Conflict: identity_handle.rs — both sides appended a test module
(ours: ecdh_key_derivation_tests; upstream: master-derive tests from
the rescan fix). Kept both; 221/221 platform-wallet lib tests green
on the merged tree.

Also folds in a build fix the merged tree needs: upstream
CreateIdentityView's funding-source footer (string concatenation
with an embedded ternary) exceeds the Swift type-checker budget on
Xcode here — hoisted into a static helper, no copy change.
@shumkov shumkov changed the title fix(platform-wallet)!: DashPay sync correctness, consensus entropy fix, and DIP-15 mobile interop (M1) fix(platform-wallet)!: dashpay sync correctness, mobile interop, payments + DashPay tab (M1+M2) Jun 12, 2026
shumkov added 3 commits June 12, 2026 22:07
The explorer-coverage CI guard caught the M2 model addition: every
SwiftData model needs an explorer row + list view + detail view.
Adds the "DashPay Payments" section (network-scoped count, newest
first, full-column read-only detail), mirroring the contact-request
views. check-storage-explorer.sh: 28/28 covered.
…3, M3)

Send side:
- contact requests now carry the DIP-15 masked accountReference
  instead of a hardcoded 0: (version << 28) | (ASK28 ^ account).
  With the contract's unique index (ownerId, toUserId,
  accountReference), the constant 0 meant a superseding request
  after key rotation could never broadcast (duplicate-unique
  rejection) — the version bump is what makes re-keying possible.
- Re-sending to a recipient with a tracked prior request unmasks the
  prior version and bumps it (saturating at the 4-bit max with a
  warning).

Crypto helper fixes (research/06 §3 found both axes wrong):
- HMAC input is now the 69-byte DIP-15 compact xpub (both reference
  clients agree), not the 107-byte DIP-14 encode().
- ASK28 extraction matches iOS dash-shared-core: digest bytes
  [28..32] big-endian >> 4. The reference clients disagree with each
  other here (Android: bytes [0..4] LE) — recipients must disregard
  the field per DIP-15, so the binding consumer is our own
  round-trip; we follow the Rust reference implementation and flag
  the divergence for a DIP clarification.
- New unmask_account_reference recovers (version, account) for the
  sender.

Receive side (DIP-15 "sender rotated their addresses"):
- Sync ingest dedups by (sender, accountReference) instead of bare
  sender id: a known sender with a NEW reference is a rotation
  request and passes the guard (the old guard silently dropped it).
- apply_rotated_incoming_request supersedes the tracked request
  (last-write-wins per pair; simultaneous multi-account rides
  acceptedAccounts later), clears payment_channel_broken — the
  recovery the flag's contract promises — and the sync pass tears
  down the stale external account so the build sweep re-registers
  it from the rotated xpub.

Tests: ASK28 byte-order pin (fails on the old head-of-digest read),
mask/unmask round-trip across version/account ranges, rotation
re-key + broken-flag clear + pending-replace + stranger no-op.
223/223 lib + 9/9 workflow green.
Shared-secret-only callback on the existing host-signer table; the
identity private key never crosses the ABI. EcdhProvider routing
stays internal to platform-wallet so M4's implementation lands
without wallet-API churn. One hook covers both send-side and
decrypt-side ECDH.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Re-verified all 10 prior findings against worktree HEAD aabc21e; every one is STILL VALID — none of the three Swift example-app commits or the v3.1-dev merge touched the dashpay-correctness Rust/Swift hotspots. Carrying forward 7 blockers (append-only V001 violation, rejected-request persistence asymmetry, missing tombstone reader, transient-as-permanent channel breakage, sticky purpose_mismatch, unreachable broken-channel recovery, SwiftData wallet-deletion miss on the new payments cascade) and 3 suggestions (sync stop/start cleanup race, send-side disabled-key selection, Copy on FFI-owned C-strings). REQUEST_CHANGES.

🔴 7 blocking

3 additional finding(s) omitted (not in diff).

3 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs:109-131: record_rejected_contact_request drops incoming in memory but never persists the deletion
  Verified at HEAD lines 109-131: line 116 calls `self.incoming_contact_requests.remove(sender_id)` but the returned `ContactChangeSet` (lines 127-129) only populates `cs.rejected`. The unified contacts-table writer in `rs-platform-wallet-storage/src/sqlite/schema/contacts.rs` only DELETEs incoming rows when `cs.removed_incoming` is non-empty, so the persisted `state='received'` row with its stale `incoming_request` blob stays in SQLite. Once `persister.load()` is wired up, the contacts reader re-buckets that row as an incoming request and `apply_changeset` re-inserts it — the explicitly-rejected request reappears in the UI. The in-memory mutation and the persisted delta are internally inconsistent. Emit a matching `ReceivedContactRequestKey { owner_id, sender_id: *sender_id }` into `cs.removed_incoming` alongside `cs.rejected`.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: rejected_contact_requests tombstones are written but never restored on rehydration
  Verified at HEAD: `managed_identity_from_entry` still hard-codes `rejected_contact_requests: Default::default()` at line 214. The writer at `contacts.rs:240` (`INSERT INTO rejected_contact_requests`) and the migration row at `V001:203` exist, but no `load_state` reader for the new table exists, and `apply_changeset` only handles live deltas — restored state is always empty. The recurring DashPay loop's tombstone-skip at `network/contact_requests.rs:396-404` therefore never fires after a restart, so a rejected contact request is resurrected on the first sweep and surfaces to the user again. Security framing: the on-platform document is immutable, so the local tombstone is the ONLY thing that suppresses a spammer's repeated contact request — a wipe-on-restart defeats the user's explicit reject. Add a per-wallet `load_state` reader for `rejected_contact_requests` and route its output into the `ContactChangeSet.rejected` synthesized during rehydration.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:711-729: Transient DAPI failures inside register_external_contact_account are classified as permanent
  Verified at HEAD lines 711-729: `build_contact_accounts` treats ANY error from `register_external_contact_account` as permanent and calls `mark_contact_channel_broken`. `register_external_contact_account` performs a fresh `Identity::fetch` internally and wraps DAPI/network failures as `PlatformWalletError::InvalidIdentityData`. A single transient DAPI hiccup therefore permanently disables the payment channel; subsequent sweeps skip the contact via the `payment_channel_broken` filter, and recovery only fires if a superseding contactRequest arrives — but the established-contact ingest skip at line 389 makes that path unreachable. Combined, a transient network event bricks a channel forever, and a malicious or unreliable DAPI endpoint becomes a persistent availability attack against payments to a specific contact. Fix by either passing the pre-fetched identity into registration or scoping permanent-broken classification to genuinely non-recoverable failures (decrypt/decode, missing-key, key-type mismatch).
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:383-404: Established-contact ingest skip makes payment_channel_broken recovery unreachable
  Verified at HEAD lines 383-404: the received-side ingest drops every doc whose sender is already in `established_contacts` (line 389) BEFORE consulting `accountReference`. `EstablishedContact::reestablish_preserving_metadata` exists precisely to clear `payment_channel_broken` when a fresh request flows in, and `collect_account_build_candidates` documents the recovery contract ("never retry a permanently-broken channel — wait for a superseding request which clears the flag on re-establish"). But no path reaches `reestablish_preserving_metadata` for an already-established sender from the sync sweep — `add_incoming_contact_request` is only called for new senders here, and the send-side guard at `state/managed_identity/contact_requests.rs:46-48` also returns early for established contacts. Once `payment_channel_broken` is set, it stays set forever. Either detect a new `accountReference` for the same sender and route through the reestablish path, or change the broken-channel policy to permit controlled retry.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:245-261: select_recipient_key_index returns disabled (revoked) keys — DIP-15 compact xpub encrypted to a key whose private half may be compromised
  Verified at HEAD lines 245-261: the selector iterates `recipient_identity.public_keys()` and returns the first DECRYPTION (then ENCRYPTION) ECDSA_SECP256K1 key with no `disabled_at` check. `validate_contact_request` in `crypto/validation.rs` does gate on `disabled_at`, so the asymmetry creates both a reliability bug (an opaque downstream broadcast failure instead of falling through to a usable key) AND a real confidentiality exposure: on send, the wallet encrypts the 69-byte DIP-15 compact xpub (fingerprint‖chaincode‖pubkey — combined with `accountReference` lets the holder derive every receiving address on that account) to a key the recipient has explicitly revoked. Identity-key revocation is the on-platform mechanism for declaring "the private half of this key may be compromised". Add `&& k.disabled_at().is_none()` to both branches so selection matches validation. (Promoted from suggestion to blocking on the security-auditor confidentiality analysis.)

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3018-3035: Wallet deletion PHASE 1 omits PersistentDashpayPayment children — same fatal pattern as contactRequests
  Verified at HEAD: PHASE 1 (lines 3024-3034) iterates `dpnsNames`, `dashpayProfile`, and `contactRequests` but NOT `dashpayPayments`. `PersistentDashpayPayment.owner` is non-optional (`PersistentDashpayPayment.swift:98`), and `PersistentIdentity.dashpayPayments` is the cascading inverse added in this PR. The surrounding comment (lines 3018-3023) explicitly states this phase exists because SwiftData fatals during `save()` when it must null out a non-optional inverse on a child processed in the same batch — exactly the shape of the new payments relationship. A wallet with persisted DashPay payment history will hit the SwiftData fatal at PHASE 2 (`save()` after `delete(identity)`), aborting before the wallet row is removed. The user's belief that they wiped DashPay data is wrong, and plaintext memo + counterparty id + amount + txid rows remain on disk. Add a `for payment in Array(identity.dashpayPayments) { backgroundContext.delete(payment) }` loop alongside the existing pre-deletions.

In `packages/rs-platform-wallet/src/manager/dashpay_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/dashpay_sync.rs:192-235: DashPaySyncManager thread cleanup unconditionally clears the cancel token — stop/start race enables use-after-free across FFI
  Verified at HEAD lines 192-235: the spawned thread's cleanup at lines 230-232 writes `*guard = None` on loop exit regardless of which token the slot currently holds. If `stop()` cancels the old token and `start()` installs a fresh token before the old thread reaches cleanup, the old thread clears the NEW token — `background_cancel` is empty while a fresh sync thread is still running. `stop()`/`quiesce()` then become a no-op against that running thread. In this PR's Swift integration the persister and DashPay event callbacks close over an UnsafePointer<Context> allocated on the Swift side; calling `dashpay_sync_manager_destroy` (or the wallet manager's drop) after the visible token was cleared frees that context while the surviving thread continues invoking the callbacks against the freed pointer — a concrete use-after-free crossing the C ABI, reachable through normal start/stop/destroy controls (toggling tabs, login/logout) and widened by attacker-influenced network timing. Capture the spawned token and only clear the slot if it still holds the same token (`Arc::ptr_eq`), mirroring `ShieldedSyncManager`'s generation guard. (Upgraded from suggestion to blocking based on the security-auditor and codex-security cross-checks of the destroy/UAF path.)

Comment on lines +109 to +131
pub fn record_rejected_contact_request(
&mut self,
sender_id: &Identifier,
account_reference: u32,
document_id: Option<Identifier>,
) -> ContactChangeSet {
let owner_id = self.id();
self.incoming_contact_requests.remove(sender_id);

let tombstone = RejectedContactRequest {
owner_id,
sender_id: *sender_id,
account_reference,
document_id,
};
self.rejected_contact_requests
.insert((*sender_id, account_reference), tombstone.clone());

let mut cs = ContactChangeSet::default();
cs.rejected
.insert((owner_id, *sender_id, account_reference), tombstone);
cs
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: record_rejected_contact_request drops incoming in memory but never persists the deletion

Verified at HEAD lines 109-131: line 116 calls self.incoming_contact_requests.remove(sender_id) but the returned ContactChangeSet (lines 127-129) only populates cs.rejected. The unified contacts-table writer in rs-platform-wallet-storage/src/sqlite/schema/contacts.rs only DELETEs incoming rows when cs.removed_incoming is non-empty, so the persisted state='received' row with its stale incoming_request blob stays in SQLite. Once persister.load() is wired up, the contacts reader re-buckets that row as an incoming request and apply_changeset re-inserts it — the explicitly-rejected request reappears in the UI. The in-memory mutation and the persisted delta are internally inconsistent. Emit a matching ReceivedContactRequestKey { owner_id, sender_id: *sender_id } into cs.removed_incoming alongside cs.rejected.

Suggested change
pub fn record_rejected_contact_request(
&mut self,
sender_id: &Identifier,
account_reference: u32,
document_id: Option<Identifier>,
) -> ContactChangeSet {
let owner_id = self.id();
self.incoming_contact_requests.remove(sender_id);
let tombstone = RejectedContactRequest {
owner_id,
sender_id: *sender_id,
account_reference,
document_id,
};
self.rejected_contact_requests
.insert((*sender_id, account_reference), tombstone.clone());
let mut cs = ContactChangeSet::default();
cs.rejected
.insert((owner_id, *sender_id, account_reference), tombstone);
cs
}
let owner_id = self.id();
self.incoming_contact_requests.remove(sender_id);
let tombstone = RejectedContactRequest {
owner_id,
sender_id: *sender_id,
account_reference,
document_id,
};
self.rejected_contact_requests
.insert((*sender_id, account_reference), tombstone.clone());
let mut cs = ContactChangeSet::default();
cs.removed_incoming.insert(ReceivedContactRequestKey {
owner_id,
sender_id: *sender_id,
});
cs.rejected
.insert((owner_id, *sender_id, account_reference), tombstone);
cs

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 440ffcarecord_rejected_contact_request drops incoming in memory but never persists the deletion no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 440ffcarecord_rejected_contact_request drops incoming in memory but never persists the deletion no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +711 to +729
if let Err(e) = self
.register_external_contact_account(
identity_id,
&contact_id,
&candidate.encrypted_public_key,
candidate.our_decryption_key_index,
candidate.contact_encryption_key_index,
)
.await
{
tracing::warn!(
identity = %identity_id,
contact = %contact_id,
error = %e,
"Failed to register DashPay external account; marking payment channel broken (permanent)"
);
self.mark_contact_channel_broken(identity_id, &contact_id)
.await;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Transient DAPI failures inside register_external_contact_account are classified as permanent

Verified at HEAD lines 711-729: build_contact_accounts treats ANY error from register_external_contact_account as permanent and calls mark_contact_channel_broken. register_external_contact_account performs a fresh Identity::fetch internally and wraps DAPI/network failures as PlatformWalletError::InvalidIdentityData. A single transient DAPI hiccup therefore permanently disables the payment channel; subsequent sweeps skip the contact via the payment_channel_broken filter, and recovery only fires if a superseding contactRequest arrives — but the established-contact ingest skip at line 389 makes that path unreachable. Combined, a transient network event bricks a channel forever, and a malicious or unreliable DAPI endpoint becomes a persistent availability attack against payments to a specific contact. Fix by either passing the pre-fetched identity into registration or scoping permanent-broken classification to genuinely non-recoverable failures (decrypt/decode, missing-key, key-type mismatch).

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 440ffcaTransient DAPI failures inside register_external_contact_account are classified as permanent no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +383 to 404
// G1a: do NOT skip just because the sender is in
// `sent_contact_requests` — that is the reciprocal we
// need to let through to auto-establish. Skip only when
// already tracked as incoming or established (true
// dedup), or suppressed by a rejected-request tombstone.
if managed.incoming_contact_requests.contains_key(&sender_id)
|| managed.established_contacts.contains_key(&sender_id)
{
continue;
}
};
let account_reference = match props
.get("accountReference")
.and_then(|v: &Value| v.to_integer::<u32>().ok())
{
Some(v) => v,
None => {
tracing::warn!(
// G5 stage 1: a rejected request (same sender +
// accountReference) must not be resurrected. A rotated
// request (bumped accountReference) is NOT suppressed.
if managed.is_request_rejected(&sender_id, contact_request.account_reference) {
tracing::debug!(
sender = %sender_id,
recipient = %identity_id,
"Skipping contact request document: missing accountReference"
account_reference = contact_request.account_reference,
"Skipping rejected contact request (tombstoned); doc {doc_id}"
);
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Established-contact ingest skip makes payment_channel_broken recovery unreachable

Verified at HEAD lines 383-404: the received-side ingest drops every doc whose sender is already in established_contacts (line 389) BEFORE consulting accountReference. EstablishedContact::reestablish_preserving_metadata exists precisely to clear payment_channel_broken when a fresh request flows in, and collect_account_build_candidates documents the recovery contract ("never retry a permanently-broken channel — wait for a superseding request which clears the flag on re-establish"). But no path reaches reestablish_preserving_metadata for an already-established sender from the sync sweep — add_incoming_contact_request is only called for new senders here, and the send-side guard at state/managed_identity/contact_requests.rs:46-48 also returns early for established contacts. Once payment_channel_broken is set, it stays set forever. Either detect a new accountReference for the same sender and route through the reestablish path, or change the broken-channel policy to permit controlled retry.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 440ffcaEstablished-contact ingest skip makes payment_channel_broken recovery unreachable no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +192 to +235
pub fn start(self: Arc<Self>) {
let mut guard = self.background_cancel.lock().expect("bg_cancel poisoned");
if guard.is_some() {
return;
}
let cancel = CancellationToken::new();
*guard = Some(cancel.clone());
drop(guard);

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,
}
}

if let Ok(mut guard) = this.background_cancel.lock() {
*guard = None;
}
});
})
.expect("failed to spawn dashpay-sync thread");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: DashPaySyncManager thread cleanup unconditionally clears the cancel token — stop/start race enables use-after-free across FFI

Verified at HEAD lines 192-235: the spawned thread's cleanup at lines 230-232 writes *guard = None on loop exit regardless of which token the slot currently holds. If stop() cancels the old token and start() installs a fresh token before the old thread reaches cleanup, the old thread clears the NEW token — background_cancel is empty while a fresh sync thread is still running. stop()/quiesce() then become a no-op against that running thread. In this PR's Swift integration the persister and DashPay event callbacks close over an UnsafePointer allocated on the Swift side; calling dashpay_sync_manager_destroy (or the wallet manager's drop) after the visible token was cleared frees that context while the surviving thread continues invoking the callbacks against the freed pointer — a concrete use-after-free crossing the C ABI, reachable through normal start/stop/destroy controls (toggling tabs, login/logout) and widened by attacker-influenced network timing. Capture the spawned token and only clear the slot if it still holds the same token (Arc::ptr_eq), mirroring ShieldedSyncManager's generation guard. (Upgraded from suggestion to blocking based on the security-auditor and codex-security cross-checks of the destroy/UAF path.)

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — DashPaySyncManager thread cleanup unconditionally clears the cancel token — stop/start race enables use-after-free across FFI no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

shumkov added 2 commits June 12, 2026 22:34
…earch

Decisive: no reference client (DashSync-iOS, dashj, dash-shared-core)
ever implemented contactInfo — our implementation sets the de-facto
convention. Adopts: DIP-15 child derivation (root/65536'+65537'/idx'),
AES-256-ECB encToUserId, IV-prepended AES-256-CBC privateData, CBOR
array [aliasName, note, displayHidden] per the deployed schema (which
contradicts DIP-15 prose — table included), ≥2-contacts publish gate.
… part 1)

The crypto core for DashPay contactInfo documents, following the
conventions recorded in docs/dashpay/research/07 (no reference client
ever implemented this doc type — this sets the de-facto wire format):

- platform-encryption: AES-256-ECB encrypt/decrypt for the 32-byte
  encToUserId (two raw blocks, no IV/padding — DIP-15's own ECB
  soundness argument: the plaintext is a SHA-256 output and the key
  is single-purpose), plus IV-prepended AES-256-CBC helpers for
  privateData. Tests pin the ECB property (identical blocks encrypt
  identically) so a CBC-with-zero-IV regression can't slip in.

- platform-wallet crypto/contact_info.rs: DIP-15 key derivation
  (rootEncryptionKey / 65536' / index' for encToUserId,
  / 65537' / index' for privateData — hardened children of the
  identity's registered ENCRYPTION key path), CBOR codec for the
  deployed schema's array shape [aliasName, note, displayHidden]
  with a 4th ignored padding element lifting tiny payloads to the
  schema's 48-byte ciphertext floor.

Tests: key-derivation determinism + domain separation, CBOR
round-trip incl. all-absent payload, full derive→encrypt→decrypt
round-trip with schema bounds check.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Reconciliation against HEAD 440ffca: prior finding #6 (established-contact ingest skip) is FIXED by the new rotation path. Nine prior findings remain STILL VALID and the new G3 delta introduces two additional blockers — the send-side rotation-version lookup ignores established_contacts (forcing version=0 collisions after auto-establishment) and the receive-side rotation handler replays immutable historical requests as fresh rotations on every sweep, churning the external account. Total: 10 in-scope blockers. Overflow: 1 suggestion (DashpayPaymentFFI Copy) dropped due to budget.

🔴 5 blocking

2 additional finding(s) omitted (not in diff).

5 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:165-201: Send-side rotation version lookup ignores established_contacts — re-send after auto-establishment reuses version=0 and collides on the unique index
  The new G3 rotation logic computes `previous_version` only from `managed.sent_contact_requests.get(recipient_identity_id)` (line 171). But establishment (`add_incoming_contact_request` line 175 and `apply_established_contact` line 372 in state/managed_identity/contact_requests.rs) explicitly removes the entry from `sent_contact_requests` and parks the prior outgoing request on `EstablishedContact.outgoing_request`. Once the reciprocal arrives and a sweep auto-establishes the pair, the next `send_contact_request` to that recipient sees `previous_version = None` and falls back to `version = 0`. With deterministic xpub/ECDH for the same (sender, recipient) and unchanged `account_index`, the PRF reproduces the same masked `account_reference` as the original sent request. The contract's unique index `($ownerId, toUserId, accountReference)` rejects the broadcast — the exact failure mode G3 was added to prevent. Fall back to `established_contacts[recipient].outgoing_request` (taking the max of both versions if both are present).
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:441-478: Historical contactRequest documents replay as fresh rotations every sync sweep
  The rotation guard at line 451 only compares the incoming reference against the currently tracked reference (incoming map or established contact). contactRequest documents are immutable, and `fetch_received_contact_requests(identity_id, None)` (line 370) is unfiltered, so every sweep returns both the original v=0 and any rotated v=N documents. Within a single sweep, ingesting v=0 against an already-tracked v=N flips the established contact back to v=0 and queues a teardown (lines 472-478, then 517-528), and the next document in the same iteration flips it forward again. Across sweeps the same churn replays — the external account is torn down and rebuilt on every cycle, generating wasted DAPI traffic. Worse, if the freshest document falls outside the eventual paginated window (post-M3 growth), the contact can regress to the stale xpub. Compare by `created_at`/version monotonicity, not bare reference inequality: only apply rotation when the incoming request strictly supersedes the tracked one.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:292-308: select_recipient_key_index returns disabled (revoked) keys — DIP-15 compact xpub encrypted to a key whose private half may be compromised
  Verified at HEAD lines 292-308: the selector iterates `recipient_identity.public_keys()` and returns the first DECRYPTION (then ENCRYPTION) ECDSA_SECP256K1 key with no `disabled_at` check. `validate_contact_request` does gate on `disabled_at`, so the send/receive interop rules are asymmetric. On send, the 69-byte DIP-15 compact xpub (fingerprint‖chaincode‖pubkey — combined with `accountReference` lets the holder derive every receiving address on that account) is encrypted to a key the recipient has explicitly revoked. Identity-key revocation is the on-platform mechanism for declaring 'this key's private half may be compromised'. Add `&& k.disabled_at().is_none()` to both branches so selection matches validation.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: rejected_contact_requests tombstones are written but never restored on rehydration
  `managed_identity_from_entry` still hard-codes `rejected_contact_requests: Default::default()` at line 214. The writer at `sqlite/schema/contacts.rs:240` (INSERT INTO rejected_contact_requests) and the V001 row exist, but there is no `load_state` reader for the new table, and `apply_changeset` only handles live deltas — restored state is always empty after restart. The recurring DashPay loop's tombstone-skip at `network/contact_requests.rs:457` therefore never fires after restart, so a rejected request is resurrected on the first sweep. Because the on-platform document is immutable, wiping the tombstone on restart defeats the user's explicit reject. Add a per-wallet `load_state` reader for `rejected_contact_requests` and route its output into the rehydration changeset / ManagedIdentity field.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3024-3034: Wallet deletion PHASE 1 omits PersistentDashpayPayment children — same fatal pattern as contactRequests
  PHASE 1 iterates `dpnsNames`, `dashpayProfile`, and `contactRequests` but NOT `dashpayPayments`. `PersistentDashpayPayment.owner` is non-optional and `PersistentIdentity.dashpayPayments` is the cascading inverse added in this PR. The surrounding comment (lines 3008-3023) explicitly documents that this phase exists because SwiftData fatals during `save()` when it must null out a non-optional inverse on a child processed in the same batch — exactly the shape of the new payments relationship. A wallet with persisted DashPay payment history will hit the SwiftData fatal at PHASE 2 `save()`, aborting before the wallet row is removed. The user believes their data was wiped; plaintext memo + counterparty id + amount + txid rows remain on disk. Pre-delete `identity.dashpayPayments` alongside the other children.

Comment on lines +165 to +201
let account_reference = {
let secret = ecdh_private_key.secret_bytes();
let previous_version = {
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))
.and_then(|managed| managed.sent_contact_requests.get(recipient_identity_id))
.map(|prior| {
crate::wallet::identity::crypto::dip14::unmask_account_reference(
prior.account_reference,
&secret,
&xpub_bytes,
)
.0
})
};
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,
};
crate::wallet::identity::crypto::dip14::calculate_account_reference(
&secret,
&xpub_bytes,
account_index,
version,
)
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Send-side rotation version lookup ignores established_contacts — re-send after auto-establishment reuses version=0 and collides on the unique index

The new G3 rotation logic computes previous_version only from managed.sent_contact_requests.get(recipient_identity_id) (line 171). But establishment (add_incoming_contact_request line 175 and apply_established_contact line 372 in state/managed_identity/contact_requests.rs) explicitly removes the entry from sent_contact_requests and parks the prior outgoing request on EstablishedContact.outgoing_request. Once the reciprocal arrives and a sweep auto-establishes the pair, the next send_contact_request to that recipient sees previous_version = None and falls back to version = 0. With deterministic xpub/ECDH for the same (sender, recipient) and unchanged account_index, the PRF reproduces the same masked account_reference as the original sent request. The contract's unique index ($ownerId, toUserId, accountReference) rejects the broadcast — the exact failure mode G3 was added to prevent. Fall back to established_contacts[recipient].outgoing_request (taking the max of both versions if both are present).

Suggested change
let account_reference = {
let secret = ecdh_private_key.secret_bytes();
let previous_version = {
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))
.and_then(|managed| managed.sent_contact_requests.get(recipient_identity_id))
.map(|prior| {
crate::wallet::identity::crypto::dip14::unmask_account_reference(
prior.account_reference,
&secret,
&xpub_bytes,
)
.0
})
};
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,
};
crate::wallet::identity::crypto::dip14::calculate_account_reference(
&secret,
&xpub_bytes,
account_index,
version,
)
};
let account_reference = {
let secret = ecdh_private_key.secret_bytes();
let previous_version = {
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))
.and_then(|managed| {
managed.sent_contact_requests.get(recipient_identity_id).or_else(|| {
managed
.established_contacts
.get(recipient_identity_id)
.map(|contact| &contact.outgoing_request)
})
})
.map(|prior| {
crate::wallet::identity::crypto::dip14::unmask_account_reference(
prior.account_reference,
&secret,
&xpub_bytes,
)
.0
})
};

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Send-side rotation version lookup ignores established_contacts — re-send after auto-establishment reuses version=0 and collides on the unique index no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +441 to 478
let tracked_reference = managed
.incoming_contact_requests
.get(&sender_id)
.map(|r| r.account_reference)
.or_else(|| {
managed
.established_contacts
.get(&sender_id)
.map(|c| c.incoming_request.account_reference)
});
if tracked_reference == Some(contact_request.account_reference) {
continue;
}
};
let account_reference = match props
.get("accountReference")
.and_then(|v: &Value| v.to_integer::<u32>().ok())
{
Some(v) => v,
None => {
tracing::warn!(
// G5 stage 1: a rejected request (same sender +
// accountReference) must not be resurrected. A rotated
// request (bumped accountReference) is NOT suppressed.
if managed.is_request_rejected(&sender_id, contact_request.account_reference) {
tracing::debug!(
sender = %sender_id,
recipient = %identity_id,
"Skipping contact request document: missing accountReference"
account_reference = contact_request.account_reference,
"Skipping rejected contact request (tombstoned); doc {doc_id}"
);
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 tracked_reference.is_some() {
// Rotation: supersede the tracked request. When an
// established contact was re-keyed, queue the stale
// external account for teardown so the build sweep
// below re-registers it from the new xpub.
if managed
.apply_rotated_incoming_request(contact_request.clone(), &self.persister)
{
rotated_contacts.push(sender_id);
}
all_requests.push(contact_request);
continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Historical contactRequest documents replay as fresh rotations every sync sweep

The rotation guard at line 451 only compares the incoming reference against the currently tracked reference (incoming map or established contact). contactRequest documents are immutable, and fetch_received_contact_requests(identity_id, None) (line 370) is unfiltered, so every sweep returns both the original v=0 and any rotated v=N documents. Within a single sweep, ingesting v=0 against an already-tracked v=N flips the established contact back to v=0 and queues a teardown (lines 472-478, then 517-528), and the next document in the same iteration flips it forward again. Across sweeps the same churn replays — the external account is torn down and rebuilt on every cycle, generating wasted DAPI traffic. Worse, if the freshest document falls outside the eventual paginated window (post-M3 growth), the contact can regress to the stale xpub. Compare by created_at/version monotonicity, not bare reference inequality: only apply rotation when the incoming request strictly supersedes the tracked one.

source: ['codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Historical contactRequest documents replay as fresh rotations every sync sweep no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +292 to +308
fn select_recipient_key_index(recipient_identity: &Identity) -> Result<u32, PlatformWalletError> {
// 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
}) {
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
}) {
return Ok(*id);
}
Err(PlatformWalletError::InvalidIdentityData(
"Recipient identity has no ECDSA_SECP256K1 DECRYPTION or ENCRYPTION key".to_string(),
))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: select_recipient_key_index returns disabled (revoked) keys — DIP-15 compact xpub encrypted to a key whose private half may be compromised

Verified at HEAD lines 292-308: the selector iterates recipient_identity.public_keys() and returns the first DECRYPTION (then ENCRYPTION) ECDSA_SECP256K1 key with no disabled_at check. validate_contact_request does gate on disabled_at, so the send/receive interop rules are asymmetric. On send, the 69-byte DIP-15 compact xpub (fingerprint‖chaincode‖pubkey — combined with accountReference lets the holder derive every receiving address on that account) is encrypted to a key the recipient has explicitly revoked. Identity-key revocation is the on-platform mechanism for declaring 'this key's private half may be compromised'. Add && k.disabled_at().is_none() to both branches so selection matches validation.

Suggested change
fn select_recipient_key_index(recipient_identity: &Identity) -> Result<u32, PlatformWalletError> {
// 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
}) {
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
}) {
return Ok(*id);
}
Err(PlatformWalletError::InvalidIdentityData(
"Recipient identity has no ECDSA_SECP256K1 DECRYPTION or ENCRYPTION key".to_string(),
))
}
fn select_recipient_key_index(recipient_identity: &Identity) -> Result<u32, PlatformWalletError> {
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);
}
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 usable ECDSA_SECP256K1 DECRYPTION or ENCRYPTION key".to_string(),
))
}

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 440ffcaselect_recipient_key_index returns disabled (revoked) keys — DIP-15 compact xpub encrypted to a key whose private half may be compromised no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Network layer over the part-1 crypto core:

- fetch_decrypted_contact_infos: query the owner's contactInfo docs
  (with the load-bearing ORDER BY $updatedAt — drive proves absence
  for bare secondary-index equality, same trap as the contact-request
  queries), derive each doc's keys from its own
  rootEncryptionKeyIndex/derivationEncryptionKeyIndex, decrypt
  encToUserId to find which contact it belongs to. The contact↔doc
  mapping is deliberately stateless — restore-from-seed recovers
  alias/note/hidden entirely from chain.
- sync_contact_infos: step 3 of the recurring dashpay_sync pass;
  applies decrypted metadata onto established contacts through the
  new ManagedIdentity::set_contact_metadata (no-op when unchanged so
  recurring passes don't spam the persister).
- set_contact_info_with_external_signer: local state first (works
  offline), then publish create-or-update through the put_document
  seam. Enforces DIP-15's privacy rule: with <2 established contacts
  the network write is deferred (logged), local state still lands.
  Fresh docs take the next sequential derivation index; updates
  reuse the existing doc's index + bump its revision.
- FFI: platform_wallet_set_dashpay_contact_info_with_signer (same
  vtable-signer shape as the profile write).

Follow-ups (part 3): persist alias/note/hidden across the FFI
persister into SwiftData (ContactRequestFFI + Swift model columns),
switch ContactDetailView off the UserDefaults meta store, and
seam-level tests for the sync/publish paths via the recording
SdkWriter. 226/226 lib tests green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs (1)

109-130: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Also emit removed_incoming with the rejection tombstone.

Line 116 removes the incoming request from in-memory state, but the returned ContactChangeSet only carries rejected. The reject path in packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs persists this delta as-is, so restore/replay has no persisted signal to delete the old incoming request and can resurrect the rejected request after reload. Add the matching ReceivedContactRequestKey to cs.removed_incoming when the map entry is removed.

Proposed fix
     pub fn record_rejected_contact_request(
         &mut self,
         sender_id: &Identifier,
         account_reference: u32,
         document_id: Option<Identifier>,
     ) -> ContactChangeSet {
         let owner_id = self.id();
-        self.incoming_contact_requests.remove(sender_id);
+        let removed_incoming = self.incoming_contact_requests.remove(sender_id).is_some();
 
         let tombstone = RejectedContactRequest {
             owner_id,
             sender_id: *sender_id,
             account_reference,
             document_id,
         };
         self.rejected_contact_requests
             .insert((*sender_id, account_reference), tombstone.clone());
 
         let mut cs = ContactChangeSet::default();
+        if removed_incoming {
+            cs.removed_incoming.insert(ReceivedContactRequestKey {
+                owner_id,
+                sender_id: *sender_id,
+            });
+        }
         cs.rejected
             .insert((owner_id, *sender_id, account_reference), tombstone);
         cs
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`
around lines 109 - 130, In record_rejected_contact_request, after removing the
entry from incoming_contact_requests you must also record that removal in the
returned ContactChangeSet so the delta persists deletion; build a
ReceivedContactRequestKey (owner_id, *sender_id, account_reference) and insert
it into cs.removed_incoming before returning. This ensures the rejection
tombstone is added to cs.rejected and the corresponding incoming entry is
emitted via cs.removed_incoming for proper replay; locate
record_rejected_contact_request, incoming_contact_requests, ContactChangeSet and
Removed/removed_incoming to implement the insertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/dashpay/research/07-contactinfo-conventions.md`:
- Around line 25-28: The fenced code block showing the derivation paths lacks a
language tag; update the triple-backtick fence surrounding the lines starting
with "encToUserId key:" and "privateData key:" to include a plain text specifier
(e.g., ```text or ```plaintext) so the block is lint-compliant and renders
correctly.

In `@packages/rs-platform-wallet-ffi/src/contact_info.rs`:
- Around line 51-56: The code currently converts signer_handle to usize and back
to a pointer which can lose pointer provenance; instead capture a typed pointer
to VTableSigner before the worker hop and then unsafely dereference it inside
the closure. Concretely, in the block that calls
PLATFORM_WALLET_STORAGE.with_item and block_on_worker, replace the
signer_addr/usize roundtrip with a typed pointer (e.g., signer_ptr of type
*const VTableSigner derived from signer_handle) and inside the async closure
obtain the signer with unsafe { &*signer_ptr } when creating the &VTableSigner
used by the worker; leave block_on_worker and wallet access unchanged. Ensure
the symbol names referenced are signer_handle, signer_ptr, VTableSigner,
PLATFORM_WALLET_STORAGE.with_item, and block_on_worker.

---

Duplicate comments:
In
`@packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- Around line 109-130: In record_rejected_contact_request, after removing the
entry from incoming_contact_requests you must also record that removal in the
returned ContactChangeSet so the delta persists deletion; build a
ReceivedContactRequestKey (owner_id, *sender_id, account_reference) and insert
it into cs.removed_incoming before returning. This ensures the rejection
tombstone is added to cs.rejected and the corresponding incoming entry is
emitted via cs.removed_incoming for proper replay; locate
record_rejected_contact_request, incoming_contact_requests, ContactChangeSet and
Removed/removed_incoming to implement the insertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b106d317-7b25-48d6-884b-90c7c13f2b57

📥 Commits

Reviewing files that changed from the base of the PR and between aabc21e and 3f707c2.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • docs/dashpay/SPEC.md
  • docs/dashpay/research/07-contactinfo-conventions.md
  • packages/rs-platform-encryption/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/contact_info.rs
  • packages/rs-platform-wallet-ffi/src/dashpay_profile.rs
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet/Cargo.toml
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs
  • packages/rs-platform-wallet/tests/contact_workflow_tests.rs
✅ Files skipped from review due to trivial changes (2)
  • packages/rs-platform-wallet/Cargo.toml
  • docs/dashpay/SPEC.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs

Comment on lines +25 to +28
```
encToUserId key: rootEncryptionKey / 65536' / derivationEncryptionKeyIndex' (2^16)
privateData key: rootEncryptionKey / 65537' / derivationEncryptionKeyIndex' (2^16 + 1)
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language specifier to the fenced code block.

The code block showing the derivation paths lacks a language identifier. Adding text or plaintext would satisfy linting and improve rendering.

-```
+```text
 encToUserId key:  rootEncryptionKey / 65536' / derivationEncryptionKeyIndex'   (2^16)
 privateData key:  rootEncryptionKey / 65537' / derivationEncryptionKeyIndex'   (2^16 + 1)

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>

[warning] 25-25: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/dashpay/research/07-contactinfo-conventions.md around lines 25 - 28,
The fenced code block showing the derivation paths lacks a language tag; update
the triple-backtick fence surrounding the lines starting with "encToUserId key:"
and "privateData key:" to include a plain text specifier (e.g., ```text or

Source: Linters/SAST tools

Comment on lines +51 to +56
let signer_addr = signer_handle as usize;

let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| {
let identity_wallet = wallet.identity().clone();
block_on_worker(async move {
let signer: &VTableSigner = &*(signer_addr as *const VTableSigner);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Signer type definitions and thread-safety markers =="
fd -t f . packages/rs-sdk-ffi/src | xargs rg -n -C3 'SignerHandle|VTableSigner|unsafe impl .*Send|unsafe impl .*Sync|impl .*Send|impl .*Sync'

echo
echo "== FFI call sites that move signer pointers into async workers =="
rg -n -C3 'signer_addr as usize|as \*const VTableSigner|block_on_worker' packages/rs-platform-wallet-ffi/src

Repository: dashpay/platform

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate VTableSigner / SignerHandle definitions =="
fd -t f "signer*" packages/rs-sdk-ffi/src | head -n 50
echo "---"
rg -n "struct VTableSigner|VTableSigner::|impl .*VTableSigner|type VTableSigner" packages/rs-sdk-ffi/src | head -n 200
echo "---"
rg -n "SignerHandle" packages/rs-sdk-ffi/src | head -n 200

echo
echo "== Check for explicit Send/Sync impls for signer-related types =="
rg -n --hidden "unsafe impl .*Send|unsafe impl .*Sync|impl .*Send|impl .*Sync" packages/rs-sdk-ffi/src/sign* packages/rs-sdk-ffi/src/*sign* 2>/dev/null || true
rg -n --hidden "Send.*VTableSigner|Sync.*VTableSigner|Send.*SignerHandle|Sync.*SignerHandle" packages/rs-sdk-ffi/src || true

echo
echo "== Inspect contact_info.rs around lines 40-90 =="
sed -n '35,95p' packages/rs-platform-wallet-ffi/src/contact_info.rs

echo
echo "== Find block_on_worker usage that touches signer_addr / VTableSigner in wallet-ffi =="
rg -n --context 2 "signer_addr as usize|as \\*const VTableSigner|block_on_worker" packages/rs-platform-wallet-ffi/src/contact_info.rs

echo
echo "== Look for same pointer pattern in dashpay_profile.rs =="
rg -n --context 3 "signer_addr as usize|VTableSigner|\\*const VTableSigner|block_on_worker" packages/rs-platform-wallet-ffi/src/dashpay_profile.rs

Repository: dashpay/platform

Length of output: 30571


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Find block_on_worker definition =="
fd -t f "block_on_worker" packages/rs-platform-wallet-ffi/src | head -n 50
rg -n "fn block_on_worker|block_on_worker<|block_on_worker\(" packages/rs-platform-wallet-ffi/src | head -n 50

echo
echo "== Show runtime module around block_on_worker =="
fd -t f "runtime" packages/rs-platform-wallet-ffi/src | head -n 50
# print likely file(s)
fd -t f "runtime" packages/rs-platform-wallet-ffi/src | xargs -I{} bash -lc 'echo "---- {}"; rg -n "block_on_worker" "{}" || true' 2>/dev/null || true
# fallback: just cat small likely candidates
for f in $(rg -l "block_on_worker" packages/rs-platform-wallet-ffi/src | head -n 20); do
  echo "---- $f"
  sed -n '1,200p' "$f"
done

Repository: dashpay/platform

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== runtime.rs: block_on_worker implementation =="
sed -n '1,140p' packages/rs-platform-wallet-ffi/src/runtime.rs

echo
echo "== contact_info.rs: whole function (context) =="
sed -n '1,140p' packages/rs-platform-wallet-ffi/src/contact_info.rs

Repository: dashpay/platform

Length of output: 5215


Fix signer pointer provenance loss in contact_info worker hop

rs_sdk_ffi::signer::VTableSigner is explicitly unsafe impl Send + Sync, and block_on_worker spawns the async work onto a Tokio worker but waits for it to finish, so the signer being used off-thread is the intended/allowed model. The real problem in lines 51-56 is the signer_handle as usizeas *const VTableSigner roundtrip, which can drop pointer provenance; capture a typed pointer instead (e.g., let signer_ptr = signer_handle as *const VTableSigner; then let signer = unsafe { &*signer_ptr };).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-ffi/src/contact_info.rs` around lines 51 - 56,
The code currently converts signer_handle to usize and back to a pointer which
can lose pointer provenance; instead capture a typed pointer to VTableSigner
before the worker hop and then unsafely dereference it inside the closure.
Concretely, in the block that calls PLATFORM_WALLET_STORAGE.with_item and
block_on_worker, replace the signer_addr/usize roundtrip with a typed pointer
(e.g., signer_ptr of type *const VTableSigner derived from signer_handle) and
inside the async closure obtain the signer with unsafe { &*signer_ptr } when
creating the &VTableSigner used by the worker; leave block_on_worker and wallet
access unchanged. Ensure the symbol names referenced are signer_handle,
signer_ptr, VTableSigner, PLATFORM_WALLET_STORAGE.with_item, and
block_on_worker.

…part 3)

Pipeline: ContactRequestFFI gains alias/note/is_hidden (established
rows only, replicated onto both directions like the broken flag;
CString lifecycle owned by free_contact_requests_ffi; layout asserts
updated 152→176) → Swift persistence handler copies them onto three
new additive PersistentDashpayContactRequest columns (lightweight
migration) → ContactDetailView + ContactsView read them reactively
off the @query rows and write through the new
ManagedPlatformWallet.setDashPayContactInfo (KeychainSigner, same
vtable shape as the profile write). "This device only" labels
replaced; the UserDefaults meta store now only keeps the add-time
DPNS hint.

Verified on-sim: alias save → FFI → Rust set_contact_metadata →
persister → both SwiftData rows carry the alias; the DIP-15 privacy
gate correctly deferred the document publish at 1 established
contact ("publish deferred ... local state updated").

KNOWN GAP (fix follows): in the deferred-publish window the metadata
does NOT survive an app relaunch — contacts are not restored from
local persistence at load (they re-derive from chain via the sync
sweep), so the re-establish round writes alias=None back over the
rows. Once the contactInfo doc publishes (≥2 contacts), relaunch
durability comes from chain. Next commit restores contacts (incl.
metadata) from SwiftData at load, which also makes contacts visible
on offline launches.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

All 10 prior findings against 440ffca are STILL VALID at HEAD 3f707c2 — verified directly in the worktree. The new contactInfo delta (commits d78bf31 + 3f707c2) does not touch any of the prior hotspots, so the prior blockers carry forward unchanged. Two additional issues surfaced in the new contactInfo code (pagination cap of 100 docs driving both sync and derivation_index allocation; ENCRYPTION key selection without disabled_at check on the owner's own keys at contact_info.rs:313-320). The pagination issue is folded into the budget as it overlaps with derivation-index correctness; the contact_info disabled_at gap is folded into finding #10 since it is the same root-cause pattern. Budget filled with 10 blockers; 2 suggestion-level findings (DashpayPaymentFFI Copy/Clone, contactInfo pagination cap) dropped due to budget but recorded in out_of_scope_findings.

🔴 2 blocking

2 additional finding(s) omitted (not in diff).

8 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs:206-222: rejected_contact_requests tombstones are written but never restored on rehydration
  Verified at HEAD: `managed_identity_from_entry` hard-codes `rejected_contact_requests: Default::default()` at line 214. The writer (INSERT INTO rejected_contact_requests) and the V001 row exist, but no `load_state` reader for the new table exists, and `apply_changeset` only handles live deltas — restored state is always empty after restart. The recurring DashPay loop's tombstone check at `network/contact_requests.rs:457` therefore never fires after a restart, so a rejected request is resurrected on the first sweep. Add a per-wallet `load_state` reader for `rejected_contact_requests` and route its output into the rehydrated `ManagedIdentity.rejected_contact_requests`.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3024-3034: Wallet deletion PHASE 1 omits PersistentDashpayPayment children — same fatal pattern as contactRequests
  Verified at HEAD lines 3024-3034: PHASE 1 iterates `dpnsNames`, `dashpayProfile`, and `contactRequests` but NOT `dashpayPayments`. `PersistentDashpayPayment.owner` is non-optional and `PersistentIdentity.dashpayPayments` is the cascading inverse added in this PR. The surrounding comment (lines 3005-3023) explicitly documents that this phase exists because SwiftData fatals during `save()` when it must null out a non-optional inverse on a child processed in the same batch — exactly the shape of the new payments relationship. A wallet with persisted DashPay payment history will hit the SwiftData fatal at PHASE 2 `save()`, aborting before the wallet row is removed. The user believes their data was wiped; plaintext memo + counterparty id + amount + txid rows remain on disk — a confidentiality regression for users handing off or disposing devices.

shumkov added 2 commits June 12, 2026 23:23
…ad (M3, part 4)

Closes the part-3 relaunch-durability gap: contacts were never
restored from local persistence — they re-derived from chain on the
first sync sweep, so (a) the Contacts UI was empty on offline
launches and (b) the re-establish round emitted alias=None over the
SwiftData rows, wiping contactInfo metadata during the DIP-15
deferred-publish window.

- IdentityRestoreEntryFFI gains a contacts array (reuses the
  persist-side ContactRequestFFI shape; rows are load-allocation-
  owned — Rust's persist-side destructor never runs on them).
- Swift assembles the per-identity PersistentDashpayContactRequest
  rows into the load callback (payloads on scalarBuffers, metadata
  strings on cStringBuffers, array on the new contactArrays list).
- Rust folds them into ManagedIdentity at load: direction pairs →
  EstablishedContact (with alias/note/hidden + broken flag), single
  rows → pending sent/incoming maps. Direct map inserts, no
  persister rounds.

Verified on-sim: set alias → relaunch → SwiftData rows still carry
it AND the contact list renders it ("B, Bestie") after the first
sweep — the re-establish path now preserves metadata from the
restored in-memory contact.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Verified the checked-out worktree at 8037a47. All 10 prior blocking findings remain valid and in scope for this PR; no prior finding was resolved. CodeRabbit supplied no inline findings, and one lower-priority in-scope suggestion was omitted because the 10 blocking issues fill the comment budget.

🔴 10 blocking

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:167-180: Send-side rotation ignores established contacts
  The resend path still derives `previous_version` only from `managed.sent_contact_requests`. Once reciprocal sync establishes the contact, that sent request is moved to `EstablishedContact.outgoing_request`, so a later send to the same recipient falls back to version 0. With the same sender, recipient, xpub, and account index, that can reproduce the original masked `accountReference` and collide with the DashPay contract unique index instead of creating a broadcastable rotation. The new load path also restores established contacts without repopulating `sent_contact_requests`, so a resend after relaunch hits the same state immediately.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:441-478: Historical requests replay as rotations
  The receive-side rotation guard still compares only the incoming `accountReference` against the currently tracked reference. Because `fetch_received_contact_requests(identity_id, None)` is unfiltered and contactRequest documents are immutable, older v0 documents can be seen after a newer rotated document has advanced local state. Reprocessing the stale document as a rotation can replace the tracked request, tear down the external account, and then flip forward again on a later document or sweep; rotation acceptance needs a monotonic freshness check such as created time or decoded version, not bare reference inequality.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:806-826: Transient account-registration failures become permanent
  `build_contact_accounts` still treats every `register_external_contact_account` error as permanent and marks `payment_channel_broken`. That callee performs additional network-backed identity/decryption work, so a transient DAPI or fetch failure can be converted into durable local broken-channel state. Future sweeps then skip the contact until the other party sends a superseding request; only genuinely non-recoverable decrypt, decode, missing-key, or key-type failures should take the permanent-broken path.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:292-308: Disabled encryption keys are still selectable
  `select_recipient_key_index` still chooses the first DECRYPTION or ENCRYPTION ECDSA_SECP256K1 key without checking `disabled_at`, while receive-side validation rejects disabled keys. A sender can therefore encrypt the DIP-15 compact xpub to a key the recipient has revoked on-chain, which is the signal that the private half may be compromised. The same missing disabled-key filter remains in the contactInfo root encryption key selector at `contact_info.rs:313-320`, allowing owner-private alias/note/hidden metadata to be published under a revoked encryption key id.

In `packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/migrations/V001__initial.rs:178-212: V001 was edited in place
  `contacts.payment_channel_broken` and the `rejected_contact_requests` table are still added directly to V001, and there is no V002 migration. V001 already shipped, so existing v4.0.0-beta.4 databases will either fail refinery checksum validation or treat the old V001 as already applied and never create the new column/table. Runtime writes to the new broken-channel flag or rejected-request tombstone table then fail on upgraded wallets; these schema additions need an append-only migration.

In `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs:109-130: Reject does not persist the incoming-row deletion
  `record_rejected_contact_request` removes the incoming request from memory, but the returned `ContactChangeSet` only populates `cs.rejected`. The storage writer inserts rejected tombstones separately and only deletes live incoming rows when `cs.removed_incoming` is populated, so the stale `state='received'` contact row remains persisted. On the next load or contact restore, that stale row can reappear as a visible incoming request even though the user rejected it.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:3311-3417: Rejected tombstones are not restored
  The new `restore_dashpay_contacts` bridge restores sent, incoming, and established contact rows, but `IdentityRestoreEntryFFI` has no rejected-tombstone input and this function never repopulates `ManagedIdentity.rejected_contact_requests`. SwiftData likewise treats rejected snapshots as delete-only visible-row operations. After relaunch, Rust has no suppression entry for the immutable rejected contactRequest, while stale incoming rows can still be restored or fetched again, so a rejected request can reappear despite the user's action.

In `packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs:50-79: purpose_mismatch survives hard validation errors
  The type documents `purpose_mismatch` as true only when a key-purpose mismatch is the sole invalidity reason, and the sync code uses that flag to choose retry versus permanent broken-channel handling. However, `add_error` does not clear the flag, `add_purpose_error` sets it unconditionally, and `merge` ORs it. A request with both a hard key error and a purpose error can therefore be retried forever instead of being classified as permanently invalid.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3031-3047: Wallet deletion omits DashPay payment children
  The SwiftData deletion workaround pre-deletes identity children with non-optional inverse relationships, but it still deletes only DPNS names, DashPay profile, and contact requests. `PersistentDashpayPayment.owner` is also non-optional and is reached through `PersistentIdentity.dashpayPayments`, so a wallet with DashPay payment history can hit the same SwiftData inverse-removal fatal during identity deletion. That aborts the wipe before the wallet row is removed and can leave payment metadata such as memo, counterparty id, amount, and txid on disk.

In `packages/rs-platform-wallet/src/manager/dashpay_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/dashpay_sync.rs:230-232: DashPay sync cleanup can clear a newer cancel token
  The DashPay sync worker still unconditionally sets `background_cancel` to `None` when its loop exits. If `stop()` cancels token A, `start()` installs token B, and the old thread exits afterward, the old cleanup erases B while the new loop is still running. Later `stop()` or shutdown cannot find the live token, so FFI callback context can be freed while an untracked DashPay thread continues calling through Swift-owned callback pointers. The identity and shielded sync managers already use generation guards to avoid this race.

Comment on lines +167 to +180
let previous_version = {
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))
.and_then(|managed| managed.sent_contact_requests.get(recipient_identity_id))
.map(|prior| {
crate::wallet::identity::crypto::dip14::unmask_account_reference(
prior.account_reference,
&secret,
&xpub_bytes,
)
.0
})
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Send-side rotation ignores established contacts

The resend path still derives previous_version only from managed.sent_contact_requests. Once reciprocal sync establishes the contact, that sent request is moved to EstablishedContact.outgoing_request, so a later send to the same recipient falls back to version 0. With the same sender, recipient, xpub, and account index, that can reproduce the original masked accountReference and collide with the DashPay contract unique index instead of creating a broadcastable rotation. The new load path also restores established contacts without repopulating sent_contact_requests, so a resend after relaunch hits the same state immediately.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in a9599b6Send-side rotation ignores established contacts no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +441 to 478
let tracked_reference = managed
.incoming_contact_requests
.get(&sender_id)
.map(|r| r.account_reference)
.or_else(|| {
managed
.established_contacts
.get(&sender_id)
.map(|c| c.incoming_request.account_reference)
});
if tracked_reference == Some(contact_request.account_reference) {
continue;
}
};
let account_reference = match props
.get("accountReference")
.and_then(|v: &Value| v.to_integer::<u32>().ok())
{
Some(v) => v,
None => {
tracing::warn!(
// G5 stage 1: a rejected request (same sender +
// accountReference) must not be resurrected. A rotated
// request (bumped accountReference) is NOT suppressed.
if managed.is_request_rejected(&sender_id, contact_request.account_reference) {
tracing::debug!(
sender = %sender_id,
recipient = %identity_id,
"Skipping contact request document: missing accountReference"
account_reference = contact_request.account_reference,
"Skipping rejected contact request (tombstoned); doc {doc_id}"
);
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 tracked_reference.is_some() {
// Rotation: supersede the tracked request. When an
// established contact was re-keyed, queue the stale
// external account for teardown so the build sweep
// below re-registers it from the new xpub.
if managed
.apply_rotated_incoming_request(contact_request.clone(), &self.persister)
{
rotated_contacts.push(sender_id);
}
all_requests.push(contact_request);
continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Historical requests replay as rotations

The receive-side rotation guard still compares only the incoming accountReference against the currently tracked reference. Because fetch_received_contact_requests(identity_id, None) is unfiltered and contactRequest documents are immutable, older v0 documents can be seen after a newer rotated document has advanced local state. Reprocessing the stale document as a rotation can replace the tracked request, tear down the external account, and then flip forward again on a later document or sweep; rotation acceptance needs a monotonic freshness check such as created time or decoded version, not bare reference inequality.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in a9599b6Historical requests replay as rotations no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines 186 to +212
note TEXT,
is_hidden INTEGER,
accepted_accounts BLOB,
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
);

-- Rejected-request tombstone (G5 stage 1). Keyed by
-- `(wallet_id, owner_id, sender_id, account_reference)` — NOT bare
-- sender id — so a once-rejected sender can still re-request via a
-- bumped accountReference (the DIP-15 rotation mechanism), while a
-- replay of the exact same immutable request stays suppressed. The
-- optional `document_id` records the rejected document's id for audit /
-- exact-match suppression. The sync ingest path consults this table
-- before re-ingesting a received contactRequest.
CREATE TABLE rejected_contact_requests (
wallet_id BLOB NOT NULL,
owner_id BLOB NOT NULL,
sender_id BLOB NOT NULL,
account_reference INTEGER NOT NULL,
document_id BLOB,
rejected_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (wallet_id, owner_id, sender_id, account_reference),
FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: V001 was edited in place

contacts.payment_channel_broken and the rejected_contact_requests table are still added directly to V001, and there is no V002 migration. V001 already shipped, so existing v4.0.0-beta.4 databases will either fail refinery checksum validation or treat the old V001 as already applied and never create the new column/table. Runtime writes to the new broken-channel flag or rejected-request tombstone table then fail on upgraded wallets; these schema additions need an append-only migration.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — V001 was edited in place no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +109 to +130
pub fn record_rejected_contact_request(
&mut self,
sender_id: &Identifier,
account_reference: u32,
document_id: Option<Identifier>,
) -> ContactChangeSet {
let owner_id = self.id();
self.incoming_contact_requests.remove(sender_id);

let tombstone = RejectedContactRequest {
owner_id,
sender_id: *sender_id,
account_reference,
document_id,
};
self.rejected_contact_requests
.insert((*sender_id, account_reference), tombstone.clone());

let mut cs = ContactChangeSet::default();
cs.rejected
.insert((owner_id, *sender_id, account_reference), tombstone);
cs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Reject does not persist the incoming-row deletion

record_rejected_contact_request removes the incoming request from memory, but the returned ContactChangeSet only populates cs.rejected. The storage writer inserts rejected tombstones separately and only deletes live incoming rows when cs.removed_incoming is populated, so the stale state='received' contact row remains persisted. On the next load or contact restore, that stale row can reappear as a visible incoming request even though the user rejected it.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Reject does not persist the incoming-row deletion no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +3311 to +3417
unsafe fn restore_dashpay_contacts(
spec: &IdentityRestoreEntryFFI,
owner_id: &Identifier,
managed: &mut ManagedIdentity,
) {
use platform_wallet::{ContactRequest, EstablishedContact};

if spec.contacts.is_null() || spec.contacts_count == 0 {
return;
}
let rows = slice::from_raw_parts(spec.contacts, spec.contacts_count);

/// Per-contact accumulator while pairing the direction rows.
#[derive(Default)]
struct PairAccumulator {
outgoing: Option<ContactRequest>,
incoming: Option<ContactRequest>,
payment_channel_broken: bool,
alias: Option<String>,
note: Option<String>,
is_hidden: bool,
}

let opt_string = |ptr: *const std::os::raw::c_char| -> Option<String> {
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<Vec<u8>> {
if ptr.is_null() || len == 0 {
None
} else {
Some(slice::from_raw_parts(ptr, len).to_vec())
}
};

let mut by_contact: BTreeMap<[u8; 32], PairAccumulator> = BTreeMap::new();
for row in rows {
let contact_id = Identifier::from(row.contact_id);
let (sender_id, recipient_id) = if row.is_outgoing {
(*owner_id, contact_id)
} else {
(contact_id, *owner_id)
};
let mut request = ContactRequest::new(
sender_id,
recipient_id,
row.sender_key_index,
row.recipient_key_index,
row.account_reference,
opt_bytes(row.encrypted_public_key, row.encrypted_public_key_len)
.unwrap_or_default(),
row.core_height_created_at,
row.created_at,
);
request.encrypted_account_label =
opt_bytes(row.encrypted_account_label, row.encrypted_account_label_len);
request.auto_accept_proof = opt_bytes(row.auto_accept_proof, row.auto_accept_proof_len);

let acc = by_contact.entry(row.contact_id).or_default();
if row.is_outgoing {
acc.outgoing = Some(request);
} else {
acc.incoming = Some(request);
}
// Relationship-level properties are replicated onto both rows
// by the persist projection; OR / first-non-null is the safe
// re-fold.
acc.payment_channel_broken |= row.payment_channel_broken;
acc.is_hidden |= row.is_hidden;
if acc.alias.is_none() {
acc.alias = opt_string(row.alias);
}
if acc.note.is_none() {
acc.note = opt_string(row.note);
}
}

for (contact_id_bytes, acc) in by_contact {
let contact_id = Identifier::from(contact_id_bytes);
match (acc.outgoing, acc.incoming) {
(Some(outgoing), Some(incoming)) => {
let mut contact = EstablishedContact::new(contact_id, outgoing, incoming);
contact.alias = acc.alias;
contact.note = acc.note;
contact.is_hidden = acc.is_hidden;
contact.payment_channel_broken = acc.payment_channel_broken;
managed.established_contacts.insert(contact_id, contact);
}
(Some(outgoing), None) => {
managed.sent_contact_requests.insert(contact_id, outgoing);
}
(None, Some(incoming)) => {
managed
.incoming_contact_requests
.insert(contact_id, incoming);
}
(None, None) => unreachable!("accumulator entries always hold at least one row"),
}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Rejected tombstones are not restored

The new restore_dashpay_contacts bridge restores sent, incoming, and established contact rows, but IdentityRestoreEntryFFI has no rejected-tombstone input and this function never repopulates ManagedIdentity.rejected_contact_requests. SwiftData likewise treats rejected snapshots as delete-only visible-row operations. After relaunch, Rust has no suppression entry for the immutable rejected contactRequest, while stale incoming rows can still be restored or fetched again, so a rejected request can reappear despite the user's action.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Rejected tombstones are not restored no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +806 to +826
// (3) Register the external (sending) account — decrypt + ECDH. A
// decrypt/decode failure is PERMANENT.
if let Err(e) = self
.register_external_contact_account(
identity_id,
&contact_id,
&candidate.encrypted_public_key,
candidate.our_decryption_key_index,
candidate.contact_encryption_key_index,
)
.await
{
tracing::warn!(
identity = %identity_id,
contact = %contact_id,
error = %e,
"Failed to register DashPay external account; marking payment channel broken (permanent)"
);
self.mark_contact_channel_broken(identity_id, &contact_id)
.await;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Transient account-registration failures become permanent

build_contact_accounts still treats every register_external_contact_account error as permanent and marks payment_channel_broken. That callee performs additional network-backed identity/decryption work, so a transient DAPI or fetch failure can be converted into durable local broken-channel state. Future sweeps then skip the contact until the other party sends a superseding request; only genuinely non-recoverable decrypt, decode, missing-key, or key-type failures should take the permanent-broken path.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in a9599b6Transient account-registration failures become permanent no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines 73 to 79
if !other.is_valid {
self.is_valid = false;
}
if other.purpose_mismatch {
self.purpose_mismatch = true;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: purpose_mismatch survives hard validation errors

The type documents purpose_mismatch as true only when a key-purpose mismatch is the sole invalidity reason, and the sync code uses that flag to choose retry versus permanent broken-channel handling. However, add_error does not clear the flag, add_purpose_error sets it unconditionally, and merge ORs it. A request with both a hard key error and a purpose error can therefore be retried forever instead of being classified as permanently invalid.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — purpose_mismatch survives hard validation errors no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +230 to +232
if let Ok(mut guard) = this.background_cancel.lock() {
*guard = None;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: DashPay sync cleanup can clear a newer cancel token

The DashPay sync worker still unconditionally sets background_cancel to None when its loop exits. If stop() cancels token A, start() installs token B, and the old thread exits afterward, the old cleanup erases B while the new loop is still running. Later stop() or shutdown cannot find the live token, so FFI callback context can be freed while an untracked DashPay thread continues calling through Swift-owned callback pointers. The identity and shielded sync managers already use generation guards to avoid this race.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — DashPay sync cleanup can clear a newer cancel token no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +292 to +308
fn select_recipient_key_index(recipient_identity: &Identity) -> Result<u32, PlatformWalletError> {
// 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
}) {
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
}) {
return Ok(*id);
}
Err(PlatformWalletError::InvalidIdentityData(
"Recipient identity has no ECDSA_SECP256K1 DECRYPTION or ENCRYPTION key".to_string(),
))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Disabled encryption keys are still selectable

select_recipient_key_index still chooses the first DECRYPTION or ENCRYPTION ECDSA_SECP256K1 key without checking disabled_at, while receive-side validation rejects disabled keys. A sender can therefore encrypt the DIP-15 compact xpub to a key the recipient has revoked on-chain, which is the signal that the private half may be compromised. The same missing disabled-key filter remains in the contactInfo root encryption key selector at contact_info.rs:313-320, allowing owner-private alias/note/hidden metadata to be published under a revoked encryption key id.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in a9599b6Disabled encryption keys are still selectable no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

@github-actions

Copy link
Copy Markdown
Contributor

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "a35c2efe8eb96a98082ae78acfdcbf0d5672c1628268cdc2f4642481647a0911"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

shumkov added 2 commits June 13, 2026 01:47
…on, G8 real ciphertext, G9 contract cache

- G6: the cfg(not(dashpay-contract)) fallback contract id held the
  DPNS id — corrected to the deployed DashPay id (latent foot-gun;
  dead code in default builds).
- G7 (send half): the selected sender/recipient key pair now gates
  through validate_contact_request BEFORE any ECDH or broadcast —
  same validator the receive/accept paths use since M1. Warnings are
  logged; hard failures abort the send. Auto-accept stays
  deliberately dormant: it activates with M5 invitations behind the
  verify-gate acceptance criterion.
- G8: the local sent ContactRequest now stores the real 96-byte
  encryptedPublicKey off the broadcast document instead of a zero
  placeholder, so the persisted/SwiftData row matches Platform.
- G9: process-wide OnceLock cache for the bundled DashPay contract —
  replaces five per-call load_system_data_contract re-parses across
  the profile/contactInfo paths.

226/226 lib tests green.
…19 done

G4 scoping found the M3 ECDH-only hook insufficient for watch-only:
the friendship/receiving xpub derivations are hardened (seed-bound)
on both send and accept, so true watch-only needs xpub-derivation
hooks too (or one combined derive-DashPay-context hook). Recorded so
the future slice starts from the real constraint, not the
incomplete M3 note. Task 20 (live cross-client e2e) marked
blocked-external with the manual-run note.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Cumulative review at af53ae0 (incremental over 8037a47). The latest delta — G6 fallback contract id, G7 pre-send key-pair validator, G8 real ciphertext capture, G9 process-wide contract cache — is small and well-scoped, and introduces no new blockers. G7 mitigates the worst-case security half of prior finding #10 on the send path (a disabled recipient key is now caught before ECDH/broadcast), but the underlying selectors still don't filter disabled_at and the contactInfo rootEncryptionKeyIndex selector is still ungated. The remaining nine prior blockers are unchanged at HEAD. Verified the send-side rotation lookup, the rejected-request changeset, the V001 in-place edits (no V002 exists), and the unconditional DashPay sync cancel-token clear by reading the worktree at HEAD. Recommending REQUEST_CHANGES.

🔴 4 blocking

1 additional finding(s) omitted (not in diff).

6 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/migrations/V001__initial.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/migrations/V001__initial.rs:178-212: V001 migration edited in place — refinery upgrade from v4.0.0-beta.4 will not pick up new column / table
  Verified at HEAD: `contacts.payment_channel_broken` (line 189) and `CREATE TABLE rejected_contact_requests` (lines 203-212) are added directly into V001, and no V002 migration exists anywhere under `packages/rs-platform-wallet-storage/migrations/` (globbed `V*.rs` returns only V001). V001 (without these additions) shipped in v4.0.0-beta.4, and the storage README enforces append-only migrations because refinery checksums each migration in `refinery_schema_history`. Against an existing beta.4 DB this either aborts on divergent checksum or silently skips V001 as already-applied — neither the new column nor the new table will exist. The first runtime write touching `payment_channel_broken` (`mark_contact_channel_broken`) or `INSERT INTO rejected_contact_requests` (G5 tombstone) then fails at SQLite, disabling the PR's payment-channel-broken safety state and the G5 rejection tombstone on every upgraded wallet. Add a V002 migration: `ALTER TABLE contacts ADD COLUMN payment_channel_broken INTEGER` plus a separate `CREATE TABLE rejected_contact_requests (...)`.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:3311-3417: Rejected tombstones are written but never restored on rehydration
  `restore_dashpay_contacts` restores sent, incoming, and established rows from the FFI restore spec, but `IdentityRestoreEntryFFI` exposes no rejected-tombstone field — there is no C struct or count/pointer pair for tombstones in the FFI surface at all. The Swift side persists rejected snapshots as write-only deletes on visible rows, and the SQLite reconstruction helper defaults `rejected_contact_requests` to empty. After relaunch, Rust has no suppression entry for the immutable rejected contactRequest, and the sync-loop tombstone check at contact_requests.rs:500 never fires — a rejected request resurfaces on the first sweep. Because the on-platform document is immutable, wiping the tombstone on restart defeats the user's explicit reject and re-exposes them to suppression-bypass by an unwanted sender. Add a per-wallet load reader for `rejected_contact_requests`, surface the rows via a new `rejected_tombstones` field on `IdentityRestoreEntryFFI`, and rehydrate `ManagedIdentity.rejected_contact_requests` from that input.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3037-3048: Wallet deletion PHASE 1 omits PersistentDashpayPayment children — same fatal pattern as contactRequests
  PHASE 1 iterates `dpnsNames`, `dashpayProfile`, and `contactRequests` but NOT `dashpayPayments`. `PersistentDashpayPayment.owner` is non-optional and `PersistentIdentity.dashpayPayments` is the cascading inverse added in this PR. The surrounding comment explicitly documents that this phase exists because SwiftData fatals during `save()` when it must null out a non-optional inverse on a child processed in the same batch — exactly the shape of the new payments relationship. A wallet with persisted DashPay payment history will hit the SwiftData fatal at PHASE 2 `save()`, aborting before the wallet row is removed. The user believes their data was wiped; plaintext memo + counterparty id + amount + txid rows remain on disk — a confidentiality regression for users handing off or disposing devices.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs:301-308: Disabled encryption keys are still selectable — contactInfo rootEncryptionKeyIndex and contactRequest selector both miss disabled_at
  G7's pre-send validator at `contact_requests.rs:127-138` now gates the contactRequest send path against `validate_contact_request`, which checks `disabled_at`, closing the worst-case 'encrypt DIP-15 compact xpub to a revoked recipient key' confidentiality path. Two residual gaps remain in this PR's scope: (1) `contact_info.rs:301-308` still picks the owner's first ECDSA_SECP256K1 ENCRYPTION key for `rootEncryptionKeyIndex` with no `disabled_at` filter and no equivalent validator gate. The published contactInfo doc references that key id, and if an attacker has the revoked root private material the public id + derivation index lets them derive the same contactInfo AES keys and decrypt the owner's private alias/note/hidden metadata — a real confidentiality exposure on the contactInfo path. (2) `contact_requests.rs:335-351` (`select_recipient_key_index`) still picks the first matching key without `disabled_at`; when a recipient has [disabled DECRYPTION + live ENCRYPTION], the new G7 gate now hard-fails the send instead of falling back to the live ENCRYPTION key — a behavioral regression introduced by this delta. Add `&& k.disabled_at().is_none()` to both selectors so contactInfo is safe and the contactRequest send path picks the live key cleanly.

Comment on lines 186 to +212
note TEXT,
is_hidden INTEGER,
accepted_accounts BLOB,
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
);

-- Rejected-request tombstone (G5 stage 1). Keyed by
-- `(wallet_id, owner_id, sender_id, account_reference)` — NOT bare
-- sender id — so a once-rejected sender can still re-request via a
-- bumped accountReference (the DIP-15 rotation mechanism), while a
-- replay of the exact same immutable request stays suppressed. The
-- optional `document_id` records the rejected document's id for audit /
-- exact-match suppression. The sync ingest path consults this table
-- before re-ingesting a received contactRequest.
CREATE TABLE rejected_contact_requests (
wallet_id BLOB NOT NULL,
owner_id BLOB NOT NULL,
sender_id BLOB NOT NULL,
account_reference INTEGER NOT NULL,
document_id BLOB,
rejected_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (wallet_id, owner_id, sender_id, account_reference),
FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: V001 migration edited in place — refinery upgrade from v4.0.0-beta.4 will not pick up new column / table

Verified at HEAD: contacts.payment_channel_broken (line 189) and CREATE TABLE rejected_contact_requests (lines 203-212) are added directly into V001, and no V002 migration exists anywhere under packages/rs-platform-wallet-storage/migrations/ (globbed V*.rs returns only V001). V001 (without these additions) shipped in v4.0.0-beta.4, and the storage README enforces append-only migrations because refinery checksums each migration in refinery_schema_history. Against an existing beta.4 DB this either aborts on divergent checksum or silently skips V001 as already-applied — neither the new column nor the new table will exist. The first runtime write touching payment_channel_broken (mark_contact_channel_broken) or INSERT INTO rejected_contact_requests (G5 tombstone) then fails at SQLite, disabling the PR's payment-channel-broken safety state and the G5 rejection tombstone on every upgraded wallet. Add a V002 migration: ALTER TABLE contacts ADD COLUMN payment_channel_broken INTEGER plus a separate CREATE TABLE rejected_contact_requests (...).

source: ['claude', 'codex']

Comment on lines +3311 to +3417
unsafe fn restore_dashpay_contacts(
spec: &IdentityRestoreEntryFFI,
owner_id: &Identifier,
managed: &mut ManagedIdentity,
) {
use platform_wallet::{ContactRequest, EstablishedContact};

if spec.contacts.is_null() || spec.contacts_count == 0 {
return;
}
let rows = slice::from_raw_parts(spec.contacts, spec.contacts_count);

/// Per-contact accumulator while pairing the direction rows.
#[derive(Default)]
struct PairAccumulator {
outgoing: Option<ContactRequest>,
incoming: Option<ContactRequest>,
payment_channel_broken: bool,
alias: Option<String>,
note: Option<String>,
is_hidden: bool,
}

let opt_string = |ptr: *const std::os::raw::c_char| -> Option<String> {
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<Vec<u8>> {
if ptr.is_null() || len == 0 {
None
} else {
Some(slice::from_raw_parts(ptr, len).to_vec())
}
};

let mut by_contact: BTreeMap<[u8; 32], PairAccumulator> = BTreeMap::new();
for row in rows {
let contact_id = Identifier::from(row.contact_id);
let (sender_id, recipient_id) = if row.is_outgoing {
(*owner_id, contact_id)
} else {
(contact_id, *owner_id)
};
let mut request = ContactRequest::new(
sender_id,
recipient_id,
row.sender_key_index,
row.recipient_key_index,
row.account_reference,
opt_bytes(row.encrypted_public_key, row.encrypted_public_key_len)
.unwrap_or_default(),
row.core_height_created_at,
row.created_at,
);
request.encrypted_account_label =
opt_bytes(row.encrypted_account_label, row.encrypted_account_label_len);
request.auto_accept_proof = opt_bytes(row.auto_accept_proof, row.auto_accept_proof_len);

let acc = by_contact.entry(row.contact_id).or_default();
if row.is_outgoing {
acc.outgoing = Some(request);
} else {
acc.incoming = Some(request);
}
// Relationship-level properties are replicated onto both rows
// by the persist projection; OR / first-non-null is the safe
// re-fold.
acc.payment_channel_broken |= row.payment_channel_broken;
acc.is_hidden |= row.is_hidden;
if acc.alias.is_none() {
acc.alias = opt_string(row.alias);
}
if acc.note.is_none() {
acc.note = opt_string(row.note);
}
}

for (contact_id_bytes, acc) in by_contact {
let contact_id = Identifier::from(contact_id_bytes);
match (acc.outgoing, acc.incoming) {
(Some(outgoing), Some(incoming)) => {
let mut contact = EstablishedContact::new(contact_id, outgoing, incoming);
contact.alias = acc.alias;
contact.note = acc.note;
contact.is_hidden = acc.is_hidden;
contact.payment_channel_broken = acc.payment_channel_broken;
managed.established_contacts.insert(contact_id, contact);
}
(Some(outgoing), None) => {
managed.sent_contact_requests.insert(contact_id, outgoing);
}
(None, Some(incoming)) => {
managed
.incoming_contact_requests
.insert(contact_id, incoming);
}
(None, None) => unreachable!("accumulator entries always hold at least one row"),
}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Rejected tombstones are written but never restored on rehydration

restore_dashpay_contacts restores sent, incoming, and established rows from the FFI restore spec, but IdentityRestoreEntryFFI exposes no rejected-tombstone field — there is no C struct or count/pointer pair for tombstones in the FFI surface at all. The Swift side persists rejected snapshots as write-only deletes on visible rows, and the SQLite reconstruction helper defaults rejected_contact_requests to empty. After relaunch, Rust has no suppression entry for the immutable rejected contactRequest, and the sync-loop tombstone check at contact_requests.rs:500 never fires — a rejected request resurfaces on the first sweep. Because the on-platform document is immutable, wiping the tombstone on restart defeats the user's explicit reject and re-exposes them to suppression-bypass by an unwanted sender. Add a per-wallet load reader for rejected_contact_requests, surface the rows via a new rejected_tombstones field on IdentityRestoreEntryFFI, and rehydrate ManagedIdentity.rejected_contact_requests from that input.

source: ['claude', 'codex']

Comment on lines +301 to +308
let root_key_id = managed
.identity
.public_keys()
.iter()
.find(|(_, k)| {
k.purpose() == Purpose::ENCRYPTION && k.key_type() == KeyType::ECDSA_SECP256K1
})
.map(|(_, k)| k.id());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Disabled encryption keys are still selectable — contactInfo rootEncryptionKeyIndex and contactRequest selector both miss disabled_at

G7's pre-send validator at contact_requests.rs:127-138 now gates the contactRequest send path against validate_contact_request, which checks disabled_at, closing the worst-case 'encrypt DIP-15 compact xpub to a revoked recipient key' confidentiality path. Two residual gaps remain in this PR's scope: (1) contact_info.rs:301-308 still picks the owner's first ECDSA_SECP256K1 ENCRYPTION key for rootEncryptionKeyIndex with no disabled_at filter and no equivalent validator gate. The published contactInfo doc references that key id, and if an attacker has the revoked root private material the public id + derivation index lets them derive the same contactInfo AES keys and decrypt the owner's private alias/note/hidden metadata — a real confidentiality exposure on the contactInfo path. (2) contact_requests.rs:335-351 (select_recipient_key_index) still picks the first matching key without disabled_at; when a recipient has [disabled DECRYPTION + live ENCRYPTION], the new G7 gate now hard-fails the send instead of falling back to the live ENCRYPTION key — a behavioral regression introduced by this delta. Add && k.disabled_at().is_none() to both selectors so contactInfo is safe and the contactRequest send path picks the live key cleanly.

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Disabled encryption keys are still selectable — contactInfo rootEncryptionKeyIndex and contactRequest selector both miss disabled_at no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

shumkov added 2 commits June 13, 2026 02:26
A send from identity A ghosted as an outgoing "Pending" row under
identity B after a picker switch — optimisticSentIds is per-identity
state and now resets on selection change. Observed live during the
rotation UAT (2026-06-13).

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Cumulative review at 9441316. Latest delta is documentation (SPEC.md UAT round 2) plus a per-identity reset of optimisticSentIds in DashPayTabView — no Rust, FFI, or schema changes. All 10 prior blocking findings independently re-verified at HEAD against the worktree and remain valid; both Claude and Codex converge on every one of them. Recommending REQUEST_CHANGES.

🔴 1 blocking

1 additional finding(s) omitted (not in diff).

9 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:3037-3048: Wallet deletion PHASE 1 omits PersistentDashpayPayment children — same fatal pattern as contactRequests
  Verified at HEAD lines 3037-3047: PHASE 1 iterates `dpnsNames`, `dashpayProfile`, and `contactRequests` but NOT `dashpayPayments`. `PersistentDashpayPayment.owner` is non-optional and `PersistentIdentity.dashpayPayments` is the cascading inverse added in this PR (populated from `DashpayPaymentFFI` rows). The surrounding comment (lines 3013-3036) explicitly documents that this phase exists because SwiftData fatals during `save()` when it must null out a non-optional inverse on a child processed in the same batch — exactly the shape of the new payments relationship. A wallet with persisted DashPay payment history will hit the SwiftData fatal at PHASE 2 `save()`, aborting before the wallet row is removed. The user believes their data was wiped; plaintext memo + counterparty id + amount + txid rows remain on disk — a confidentiality regression for users handing off or disposing devices.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants