Skip to content

fix(platform-wallet)!: complete dashpay#3841

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

fix(platform-wallet)!: complete dashpay#3841
shumkov wants to merge 72 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)

Note

CI: Rust workspace tests / Tests (macOS) red on 3 pre-existing tests — passing locally.
The macOS check fails only on three receiver-payment tests
(register_contact_account_persists_account_registration,
reconcile_records_received_payments_from_receival_utxos,
reconcile_does_not_clobber_existing_entry_for_same_txid), all with
External signable wallet has no private key.

These pass locally in every configuration tested (9): cargo test, cargo nextest
(isolated and full platform-wallet suite), the CI feature set, --all-features, the
platform-wallet-family feature unification, under cargo llvm-cov coverage, and the
exact CI package set (drive+dpp+drive-abci+… --all-features under coverage) —
all on the same macOS/aarch64 as the runner. All green.

The wallet is provably WalletType::Seed-bearing through every code path (from_seed
Seed; the manager's insert_wallet stores it verbatim; get_wallet returns a &Wallet),
yet only the CI runner reads it as ExternalSignable. Root cause is a use-after-zeroize in
the key-wallet git dependency
: Wallet has a Drop that zeroizes its Zeroize-derived
wallet_type, so the discriminant can corrupt under a particular memory layout (UB is
environment-dependent — it manifests on the CI runner but not locally). This is outside
this PR's code
— pre-existing branch tests plus an external-dependency bug being tracked for
the key-wallet maintainers; the DashPay changes themselves are correct and green.

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@thepastaclaw

thepastaclaw commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 808b347)

@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

Comment thread docs/dashpay/research/01-dip-spec.md Outdated
Comment thread docs/dashpay/research/02-rust-dashcore-keywallet.md Outdated
Comment thread docs/dashpay/research/05-swift-app.md Outdated
Comment thread docs/dashpay/research/06-interop-desk-check.md Outdated
Comment thread docs/dashpay/research/06-interop-desk-check.md Outdated
Comment thread packages/rs-platform-wallet/src/manager/dashpay_sync.rs Outdated
@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.78%. Comparing base (e2039e5) to head (c1f3aa5).
⚠️ Report is 32 commits behind head on v3.1-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v3.1-dev    #3841      +/-   ##
============================================
+ Coverage     71.20%   72.78%   +1.58%     
============================================
  Files            20       22       +2     
  Lines          2837     3054     +217     
============================================
+ Hits           2020     2223     +203     
- Misses          817      831      +14     
Components Coverage Δ
dpp ∅ <ø> (∅)
drive ∅ <ø> (∅)
drive-abci ∅ <ø> (∅)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value ∅ <ø> (∅)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier ∅ <ø> (∅)
🚀 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 thread packages/rs-platform-wallet-storage/migrations/V001__initial.rs
Comment thread packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs Outdated
Comment thread packages/rs-platform-wallet/src/manager/dashpay_sync.rs
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

@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

@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 thread packages/rs-platform-wallet-ffi/src/dashpay_payment.rs
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 thread packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs Outdated
Comment thread packages/rs-platform-wallet/src/manager/dashpay_sync.rs
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.

…m_seed_bytes

The method takes the seed by &[u8;64] and its doc-comment claimed it never
owns a non-zeroized stack copy — but `*seed_bytes` materializes one
(`[u8;64]: Copy`). Wrap that named copy in Zeroizing so it's scrubbed on
drop, and correct the doc-comment: a transient by-value copy still crosses
into key-wallet's from_seed_bytes (external crate; eliminating it fully
needs from_seed_bytes to take &[u8;64] upstream), which consumes it into
its own zeroizing Seed. Defense-in-depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shumkov

shumkov commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the latest `thepastaclaw` review (commits `631f6fc`, `aec04f2`, `ae13d13`):

🔴 Blocking 1 — `record_rejected_contact_request` leaves `cs.removed_incoming` empty → fixed in `631f6fc`. It now also emits `removed_incoming`, so the Rust SQLite contacts writer issues the `DELETE FROM contacts` (not just the tombstone-table upsert) and the rejected request no longer rehydrates on the next load. The two backends are now consistent. You were right — my earlier resolution only accounted for the SwiftData `deleteRejectedIncomingRow` path. Pinned by `record_rejected_emits_removed_incoming_so_sqlite_deletes_the_row` (✖ with only `cs.rejected`, ✔ once `removed_incoming` is also emitted).

🔴 Blocking 2 — wallet-wipe PHASE 1 omits `dashpayPayments` + `dashpayRejectedRequests` → fixed in `aec04f2`. Both (non-optional `owner` inverses) are now pre-deleted in the PHASE 1 loop, so a wipe no longer hits the SwiftData fatal mid-`save()` and leaves plaintext payment rows / rejection tombstones on disk. SwiftExampleApp builds clean.

🟡 *Suggestion 1 — `seed_bytes` non-zeroized stack copy → fixed in `ae13d13`. Wrapped the named copy in `Zeroizing` and corrected the doc-comment to be honest about the residual transient by-value copy into key-wallet's `from_seed_bytes` (external crate — fully eliminating it needs `from_seed_bytes(&[u8;64])` upstream, which consumes it into its own zeroizing `Seed`).

🟡 Suggestion 2 — create-only contact-request C ABI drops `entropy` (`rs-sdk-ffi`) — deferring: it's on an FFI surface this PR didn't touch, and the example app uses the platform-wallet FFI path rather than `dash_sdk_dashpay_create_contact_request`. Tracking as a follow-up rather than widening this PR; happy to fold it in if preferred.

Verification: platform-wallet 254 tests + clippy clean; iOS framework + SwiftExampleApp build green.

@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

Latest push resolves three prior findings (rejected-request removed_incoming emission, wallet-wipe PHASE 1 covering DashPay payments and rejected tombstones, and zeroizing the create_wallet_from_seed_bytes local seed copy). One in-scope suggestion remains: the create-only contact-request C ABI does not surface the entropy that the Rust SDK was widened in this same PR to expose, which is the consensus-critical preimage for the document id.

🟡 1 suggestion(s)

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-sdk-ffi/src/dashpay/contact_request.rs`:
- [SUGGESTION] packages/rs-sdk-ffi/src/dashpay/contact_request.rs:181-188: Create-only contact-request C ABI drops the consensus-critical entropy
  `DashSDKContactRequestResult` exposes only `document_id`, `owner_id`, and `properties_json`. In the same PR, the Rust SDK's `ContactRequestResult` was widened to publicly carry `pub entropy: Bytes32` (packages/rs-sdk/src/platform/dashpay/contact_request.rs:120), with a comment that the entropy must be reused on broadcast or the transition is rejected with `InvalidDocumentTransitionIdError` — the exact root cause this PR is fixing. The atomic `dash_sdk_dashpay_send_contact_request` path is safe because it keeps the entropy in-process, but `dash_sdk_dashpay_create_contact_request` advertises a local-construction-then-broadcast flow ('The document is not yet submitted to the platform.', lines 203-204) and silently discards the entropy at the FFI boundary. Any C/Swift consumer that uses the create-only entry point and submits the document via a separate broadcast call cannot reproduce the same document id and will reintroduce the bug this PR exists to remove. Mirror the Rust change at the C boundary by adding a 32-byte `entropy` field to `DashSDKContactRequestResult` (or a dedicated accessor), and document the binding to broadcast. Note this extends the `#[repr(C)]` struct layout, so coordinate the change with a version bump or a new entry-point name to avoid breaking pre-built ABI consumers.

@shumkov

shumkov commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Re: Suggestion 2 — create-only contact-request C ABI drops entropy (rs-sdk-ffi/src/dashpay/contact_request.rs):

Out of scope for this PR — closing as wont-fix-here. This PR's surface is the platform-wallet DashPay sync/payments flow and the SwiftExampleApp, which create contact requests through the platform-wallet FFI path. dash_sdk_dashpay_create_contact_request and its DashSDKContactRequestResult C struct are a separate generic-embedder API in rs-sdk-ffi that this PR does not touch. Extending that struct with entropy for non-Rust embedders is a legitimate improvement, but it belongs in an rs-sdk-ffi follow-up rather than widening this PR's blast radius. Tracking separately.

shumkov and others added 3 commits June 17, 2026 20:01
…odify-write)

A partial profile update wiped every field the caller did not set — both
on-platform and in the local cache. `update_profile_with_external_signer`
fetched the existing document only for its id+revision, then built a fresh
property map from the provided inputs, and returned a `DashPayProfile`
constructed from those inputs alone (which overwrote the local mirror via
`set_dashpay_profile`). Editing displayName therefore dropped
publicMessage/avatarUrl/avatar on Platform and locally.

Fix (read-modify-write, mirroring kotlin `Profiles.replace`):
- seed the property map from the existing document's `properties()`;
- overlay only caller-provided fields; overlay avatar hash/fingerprint
  only when new avatar bytes are supplied (the on-platform document is now
  the source of truth, so the fragile local-cache fallback is removed);
- build the returned profile from the merged map so the local cache keeps
  its siblings too.

Extracted two pure helpers — `merge_profile_properties` and
`profile_from_properties` — and reused the parser to replace the duplicated
read-path logic (upgrading `as_bytes` to `as_bytes_slice`, a safe superset
that also handles the sized `Bytes32` avatarHash representation).

Test would have caught this in CI: reverting the merge to the old
fresh-map build fails all three new tests (sibling-retention, avatar
overlay semantics, returned-profile merge) — ✖ before fix, ✔ after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rmed)

`send_payment` recorded the outgoing `PaymentEntry` as `Pending` at
broadcast time, and nothing ever advanced it: the live and reconcile
recorders both bail on an existing txid (`contains_key`) and only handle
`Received` outputs, so a sent payment stayed `Pending` forever (UAT: sent
payments never showed confirmed).

Wire a sender-side confirm path. The wallet re-emits `TransactionDetected`
for our own transaction as it moves mempool -> in-block -> chain-locked, so
on a confirmed re-detection (`record.is_confirmed()`) the matching `Sent`
entry is flipped to `Confirmed` in place, preserving amount/memo/
counterparty. Idempotent: once `Confirmed`, later re-detections find
nothing to change and skip the persist.

Split into the event glue (`confirm_sent_dashpay_payment`, gates on
`is_confirmed`) and a unit-testable state transition
(`confirm_sent_payment_by_txid`) so the flip can be tested without building
a full `TransactionRecord`.

Test would have caught this in CI: with the confirm path stubbed to a
no-op the entry stays `Pending` (test fails); with it wired the entry
flips to `Confirmed` -- the bug's exact symptom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…orm comparison

Planning artifacts produced this session:
- TODO.md — prioritized backlog (P0/P1/P2 bugs, spec track, contract track,
  research, guardrails) consolidating the comparison and the reviews.
- KOTLIN_PLATFORM_COMPARISON.md — deep comparison of our Rust impl vs
  kotlin-platform / dashj / dash-wallet (5 areas, severity-ranked).
- SYNC_CORRECTNESS_SPEC.md — incremental, paginated, skew-safe contact-request
  sync mirroring Android PlatformSyncService (DRAFT).
- CONTACTINFO_FORMAT_SPEC.md — migrate contactInfo privateData CBOR -> DIP-15
  varint, add relationshipState for cross-device ignore (DRAFT).
- BLOCK_SPEC.md — per-sender block/ignore design (DRAFT; being collapsed into
  the single per-sender ignore model tracked in TODO.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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 prior-finding reconciliation against d7bffcf. The create-only contact-request C ABI still omits the consensus-critical entropy field that the Rust SDK's ContactRequestResult now publicly exposes (unanimous across all six agent passes) — this remains a valid in-scope suggestion since this PR is the one that made the entropy a load-bearing part of the result contract. Codex's BlockProcessed.updated confirmation-path concern is dropped: the author's commit message and code comments explicitly document an empirical (UAT-observed) design where the upstream wallet re-emits TransactionDetected for the sender's own tx through mempool→in-block→chain-locked, and Codex offered no key-wallet evidence to overturn that.

Reviewed commit: d7bffcfd (d7bffcfdeb8236118026ba6c369aec18c9bb262a)

🟡 1 suggestion | 💬 1 nitpick

Carried-forward prior findings

  • STILL VALID: Create-only contact-request C ABI drops the consensus-critical entropy (packages/rs-sdk-ffi/src/dashpay/contact_request.rs:181-188). This was re-validated against d7bffcfd; the latest push did not touch this ABI and the Rust result still carries entropy.

New findings in the latest delta

No new blocking/suggestion issue was verified directly in the narrow ae13d13e..d7bffcfd delta. The verifier did retain one additional nitpick from the cumulative current-head review.

2 active finding(s)

suggestion: Create-only contact-request C ABI drops the consensus-critical entropy

packages/rs-sdk-ffi/src/dashpay/contact_request.rs:181-188
Source: claude, codex

packages/rs-sdk/src/platform/dashpay/contact_request.rs:114-120 now publicly exposes ContactRequestResult { id, owner_id, properties, entropy: Bytes32 } because Platform consensus recomputes the document id from the entropy carried in the document-create transition — the doc-comment on entropy explicitly says it must be reused at broadcast or the create transition is rejected with InvalidDocumentTransitionIdError (the exact bug this PR fixes Rust-side). The C-ABI mirror DashSDKContactRequestResult at lines 181–188 still exposes only document_id, owner_id, and properties_json, so a caller using the create-only entry point dash_sdk_dashpay_create_contact_request has no way to retrieve or persist the entropy that bound the returned document_id. A C/Swift caller that builds locally and broadcasts via a separate path will deterministically hit InvalidDocumentTransitionIdError unless it switches to the combined send_contact_request path that keeps create+broadcast in-process. Surface a 32-byte entropy field on the result struct (plain [u8; 32], no allocation, no free required) so the C boundary matches the Rust contract this PR introduced.

/// Result of creating a contact request
#[repr(C)]
pub struct DashSDKContactRequestResult {
    /// Document ID as hex string
    pub document_id: *mut std::os::raw::c_char,
    /// Owner ID (sender ID) as hex string
    pub owner_id: *mut std::os::raw::c_char,
    /// Document properties as JSON string
    pub properties_json: *mut std::os::raw::c_char,
    /// Entropy used to derive `document_id`. Must be reused as the
    /// document-create entropy when broadcasting so that the id
    /// Platform consensus recomputes matches `document_id`; otherwise
    /// the transition is rejected with `InvalidDocumentTransitionIdError`.
    pub entropy: [u8; 32],
}
nitpick: PaymentRestoreEntryFFI lacks the compile-time layout guard sibling FFI structs carry

packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs:339-354
Source: claude

contact_persistence.rs:214-215 pins ContactRequestFFI's layout with const _: [u8; 176] = [0u8; std::mem::size_of::<ContactRequestFFI>()] plus a matching align_of guard so any reshape fails the cargo build before producing a dylib Swift mis-parses at runtime. PaymentRestoreEntryFFI is on the same load-time boundary (Swift hands these rows back to Rust through IdentityRestoreEntryFFI.payments) and carries pointers + u64 + 32-byte id + two u8 discriminants — exactly the kind of layout with non-trivial tail/interior padding the sibling guards protect. Add the equivalent paired size_of / align_of asserts so a future field add or reorder cannot silently desynchronise Swift's view.

Dropped candidate findings
  • Sent payments miss the normal BlockProcessed confirmation path — Author's commit message and the doc-comment at packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:184 document an empirically validated design: the upstream wallet re-emits TransactionDetected for the sender's own transaction through mempool → in-block → chain-locked, and the confirm hook gates on record.is_confirmed() accordingly. The UAT symptom this commit fixes (sent payments never confirmed) was directly verified to clear with the TransactionDetected hook, and Codex provided no key-wallet source evidence that BlockProcessed.updated is the actual/only delivery channel for re-confirmations of previously-known sender txs. Without contradicting evidence from key-wallet's emission code, the documented design stands.

GitHub refused the PR diff API for this large PR during dry-run (PullRequest.diff too_large), so I posted the verified findings in the top-level review body instead of inline.

shumkov and others added 7 commits June 17, 2026 21:48
…profile sync

Extend the sync spec to cover both stages of the Android PlatformSyncService
loop (contact-request fetch fix + contact-profile fetch add) and fold in the
multi-agent review resolutions (§9):

- Cursor advance invariant rewritten — advance only on error-free EXHAUSTED
  pagination, over docs fetched (not applied), never wall-clock, under-shoot-only
  on restore, overlap mandatory. Closes the two critical burying holes.
- Cursor storage = two scalar Option<u64> fields on ManagedIdentity (not a table).
- Stage-2 query resolved from the contract indices: unique ownerId index => empty
  order_by, no pagination, IN_CAP=100, dedup, skip-empty. Q-inc shown unprovable
  as a batch.
- Stage-2 safety: negative cache (no infinite refetch of profile-less contacts),
  per-chunk failure isolation, full-replace cache writes, persist-on-change,
  avatarUrl https-validation.
- Storage = Option B (id-keyed contact_profiles) serving established + pending
  senders (+ future ignored); public-data boundary; full 5-site plumbing called out.
- Driver hook pinned; UI surface corrected (no FriendsView); un-ignore=clear-cursor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (stage 1)

The contact-request fetch used limit:100, start:None re-run every sweep, so with
>=100 requests the newest (or oldest, by $createdAt asc) legitimate requests were
never fetched — a spammer or popular identity buried real requests permanently —
and every sweep re-fetched + re-proved the first page from scratch.

Stage 1 of SYNC_CORRECTNESS_SPEC (mirrors Android PlatformSyncService):

- rs-sdk: fetch_received/sent_contact_requests now take `after_created_at` and
  drain ALL pages via a StartAfter document-id cursor (retrieve-all). Returning
  Ok means pagination exhausted without error; any page error propagates as Err.
  No truncation — a flood can no longer bury anything.
- platform-wallet: a per-identity, per-direction in-memory high-water cursor
  (`high_water_received_ms`/`high_water_sent_ms` on ManagedIdentity). Each sweep
  queries `$createdAt > (high_water - OVERLAP)` and, only when that direction's
  fetch exhausted WITHOUT error, advances the cursor to the max $createdAt
  *fetched* (over docs seen, including ones ingest collapses/skips — the cursor
  records fetch-completeness, not ingest-success), never below its current value.
  A mid-sweep fetch error leaves the cursor intact so the overlap re-fetches.

OVERLAP (10 min) is correctness-load-bearing, not just clock-skew: it re-includes
equal-$createdAt docs straddling a page boundary under the exclusive `>` bound.

The cursor is in-memory: it survives across sweeps (the merge-apply path leaves it
untouched) and resets to None only on cold restore -> one full re-fetch (safe,
since ingest is a fixpoint). Cross-relaunch persistence is a follow-up (the review
confirmed cursor-loss is a non-correctness cost). Pure cursor invariants are unit-
pinned (overlap applied, never-backward, zero-doc no-op); the >100 no-bury /
partial-page-no-advance integration cases need a paginated mock harness and are
deferred to that + devnet UAT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…enders (stage 2)

We synced only our own profile; a contact's displayName/avatar was never fetched,
so the UI had no name or picture for them. Stage 2 of SYNC_CORRECTNESS_SPEC
(mirrors Android updateContactProfiles), in-memory cache:

- New id-keyed `contact_profiles: BTreeMap<Identifier, ContactProfileEntry>` on
  ManagedIdentity (Option B) — relationship-independent so it serves established
  contacts, pending incoming-request senders, and (later) ignored senders from one
  cache. Public-data only; never contactInfo-derived.
- `sync_contact_profiles`: a distinct log-and-continue driver step (after
  sync_profiles, before sync_contact_infos). Targets the FULL set every sweep
  (established ∪ pending senders, minus own identities) so first-run backfill and
  self-heal work. Fetches via batch `$ownerId In [chunk]` (empty order_by on the
  unique ownerId index, IN_CAP=100, deduped, all chunks looped, per-chunk failure
  isolation). Full-replace cache writes (a removed field disappears — not a merge),
  persist-on-change, and a negative cache (profile-less contacts cached as None and
  re-checked at most once per refresh window — no infinite re-query).
- `avatarUrl` is validated (https-only, length-capped) before caching — an
  attacker-controlled http:/file:/javascript:/oversized URL is dropped (SSRF /
  tracking-pixel guard). Android does NO such validation.

Refresh strategy verified against live kotlin-platform/dash-wallet: Android does
NOT do $updatedAt-incremental — it refetches the full set every 15s with blind
writes. Our refresh-window (1h) + content-compare + negative cache is strictly
leaner, so the per-contact $updatedAt path stays deferred (Q-inc).

Pure helpers unit-pinned (avatar validation, refresh-window/negative-cache gating,
full-replace + change detection). The cache is in-memory (cross-relaunch
persistence + the contact-keyed FFI/SwiftData/UI surface are the follow-up); the
In-query integration path is deferred to devnet UAT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New contact requests/profiles/payments now surface within ~15s instead of ~60s,
matching Android's PlatformSyncService. The fetch is incremental (high-water
cursor + overlap) and profiles are throttled by their own refresh window, so the
4x tighter cadence does not 4x the DAPI traffic. Tunable at runtime via
DashPaySyncManager::set_interval.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the multi-agent review of the stage 1/2 implementation:

- Correct a false doc comment: the high-water cursor said "Restored from the
  persister", but no restore exists yet — it is in-memory (survives a session,
  resets on cold restart = one safe full re-fetch). Durable persistence is the
  flagged follow-up.
- All-empty profile parse is now cached as confirmed-absent (None), not a
  cached-present empty profile, so self-heal stays honest (SYNC_CORRECTNESS_SPEC
  §4.7).
- Document the load-bearing IndexMap insertion-order dependency at the pagination
  cursor (a BTreeMap there would silently reorder by doc id and break StartAfter).
- Clarify the advance_high_water match arms; pin that `0` is a real cursor value
  distinct from `None` (advance_high_water(None, Some(0)) / query_lower_bound(Some(0))).

The review verified all 8 cursor/profile spec invariants hold and the production
paths have no unwraps and correct lock discipline. The remaining gap is the
deferred surface (durable persistence + contact-keyed FFI accessor + SwiftData +
UI bind), tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…remaining

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DashPay views already tried to show a contact's name/avatar via
`cachedProfile`, but called `getDashPayProfile(identityId: contactId)` — which
only resolves the wallet's OWN managed identities, so it returned nil for real
contacts (the profile column showed the raw id).

Add a contact-keyed FFI accessor `platform_wallet_get_contact_profile(owner,
contact)` reading the new `contact_profiles` cache (populated by stage-2 sync),
its Swift wrapper `getContactProfile(ownerIdentityId:contactIdentityId:)`, and
point the five `cachedProfile`/profile reads (ContactsView, ContactDetailView,
ContactRequestsView, AddContactView, SendDashPayPaymentSheet) at it — with an
own-profile fallback for a contact that is itself one of our identities (its own
`dashpay_profile` is authoritative; the contact cache skips such ids).

This wires stage 2 end-to-end for display: after a contact-profile sweep, a
contact's name/avatar resolves from the cache. Verified by a clean
`build_ios.sh --target sim` + SwiftExampleApp build (the cbindgen header
regenerates the new FFI fn). Durable persistence + reactive @query are the
remaining follow-up (the cache is in-memory).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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 re-review of 755b485. Two prior findings are STILL VALID and carried forward (DashSDKContactRequestResult drops consensus-critical entropy; PaymentRestoreEntryFFI lacks layout guard). One new blocking issue: the new contact-profile cache is written but no FFI/UI read path exists, so contact name/avatar never reaches the UI through the existing getDashPayProfile path despite that being the sync's stated purpose. Two new suggestions (paginated drain unbounded; established_contact_get_note skips the pre-null that its sibling getter does), and one nitpick on the avatar-URL allowlist for pending senders.

Reviewed commit: 755b485

🔴 1 blocking | 🟡 4 suggestion(s) | 💬 2 nitpick(s)

Verified findings

blocking: Contact-profile sync writes a cache that no reader exposes

packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 691)

sync_contact_profiles stores fetched profiles under the owning identity's managed.contact_profiles[contact_id]. There is no FFI that reads that map: a workspace-wide search for contact_profiles shows only the writer in profile.rs and the loader in identity_ops.rs — no getter. The Swift UI reads contact profiles via wallet.getDashPayProfile(identityId: contact.identityId) (e.g. SendDashPayPaymentSheet.swift:303), which calls platform_wallet_get_dashpay_profile at packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:116-145. That FFI does info.identity_manager.managed_identity(&id).cloned() and then reads managed.dashpay_profile — it only succeeds for wallet-owned identities, and external contacts are explicitly filtered out of the sync's own set. Net effect: the stage-2 sync runs to completion and the cache fills, but the UI keeps seeing identityNotFound / nil for contact profiles and never renders the contact's display name or avatar. The PR's stated goal of so the UI can show their name/avatar is not delivered. Fix is either a new FFI like platform_wallet_get_contact_profile(wallet_handle, owner_id, contact_id, ...) reading from managed.contact_profiles, or a fallback inside the existing getter that walks owners' contact_profiles when the id is not itself a managed identity.

suggestion: [Carried forward — STILL VALID] Create-only contact-request C ABI drops consensus-critical entropy

packages/rs-sdk-ffi/src/dashpay/contact_request.rs (line 181)

Re-validated at head 755b485: DashSDKContactRequestResult still exposes only document_id, owner_id, and properties_json — the 32-byte entropy is dropped at the FFI boundary even though the Rust SDK's ContactRequestResult carries it precisely because Platform recomputes the document id from that entropy on the create transition (the same consensus footgun this PR otherwise closes for the Rust path). The atomic FFI send path handles this internally, but dash_sdk_dashpay_create_contact_request is a create-only entry point: any Swift/FFI caller that creates here and broadcasts later cannot reuse the same entropy, cannot reproduce the id locally, and cannot cross-validate the returned document_id against a later broadcast. Adding the field to the result restores symmetry with the Rust SDK and removes the broadcast-side InvalidDocumentTransitionIdError class of bug for any future deferred-broadcast caller.

/// Result of creating a contact request
#[repr(C)]
pub struct DashSDKContactRequestResult {
    /// Document ID as hex string
    pub document_id: *mut std::os::raw::c_char,
    /// Owner ID (sender ID) as hex string
    pub owner_id: *mut std::os::raw::c_char,
    /// Document properties as JSON string
    pub properties_json: *mut std::os::raw::c_char,
    /// Entropy used to derive `document_id`. Must be reused by any
    /// subsequent broadcast of this document or consensus will reject
    /// the transition with InvalidDocumentTransitionIdError.
    pub entropy: [u8; 32],
}
suggestion: established_contact_get_note skips the pre-null its sibling getter does

packages/rs-platform-wallet-ffi/src/established_contact.rs (line 156)

Sibling getter established_contact_get_alias (lines 103-117) sets *out_alias = std::ptr::null_mut() immediately after check_ptr!, so every early-return path (NotFound, None alias, CString error) leaves the out-pointer at a defined null value — Swift can rely on the contract that the FFI always writes through out_alias before returning. established_contact_get_note skips that initialisation: on the NotFound / no-note / CString-error paths it returns without ever writing *out_note, so the caller observes whatever was there at the call site. A Swift caller mirroring the alias contract (e.g. using an uninitialised var out: UnsafeMutablePointer<CChar>?) will read garbage as a C-string pointer and pass it to platform_wallet_string_free. Mirror the alias pattern so the two getters offer the same guarantee.

#[no_mangle]
pub unsafe extern "C" fn established_contact_get_note(
    contact_handle: Handle,
    out_note: *mut *mut std::os::raw::c_char,
) -> PlatformWalletFFIResult {
    check_ptr!(out_note);
    *out_note = std::ptr::null_mut();

    let option =
        ESTABLISHED_CONTACT_STORAGE.with_item(contact_handle, |contact| contact.note.clone());
    let option = unwrap_option_or_return!(option);
    let note = unwrap_option_or_return!(option);
    let c_str = unwrap_result_or_return!(std::ffi::CString::new(note));
    unsafe { *out_note = c_str.into_raw() };
    PlatformWalletFFIResult::ok()
}
suggestion: Same-$createdAt pagination boundary depends on undocumented drive cursor behavior

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 80)

The retrieve-all loop pages by Start::StartAfter(last_document_id) over a query ordered only by $createdAt. If more than 100 contact requests for one sender/recipient share an identical $createdAt, whether the next page picks up the remaining same-timestamp docs depends on whether drive's secondary-index cursor resolves StartAfter(doc_id) by full index position (correct — picks up the remaining bucket) or by the document's indexed $createdAt value (incorrect — skips the rest of the bucket). The 10-minute high-water overlap window does not recover from the latter case because every retry re-reads the same first 100 same-timestamp rows. The doc comment already notes pagination relies on Documents being insertion-ordered, so the author is aware of order-sensitivity at this layer; please confirm with a test that crafts >100 same-$createdAt documents for one recipient, or add a stable tie-breaker (e.g. ($createdAt, $id) ASC).

suggestion: Unbounded retrieve-all drain on first sweep / cold start

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 42)

fetch_contact_requests_paginated drains every matching document with no per-call page cap, total-document cap, or timeout. The intentional retrieve-all is the right call for correctness (it prevents bury-the-newest truncation), but the cost model is asymmetric on first sync, post-restore, or any sweep where after_created_at is None / very old: a hostile sender can mint N×100 cheap contactRequest documents targeting one victim identity's toUserId, and the victim's next cold start fetches and holds the full set in memory before any newest-first ingest collapsing runs. After warmup the high-water clamps the cost, so the exposure is bounded to the cold-start window — a configurable hard page cap that returns the partial set with a truncated flag (so the caller knows not to advance the cursor) would close that window without sacrificing the bury-the-newest fix.

nitpick: Pending-sender profiles are cached and made available to the UI before acceptance

packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 636)

sync_contact_profiles includes managed.incoming_contact_requests.keys() in the fetch targets, so an unaccepted sender's avatarUrl lands in the persistent cache the moment a request is fetched. The avatar URL validator at line 568-570 correctly rules out non-https/oversized URLs, but any https:// URL ≤2048 bytes is accepted — i.e. an attacker-controlled tracking-pixel host. If a future contact-profile read path (see the blocking finding) drives a SwiftUI AsyncImage for pending-request rows, the sender learns the recipient is online + their IP just by sending an unsolicited request. Defense-in-depth options: (a) only cache avatar_url for established contacts, dropping it for incoming_contact_requests-only entries, or (b) document at the new read-side FFI that the UI MUST gate remote image loading behind explicit acceptance. Not strictly a defect at this layer, but the policy boundary currently sits in the UI and the cache silently enables the abuse if the UI gets it wrong.

nitpick: [Carried forward — STILL VALID] PaymentRestoreEntryFFI lacks the compile-time layout guard sibling FFI structs carry

packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs (line 339)

Re-validated at head 755b485: PaymentRestoreEntryFFI is #[repr(C)] but still has no const _: [u8; N] = [0u8; std::mem::size_of::<PaymentRestoreEntryFFI>()] / align_of tripwire, while sibling FFI structs use this idiom precisely so that an unintended reshape on the Rust side fails the build before a dylib that Swift would mis-parse can ship — contact_persistence.rs:214-219 (ContactRequestFFI, ContactRequestRemovalFFI), contact_persistence.rs:231-232 (ContactRequestRejectionFFI), identity_persistence.rs:268-308 (IdentityKeyEntryFFI, IdentityEntryFFI), wallet_registration_persistence.rs:84-85 (AccountAddressPoolFFI). Since PaymentRestoreEntryFFI is consumed by Swift via IdentityRestoreEntryFFI.payments, a future field add or alignment shift would silently desynchronise the Swift mirror at load time. Adding the same guards (and to the new sibling UtxoRestoreEntryFFI / UnresolvedAssetLockTxRecordFFI / IdentityKeyRestoreFFI) closes the drift window.

_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3841 755b485 --dry-run --prior-sha d7bffcf
STDOUT:

STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", li), so I posted the same verified findings as a top-level review body._

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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 re-review at 84769f3. The new delta (2 commits) wires the contact-profile cache through a new platform_wallet_get_contact_profile FFI plus Swift getContactProfile and UI plumbing — this directly resolves the prior blocking finding about an orphaned writer with no reader. Of the six remaining prior findings: five (entropy on create-only FFI, get_note pre-null asymmetry, same-$createdAt cursor reliance, unbounded retrieve-all drain, PaymentRestoreEntryFFI layout guard) are STILL VALID and carried forward; one (pending-sender profile caching) is INTENTIONALLY DEFERRED — the maintainer documented it as matching Android behavior and explicitly extended it to the new FFI/UI. New in this delta: a privacy concern around AsyncImage fetching attacker-controlled avatar URLs for unaccepted senders, a minor out-pointer pre-null gap shared by the new profile-reader trio, and one speculative gap in the accept-adopt branch that does not promote established_contacts locally — verified the code gap but the trigger conditions appear narrow given auto-establishment in the ingest paths, so downgraded to suggestion. No in-scope blocking issues; action is COMMENT.

Reviewed commit: 84769f3

🟡 6 suggestion(s) | 💬 2 nitpick(s)

Verified findings

suggestion: [Carried forward] Create-only contact-request C ABI drops consensus-critical entropy

packages/rs-sdk-ffi/src/dashpay/contact_request.rs (line 181)

STILL VALID at 84769f3. DashSDKContactRequestResult still exposes only document_id, owner_id, and properties_json. The Rust SDK's ContactRequestResult carries a 32-byte entropy field — added by this PR precisely because Platform recomputes the document id from (contract_id, owner_id, document_type_name, entropy) and rejects mismatches with InvalidDocumentTransitionIdError (the exact consensus bug Milestone 1 sets out to fix). Any non-Swift FFI consumer that uses dash_sdk_dashpay_create_contact_request to build the document locally and broadcasts later through a separate path (the natural shape for cold-signer / hardware-wallet flows) cannot reproduce the same id at broadcast time, silently re-introducing the bug this PR exists to fix. The in-process send_contact_request path bundles create+broadcast atomically and is unaffected; the create-only export is the footgun.

/// Result of creating a contact request
#[repr(C)]
pub struct DashSDKContactRequestResult {
    /// Document ID as hex string
    pub document_id: *mut std::os::raw::c_char,
    /// Owner ID (sender ID) as hex string
    pub owner_id: *mut std::os::raw::c_char,
    /// Document properties as JSON string
    pub properties_json: *mut std::os::raw::c_char,
    /// 32-byte creation entropy. Drive recomputes the document id from
    /// (contract_id, owner_id, document_type_name, entropy); the caller
    /// MUST reuse this exact entropy when later broadcasting the document
    /// or the transition is rejected with InvalidDocumentTransitionIdError.
    pub entropy: [u8; 32],
}
suggestion: [Carried forward] established_contact_get_note skips the pre-null its sibling getter performs

packages/rs-platform-wallet-ffi/src/established_contact.rs (line 156)

STILL VALID at 84769f3. Sibling established_contact_get_alias (lines 107–108) writes *out_alias = std::ptr::null_mut(); immediately after check_ptr!, so every early-return path (NotFound, None value, CString interior NUL) leaves the out pointer defined as null. established_contact_get_note (lines 156–169) skips this — on the same early-return paths, *out_note is never written and a C caller that does not pre-zero the slot reads whichever stale or uninitialized pointer value the caller had there, with the risk of double-freeing through platform_wallet_string_free. Swift's wrapper happens to default-init the slot, but the FFI contract should not depend on caller hygiene when the sibling already establishes the safer pattern.

#[no_mangle]
pub unsafe extern "C" fn established_contact_get_note(
    contact_handle: Handle,
    out_note: *mut *mut std::os::raw::c_char,
) -> PlatformWalletFFIResult {
    check_ptr!(out_note);
    *out_note = std::ptr::null_mut();

    let option =
        ESTABLISHED_CONTACT_STORAGE.with_item(contact_handle, |contact| contact.note.clone());
    let option = unwrap_option_or_return!(option);
    let note = unwrap_option_or_return!(option);
    let c_str = unwrap_result_or_return!(std::ffi::CString::new(note));
    unsafe { *out_note = c_str.into_raw() };
    PlatformWalletFFIResult::ok()
}
suggestion: [Carried forward] Same-$createdAt pagination boundary relies on undocumented Drive cursor secondary order

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 66)

STILL VALID at 84769f3. The helper now adds an explicit OrderClause { field: "$createdAt", ascending: true } and a comment explaining it pins the contract's (field, $createdAt) index. But the pagination still uses only Start::StartAfter(last_document_id) over a query ordered solely by $createdAt, where the cursor seed is Documents.keys().last(). When many documents share the same $createdAt second/ms (e.g. a burst from one sender in a single block, or backfill from a mobile client that quantises timestamps), correctness depends on Drive's cursor honouring an implicit (ordered-index-key, document-id) tie-break — which is not part of the documented API surface and is not pinned by any regression test in this PR. Either add an explicit secondary OrderClause on $id or add a deterministic pin test covering >page_size same-$createdAt rows so silent skip/repeat at the page boundary is detectable.

suggestion: [Carried forward] First-sync / cold-start retrieve-all has no per-call or total document budget

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 42)

STILL VALID at 84769f3. fetch_contact_requests_paginated drains every matching document with a fixed CONTACT_REQUEST_PAGE_SIZE = 100 per page but no per-call total cap, no elapsed-time budget, and no caller-visible continuation. On cold start, restore-from-seed, or any after_created_at == None path, a long-lived identity with thousands of historical contact requests forces a full O(N/page_size) authenticated, proof-verified round-trip storm on every wallet open. Combined with the recurring 15s DashPaySyncManager tick, a single oversized identity (mobile-side spam from an attacker that varies accountReference, or a misbehaving counterparty re-broadcasting) can monopolise the sync loop and starve every other identity in the wallet. Add a total-document ceiling per sweep with a resumable cursor handed back to the caller, or at minimum a logged warn + early break above some reasonable threshold so a runaway identity is observable.

suggestion: [New] Accept-adopt branch only registers receiving account when sent_exists — never promotes to established

packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (line 1148)

When already_reciprocated is true because sent_contact_requests.contains_key(&sender_id) (but established_contacts does not contain it), the adopt branch only calls register_contact_account for the receiving account and accept_register_external_validated for the external account — neither writes into managed.established_contacts. Step 5 (lines 1201–1214) then looks up established_contacts.get(&sender_id) and returns ContactRequestNotFound, so the accept call fails even though both request documents exist locally. The normal ingest paths (add_sent_contact_request / add_incoming_contact_request) auto-establish atomically when their counterpart is already present, so reaching the sent_exists && !established state requires a non-auto-establishing insertion (e.g. apply_changeset rehydrating a persisted state that staged sent+incoming separately, or a race between sync and the user's accept). Downgraded from blocking since the trigger requires that narrow state, but the fix is trivial — either drive the adopt branch through apply_established_contact for the pair, or guard step 5 so the registered accounts are not orphaned by a misleading ContactRequestNotFound.

suggestion: [New] Pending incoming requests render attacker-controlled avatar URLs (tracking/IP-disclosure surface)

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift (line 119)

IncomingRequestRow is now passed cachedProfile(row.contactIdentityId)?.avatarUrl, which DashPayAvatarView feeds into AsyncImage. Because sync_contact_profiles intentionally fetches profiles for managed.incoming_contact_requests.keys() and the new platform_wallet_get_contact_profile FFI exposes that cache, any unsolicited identity that publishes a dashpay.profile with an avatarUrl can cause the victim's app to issue a network request to an attacker-controlled host the moment the Requests view renders, before the victim has chosen to accept the contact. The Rust filter on is_valid_avatar_url only checks https:// prefix + length and does not prevent tracking, IP disclosure, or fingerprinting. The pending-profile sync itself is documented as intentional (mirrors Android), but rendering remote images for unaccepted senders is the part that introduces the network side-channel. Suppress remote avatars in the pending-request UI (initials only) until the contact is accepted, or gate fetch on user action.

nitpick: [New] Profile-reader trio leaves out-params undefined on early-return paths

packages/rs-platform-wallet-ffi/src/dashpay_profile.rs (line 90)

All three profile readers — managed_identity_get_dashpay_profile (lines 90–112), platform_wallet_get_dashpay_profile (lines 116–145), and the new platform_wallet_get_contact_profile (lines 153–185) — only write *out_profile/*out_has_profile on the match arms inside the function body and skip pre-initialization after check_ptr!. On the early-return paths taken by unwrap_option_or_return! (handle not in storage → NotFound) and unwrap_result_or_return! (invalid identifier bytes → InvalidParameter), both out-pointers remain indeterminate. Other readers in this crate pre-write the out values immediately after check_ptr! (see established_contact_get_alias and the dashpay_sync readers); aligning with that pattern protects C callers that do not pre-init and read the slot regardless of the result code (mirrors finding above for established_contact_get_note).

nitpick: [Carried forward] PaymentRestoreEntryFFI lacks the compile-time layout guard sibling FFI structs carry

packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs (line 339)

STILL VALID at 84769f3. PaymentRestoreEntryFFI is #[repr(C)] and crosses the Swift→Rust restore-callback boundary, but unlike sibling FFI structs (ContactRequestFFI/ContactRequestRejectionFFI in contact_persistence.rs, IdentityKeyEntryFFI/IdentityEntryFFI in identity_persistence.rs, AccountAddressPoolFFI in wallet_registration_persistence.rs) it has no const _: [u8; N] = [0u8; std::mem::size_of::<T>()]; tripwire. An accidental field reorder or type widen here would compile silently and shift offsets on the Swift side, corrupting loaded payment history (mis-typed amounts/directions surface as real-money UI). Drop in the same const-assert idiom as siblings so the build fails on unintended ABI reshape.

_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3841 84769f3 --dry-run --prior-sha 755b485
STDOUT:

STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", li), so I posted the same verified findings as a top-level review body._

shumkov and others added 2 commits June 18, 2026 07:47
…ayer)

Carry the contact-profile cache through the identity changeset so it survives
cross-device merge / replay and (with the FFI layer) cold restart:

- IdentityEntry gains `contact_profiles`; `from_managed` snapshots it; merge and
  apply use last-write-wins per contact id (same policy as dashpay_payments).
- sync_contact_profiles now emits one changeset per owner, only when a profile
  actually changed (persist-on-change keeps the refetch-all-each-sweep first cut a
  persistence fixpoint).

Round-trip test pins that a cached contact profile survives snapshot→apply and a
later update (removed avatar) overwrites it full-replace. The FFI store/restore +
SwiftData model that carry this to the host store land next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ning

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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 re-review at c071782. All 8 prior findings re-verified STILL VALID and carried forward; the latest 84769f3..c071782 delta (contact_profiles persistence wire-through) introduces one new in-scope suggestion: the contact-profile negative-cache timestamp is not persisted when the profile is unchanged, so the durable backoff resets every restart. No blocking issues.

🟡 7 suggestion(s) | 💬 2 nitpick(s)

9 additional finding(s)

suggestion: [Carried forward — STILL VALID] Create-only contact-request C ABI drops consensus-critical entropy

packages/rs-sdk-ffi/src/dashpay/contact_request.rs (line 181)

Re-verified at c071782. DashSDKContactRequestResult exposes only document_id, owner_id, and properties_json; the Rust SDK's ContactRequestResult carries a 32-byte entropy field because Drive recomputes the document id from (contract_id, owner_id, document_type_name, entropy) and rejects mismatches with InvalidDocumentTransitionIdError — the exact bug this PR fixes Rust-side. The in-process atomic send_contact_request path is unaffected, but dash_sdk_dashpay_create_contact_request is a create-only export: any non-Swift C/FFI caller that creates here and broadcasts via a separate path (cold-signer / hardware-wallet flows) cannot reproduce the matching id at broadcast time, silently re-introducing the bug across the FFI boundary. Surface a 32-byte entropy field on the result struct so the C contract matches the Rust contract.

/// Result of creating a contact request
#[repr(C)]
pub struct DashSDKContactRequestResult {
    /// Document ID as hex string
    pub document_id: *mut std::os::raw::c_char,
    /// Owner ID (sender ID) as hex string
    pub owner_id: *mut std::os::raw::c_char,
    /// Document properties as JSON string
    pub properties_json: *mut std::os::raw::c_char,
    /// 32-byte creation entropy. Drive recomputes the document id from
    /// (contract_id, owner_id, document_type_name, entropy); the caller
    /// MUST reuse this exact entropy when later broadcasting the document
    /// or the transition is rejected with InvalidDocumentTransitionIdError.
    pub entropy: [u8; 32],
}
suggestion: [Carried forward — STILL VALID] established_contact_get_note skips the pre-null its sibling getter performs

packages/rs-platform-wallet-ffi/src/established_contact.rs (line 156)

Re-verified at c071782. Sibling established_contact_get_alias (lines 107-108) writes *out_alias = std::ptr::null_mut(); immediately after check_ptr!, so every early-return path (NotFound, None value, CString interior NUL) leaves the out pointer at defined null. established_contact_get_note (lines 156-169) skips this — on the same early-return paths, *out_note is never written, and a C caller that does not pre-zero the slot reads whichever stale pointer value was there, risking double-free / invalid-free through platform_wallet_string_free. Mirror the sibling pattern; the exported C ABI should not depend on caller hygiene.

#[no_mangle]
pub unsafe extern "C" fn established_contact_get_note(
    contact_handle: Handle,
    out_note: *mut *mut std::os::raw::c_char,
) -> PlatformWalletFFIResult {
    check_ptr!(out_note);
    *out_note = std::ptr::null_mut();

    let option =
        ESTABLISHED_CONTACT_STORAGE.with_item(contact_handle, |contact| contact.note.clone());
    let option = unwrap_option_or_return!(option);
    let note = unwrap_option_or_return!(option);
    let c_str = unwrap_result_or_return!(std::ffi::CString::new(note));
    unsafe { *out_note = c_str.into_raw() };
    PlatformWalletFFIResult::ok()
}
suggestion: [Carried forward + sharpened by delta — STILL VALID] Pending incoming requests render attacker-controlled avatar URLs, now persisted across restarts

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift (line 119)

Re-verified at c071782 and sharpened by the latest delta: contact_profiles is now persisted through the changeset (apply + per-owner persister.store in sync_contact_profiles), so attacker-controlled avatar URLs durably survive relaunch. sync_contact_profiles fetches profiles for managed.incoming_contact_requests.keys() (unaccepted senders); IncomingRequestRow feeds cachedProfile(row.contactIdentityId)?.avatarUrl into DashPayAvatarView → SwiftUI AsyncImage. Any party that publishes a dashpay.profile and sends an unsolicited contact request causes the victim's wallet to issue a network request to an attacker-controlled host the moment the Requests view renders, before consent — disclosing IP, online status, and TLS/UA fingerprint. The Rust-side is_valid_avatar_url only checks scheme + length. Mirroring Android does not make the side-channel safe; suppress remote avatars for unaccepted senders (initials only) until acceptance.

suggestion: [Carried forward — STILL VALID] First-sync/cold-start retrieve-all has no per-call or total document budget

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 42)

Re-verified at c071782. fetch_contact_requests_paginated drains every matching document with CONTACT_REQUEST_PAGE_SIZE = 100 per page but no per-call total cap, no elapsed-time budget, and no caller-visible continuation. On cold start, restore-from-seed, or any after_created_at == None path, a long-lived identity with thousands of historical contact requests forces an O(N/100) authenticated, proof-verified round-trip storm on every wallet open. Combined with the recurring 15s DashPaySyncManager tick, one oversized identity (organic accumulation or attacker-spammed contactRequest documents varying accountReference) can monopolize the sync loop and starve every other identity in the wallet. Add a configurable total-document ceiling per sweep with a resumable cursor, or at minimum a logged warn + early break above a threshold so a runaway identity is observable.

suggestion: [Carried forward — STILL VALID] Same-$createdAt pagination boundary relies on undocumented Drive cursor secondary order

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 66)

Re-verified at c071782. The query orders only by $createdAt ascending and paginates with Start::StartAfter(last_document_id) where last_document_id = page.keys().last().copied(). When more than CONTACT_REQUEST_PAGE_SIZE (100) documents share the same $createdAt (sender bursts in one block, second-quantized mobile clients, attacker batches), correctness depends on Drive's cursor honoring an implicit (createdAt, document_id) tie-break that is not part of the documented public contract and is not pinned by any regression test in this PR. Silent skip-or-repeat at the boundary surfaces as contact requests permanently invisible to the victim's wallet. Add an explicit secondary OrderClause on $id, or add a >page_size same-$createdAt regression test.

suggestion: [NEW — delta] Contact-profile refresh timestamps are not durably persisted when the profile is unchanged

packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 588)

New in the 84769f3..c071782 delta. apply_fetched_profile (lines 588-603) updates checked_at_ms on every fetch but returns changed only when the stored profile field differs; sync_contact_profiles (lines 717-725) calls persister.store only when any_changed is true. An hourly re-check that confirms the same present profile, or the same confirmed-absent profile (the negative-cache case), refreshes checked_at_ms in memory only. After cold start the persisted checked_at_ms is stale, so should_fetch_profile immediately re-issues an In query for every unchanged contact — the negative-cache backoff effectively does not survive restart. Additionally, once a sweep observes Platform matching the in-memory cache, a failed earlier store is not retried because subsequent sweeps see changed = false and skip persistence. Persist timestamp-only refreshes (or return a separate needs_persist signal from apply_fetched_profile) so the durable backoff matches the in-memory cache.

suggestion: [Carried forward — STILL VALID] Accept-adopt branch can leave a reciprocal request unestablished

packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (line 1148)

Re-verified at c071782. When already_reciprocated is true because sent_contact_requests contains sender_id but established_contacts does not, the adopt branch (lines 1148-1166) calls register_contact_account for the receiving account, then accept_register_external_validated runs at lines 1181-1198. Neither path inserts into managed.established_contacts. Step 5 (lines 1200-1214) then looks up established_contacts.get(&sender_id) and returns ContactRequestNotFound, so the accept call fails even though both reciprocal documents exist locally. The common ingest paths auto-establish when both sides land atomically, so the trigger is narrow — persisted-and-rehydrated sent+incoming separately, or a sync/accept race — but the failure mode is sticky because the unique index forbids re-broadcast. Either drive the adopt branch through apply_established_contact for the pair, or make step 5 account for the sent_exists adoption case so the registered accounts are not orphaned by a misleading ContactRequestNotFound.

nitpick: [Carried forward — STILL VALID] PaymentRestoreEntryFFI lacks the compile-time layout guard sibling FFI structs carry

packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs (line 339)

Re-verified at c071782. PaymentRestoreEntryFFI is #[repr(C)] and crosses the Swift→Rust restore-callback boundary as IdentityRestoreEntryFFI.payments, but unlike sibling FFI structs (ContactRequestFFI/ContactRequestRejectionFFI in contact_persistence.rs, IdentityKeyEntryFFI/IdentityEntryFFI in identity_persistence.rs, AccountAddressPoolFFI in wallet_registration_persistence.rs) it has no const _: [u8; N] = [0u8; std::mem::size_of::<T>()]; tripwire and no align_of guard. An accidental field reorder or type widen would compile silently and shift offsets on the Swift side, corrupting loaded payment history (mis-typed amounts / directions / addresses surface as real-money UI). Add the same const-assert idiom as siblings.

nitpick: [Carried forward — STILL VALID] Profile-reader trio leaves out-params undefined on early-return paths

packages/rs-platform-wallet-ffi/src/dashpay_profile.rs (line 90)

Re-verified at c071782. All three profile readers — managed_identity_get_dashpay_profile (lines 90-112), platform_wallet_get_dashpay_profile (lines 116-145), and platform_wallet_get_contact_profile (lines 153-185) — only write *out_profile / *out_has_profile on the success/no-profile match arms inside the function body. After check_ptr!, the early-return paths taken by unwrap_option_or_return! (handle not in storage → NotFound) and unwrap_result_or_return! (invalid identifier bytes → InvalidParameter) leave both out pointers indeterminate. Sibling readers (established_contact_get_alias, the dashpay_sync readers) pre-write out values right after check_ptr!. A C caller that reads *out_profile regardless of result code may interpret garbage as valid pointer fields and later pass them to dashpay_profile_ffi_free, freeing arbitrary pointers.

🤖 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.

- [SUGGESTION] In `packages/rs-sdk-ffi/src/dashpay/contact_request.rs`:181-188: [Carried forward — STILL VALID] Create-only contact-request C ABI drops consensus-critical entropy
  Re-verified at c07178248567. `DashSDKContactRequestResult` exposes only `document_id`, `owner_id`, and `properties_json`; the Rust SDK's `ContactRequestResult` carries a 32-byte `entropy` field because Drive recomputes the document id from `(contract_id, owner_id, document_type_name, entropy)` and rejects mismatches with `InvalidDocumentTransitionIdError` — the exact bug this PR fixes Rust-side. The in-process atomic `send_contact_request` path is unaffected, but `dash_sdk_dashpay_create_contact_request` is a create-only export: any non-Swift C/FFI caller that creates here and broadcasts via a separate path (cold-signer / hardware-wallet flows) cannot reproduce the matching id at broadcast time, silently re-introducing the bug across the FFI boundary. Surface a 32-byte `entropy` field on the result struct so the C contract matches the Rust contract.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:156-169: [Carried forward — STILL VALID] established_contact_get_note skips the pre-null its sibling getter performs
  Re-verified at c07178248567. Sibling `established_contact_get_alias` (lines 107-108) writes `*out_alias = std::ptr::null_mut();` immediately after `check_ptr!`, so every early-return path (NotFound, `None` value, CString interior NUL) leaves the out pointer at defined null. `established_contact_get_note` (lines 156-169) skips this — on the same early-return paths, `*out_note` is never written, and a C caller that does not pre-zero the slot reads whichever stale pointer value was there, risking double-free / invalid-free through `platform_wallet_string_free`. Mirror the sibling pattern; the exported C ABI should not depend on caller hygiene.
- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift`:119-129: [Carried forward + sharpened by delta — STILL VALID] Pending incoming requests render attacker-controlled avatar URLs, now persisted across restarts
  Re-verified at c07178248567 and sharpened by the latest delta: `contact_profiles` is now persisted through the changeset (apply + per-owner `persister.store` in `sync_contact_profiles`), so attacker-controlled avatar URLs durably survive relaunch. `sync_contact_profiles` fetches profiles for `managed.incoming_contact_requests.keys()` (unaccepted senders); `IncomingRequestRow` feeds `cachedProfile(row.contactIdentityId)?.avatarUrl` into `DashPayAvatarView` → SwiftUI `AsyncImage`. Any party that publishes a `dashpay.profile` and sends an unsolicited contact request causes the victim's wallet to issue a network request to an attacker-controlled host the moment the Requests view renders, before consent — disclosing IP, online status, and TLS/UA fingerprint. The Rust-side `is_valid_avatar_url` only checks scheme + length. Mirroring Android does not make the side-channel safe; suppress remote avatars for unaccepted senders (initials only) until acceptance.
- [SUGGESTION] In `packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs`:42-112: [Carried forward — STILL VALID] First-sync/cold-start retrieve-all has no per-call or total document budget
  Re-verified at c07178248567. `fetch_contact_requests_paginated` drains every matching document with `CONTACT_REQUEST_PAGE_SIZE = 100` per page but no per-call total cap, no elapsed-time budget, and no caller-visible continuation. On cold start, restore-from-seed, or any `after_created_at == None` path, a long-lived identity with thousands of historical contact requests forces an O(N/100) authenticated, proof-verified round-trip storm on every wallet open. Combined with the recurring 15s `DashPaySyncManager` tick, one oversized identity (organic accumulation or attacker-spammed `contactRequest` documents varying `accountReference`) can monopolize the sync loop and starve every other identity in the wallet. Add a configurable total-document ceiling per sweep with a resumable cursor, or at minimum a logged warn + early break above a threshold so a runaway identity is observable.
- [SUGGESTION] In `packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs`:66-109: [Carried forward — STILL VALID] Same-$createdAt pagination boundary relies on undocumented Drive cursor secondary order
  Re-verified at c07178248567. The query orders only by `$createdAt` ascending and paginates with `Start::StartAfter(last_document_id)` where `last_document_id = page.keys().last().copied()`. When more than `CONTACT_REQUEST_PAGE_SIZE` (100) documents share the same `$createdAt` (sender bursts in one block, second-quantized mobile clients, attacker batches), correctness depends on Drive's cursor honoring an implicit `(createdAt, document_id)` tie-break that is not part of the documented public contract and is not pinned by any regression test in this PR. Silent skip-or-repeat at the boundary surfaces as contact requests permanently invisible to the victim's wallet. Add an explicit secondary `OrderClause` on `$id`, or add a >page_size same-`$createdAt` regression test.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs`:588-725: [NEW — delta] Contact-profile refresh timestamps are not durably persisted when the profile is unchanged
  New in the 84769f36..c0717824 delta. `apply_fetched_profile` (lines 588-603) updates `checked_at_ms` on every fetch but returns `changed` only when the stored `profile` field differs; `sync_contact_profiles` (lines 717-725) calls `persister.store` only when `any_changed` is true. An hourly re-check that confirms the same present profile, or the same confirmed-absent profile (the negative-cache case), refreshes `checked_at_ms` in memory only. After cold start the persisted `checked_at_ms` is stale, so `should_fetch_profile` immediately re-issues an `In` query for every unchanged contact — the negative-cache backoff effectively does not survive restart. Additionally, once a sweep observes Platform matching the in-memory cache, a failed earlier store is not retried because subsequent sweeps see `changed = false` and skip persistence. Persist timestamp-only refreshes (or return a separate `needs_persist` signal from `apply_fetched_profile`) so the durable backoff matches the in-memory cache.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:1148-1214: [Carried forward — STILL VALID] Accept-adopt branch can leave a reciprocal request unestablished
  Re-verified at c07178248567. When `already_reciprocated` is true because `sent_contact_requests` contains `sender_id` but `established_contacts` does not, the adopt branch (lines 1148-1166) calls `register_contact_account` for the receiving account, then `accept_register_external_validated` runs at lines 1181-1198. Neither path inserts into `managed.established_contacts`. Step 5 (lines 1200-1214) then looks up `established_contacts.get(&sender_id)` and returns `ContactRequestNotFound`, so the accept call fails even though both reciprocal documents exist locally. The common ingest paths auto-establish when both sides land atomically, so the trigger is narrow — persisted-and-rehydrated sent+incoming separately, or a sync/accept race — but the failure mode is sticky because the unique index forbids re-broadcast. Either drive the adopt branch through `apply_established_contact` for the pair, or make step 5 account for the `sent_exists` adoption case so the registered accounts are not orphaned by a misleading `ContactRequestNotFound`.
- [NITPICK] In `packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs`:339-354: [Carried forward — STILL VALID] PaymentRestoreEntryFFI lacks the compile-time layout guard sibling FFI structs carry
  Re-verified at c07178248567. `PaymentRestoreEntryFFI` is `#[repr(C)]` and crosses the Swift→Rust restore-callback boundary as `IdentityRestoreEntryFFI.payments`, but unlike sibling FFI structs (`ContactRequestFFI`/`ContactRequestRejectionFFI` in contact_persistence.rs, `IdentityKeyEntryFFI`/`IdentityEntryFFI` in identity_persistence.rs, `AccountAddressPoolFFI` in wallet_registration_persistence.rs) it has no `const _: [u8; N] = [0u8; std::mem::size_of::<T>()];` tripwire and no `align_of` guard. An accidental field reorder or type widen would compile silently and shift offsets on the Swift side, corrupting loaded payment history (mis-typed amounts / directions / addresses surface as real-money UI). Add the same const-assert idiom as siblings.
- [NITPICK] In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:90-185: [Carried forward — STILL VALID] Profile-reader trio leaves out-params undefined on early-return paths
  Re-verified at c07178248567. All three profile readers — `managed_identity_get_dashpay_profile` (lines 90-112), `platform_wallet_get_dashpay_profile` (lines 116-145), and `platform_wallet_get_contact_profile` (lines 153-185) — only write `*out_profile` / `*out_has_profile` on the success/no-profile match arms inside the function body. After `check_ptr!`, the early-return paths taken by `unwrap_option_or_return!` (handle not in storage → NotFound) and `unwrap_result_or_return!` (invalid identifier bytes → InvalidParameter) leave both out pointers indeterminate. Sibling readers (`established_contact_get_alias`, the dashpay_sync readers) pre-write out values right after `check_ptr!`. A C caller that reads `*out_profile` regardless of result code may interpret garbage as valid pointer fields and later pass them to `dashpay_profile_ffi_free`, freeing arbitrary pointers.

Inline dry-run could not load the GitHub PR diff because this PR exceeds GitHub's 20,000-line diff limit, so I posted the same verified findings as a top-level review body.

shumkov and others added 3 commits June 18, 2026 11:34
…Data

Carry the contact_profiles cache to the iOS SwiftData store and restore it on
cold start, so contact names/avatars survive relaunch instead of repopulating
~15s later. Completes the durable-persistence half of SYNC_CORRECTNESS_SPEC §4.5.

Store (Rust->Swift): a `ContactProfileRowFFI` array on `IdentityEntryFFI`
(present profiles only — the negative cache rebuilds harmlessly), allocated/freed
on the Rust side mirroring the DPNS label-array pattern. Restore (Swift->Rust):
`ContactProfileRestoreEntryFFI` on `IdentityRestoreEntryFFI` + `restore_contact_profiles`
mirroring `restore_dashpay_payments`; Swift owns + frees the array (deferred to the
Rust load-free callback). New SwiftData `PersistentDashpayContactProfile`
(owner+contact keyed) + handler store/restore; avatar_url re-validated (https) on
restore.

Implemented via the rust-master-engineer agent (the mandated swift-rust-ffi-engineer
is not registered in this environment) and reviewed by a memory-safety audit of the
bidirectional unsafe marshaling: no double-free / leak / UAF / OOB found — every
CString into_raw/from_raw balanced, Box length invariant held, Swift release
correctly deferred, non-UTF8 decode safe, size-guard 208->224 correct. Added a
contact-id length guard on restore (drop wrong-length rows up front rather than
zero-pad to a wrong key), matching the UTXO restore convention.

Verified: cargo test -p platform-wallet-ffi (110 passed incl. new
restore_contact_profiles_fold_rebuilds_cache) + build_ios.sh --target sim BUILD
SUCCEEDED (regenerated cbindgen header + SwiftExampleApp build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt migration

Before implementing "Spec 1" (migrate contactInfo.privateData CBOR -> DIP-15
varint + version/acceptedAccounts), the review step checked the premise against
the deployed schema — and it's wrong.

packages/dashpay-contract/schema/v1/dashpay.schema.json defines
contactInfo.privateData as a byteArray (48..2048) described as "aliasName + note
+ displayHidden encoded as an array in cbor" — CBOR, three fields, NO
version/acceptedAccounts. Clients validate against the registered contract, not
DIP-15 prose, so CBOR is correct and the current crypto/contact_info.rs codec
needs no change. Migrating to varint would diverge from the contract and require
a coordinated contract update for no benefit.

- CONTACTINFO_FORMAT_SPEC.md: marked SCRAPPED with the resolution; rejected draft
  kept for history.
- TODO: Spec 1 resolved (keep CBOR); research/01-vs-07 reconcile done; Spec 2
  (Ignore) corrected to ride the existing CBOR array (reuse displayHidden or a 4th
  element via the forward-compat seam) rather than a format migration.

This is the research->review->code pipeline catching an interop-breaking change
before any code was written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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 re-review at c1f3aa5. The latest delta (87d6cc7 + c1f3aa5) wires durable contact-profile persistence/restore through two new FFI structs and a SwiftData model. NEW finding: the persist path skips confirmed-absent profiles and the Swift upsert is explicitly append/refresh-only, so once a contact's profile goes from present to absent the SwiftData row survives and the cold-start restore re-inflates the cache as Some(stale_profile). All 9 prior findings re-validated as STILL VALID and carried forward (the avatar-URL leakage and FFI layout-guard items are sharpened by the new persistence/restore plumbing). Dropped the codex-ffi-engineer 'blocking' ABI break claim as a false positive — this SDK regenerates its C header in lockstep, no external consumer is compiled against an older IdentityRestoreEntryFFI.

Reviewed commit: c1f3aa5

🟡 8 suggestion(s) | 💬 2 nitpick(s)

Verified findings

suggestion: [NEW — latest delta] Confirmed-absent contact profiles never delete their SwiftData row; stale display name / avatar resurrects on cold start

packages/rs-platform-wallet-ffi/src/identity_persistence.rs (line 577)

The persist path allocate_contact_profile_rows (identity_persistence.rs:587-592) explicitly skips entries whose profile == None: "Skip confirmed-absent entries — the negative cache is not persisted; it rebuilds on the next sweep." The Swift upsertDashpayContactProfiles (PlatformWalletPersistenceHandler.swift:1416-1418) is documented append/refresh-only: "contacts absent from this flush keep their existing rows." And restore apply_contact_profile_rows (persistence.rs:3864-3893) unconditionally re-inserts every restored row as ContactProfileEntry { profile: Some(..), checked_at_ms }. Combined: when a contact whose profile was previously cached subsequently removes their dashpay.profile document, apply_fetched_profile flips the in-memory entry to None and any_changed=true triggers a persist round — but allocate_contact_profile_rows drops the now-absent entry and the stale PersistentDashpayContactProfile row survives. On the next cold start the cache rehydrates as Some(stale_profile), so the Requests/Contacts UI shows the contact's old display name and re-issues a GET to the previously-attacker-controlled avatar URL until the next sweep re-confirms absence (which again does not delete the row). Either project absences as a delete intent on the persist side (e.g. carry a removed-contact-ids vector through the changeset) or have the restore fold prune rows the Rust side has since confirmed gone.

suggestion: [Carried forward — STILL VALID] Create-only contact-request C ABI drops consensus-critical entropy

packages/rs-sdk-ffi/src/dashpay/contact_request.rs (line 181)

Re-verified at c1f3aa5. DashSDKContactRequestResult still exposes only document_id, owner_id, and properties_json. The Rust SDK's ContactRequestResult carries a 32-byte entropy field because Drive recomputes the document id from (contract_id, owner_id, document_type_name, entropy) and rejects mismatches with InvalidDocumentTransitionIdError — the exact bug this PR fixes Rust-side. The in-process atomic send_contact_request path is unaffected, but dash_sdk_dashpay_create_contact_request is a create-only export: any non-Swift C/FFI caller that creates here and broadcasts via a separate path (cold-signer / hardware-wallet flows) cannot reproduce the matching id at broadcast time, silently re-introducing the consensus rejection across the FFI boundary. Surface a 32-byte entropy field on the result struct so the C contract matches the Rust contract.

/// Result of creating a contact request
#[repr(C)]
pub struct DashSDKContactRequestResult {
    /// Document ID as hex string
    pub document_id: *mut std::os::raw::c_char,
    /// Owner ID (sender ID) as hex string
    pub owner_id: *mut std::os::raw::c_char,
    /// Document properties as JSON string
    pub properties_json: *mut std::os::raw::c_char,
    /// 32-byte creation entropy. Drive recomputes the document id from
    /// (contract_id, owner_id, document_type_name, entropy); the caller
    /// MUST reuse this exact entropy when later broadcasting the document
    /// or the transition is rejected with InvalidDocumentTransitionIdError.
    pub entropy: [u8; 32],
}
suggestion: [Carried forward — STILL VALID] established_contact_get_note skips the pre-null its sibling getter performs

packages/rs-platform-wallet-ffi/src/established_contact.rs (line 156)

Re-verified at c1f3aa5. Sibling established_contact_get_alias (lines 107-108) writes *out_alias = std::ptr::null_mut(); immediately after check_ptr!, so every early-return path (NotFound, None value, CString interior NUL) leaves the out pointer at defined null. established_contact_get_note (lines 156-169) skips this — on the same early-return paths *out_note is never written, and a C caller that does not pre-zero the slot reads whichever stale pointer value was there, risking double-free / invalid-free through platform_wallet_string_free. Mirror the sibling pattern; the exported C ABI should not depend on caller hygiene.

#[no_mangle]
pub unsafe extern "C" fn established_contact_get_note(
    contact_handle: Handle,
    out_note: *mut *mut std::os::raw::c_char,
) -> PlatformWalletFFIResult {
    check_ptr!(out_note);
    *out_note = std::ptr::null_mut();

    let option =
        ESTABLISHED_CONTACT_STORAGE.with_item(contact_handle, |contact| contact.note.clone());
    let option = unwrap_option_or_return!(option);
    let note = unwrap_option_or_return!(option);
    let c_str = unwrap_result_or_return!(std::ffi::CString::new(note));
    unsafe { *out_note = c_str.into_raw() };
    PlatformWalletFFIResult::ok()
}
suggestion: [Carried forward + further sharpened — STILL VALID] Pending incoming requests render attacker-controlled avatar URLs, now persisted AND restored across restarts

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift (line 119)

Re-verified at c1f3aa5 and further sharpened by this delta. The previous push made contact_profiles durable through the Rust-side persister; this delta closes the loop with PersistentDashpayContactProfile plus the apply_contact_profile_rows restore. The attacker-controlled avatarUrl is now stored Swift-side, restored Rust-side at cold start, and re-projected into the UI before the recipient accepts. sync_contact_profiles populates profiles for managed.incoming_contact_requests.keys() (unaccepted senders); IncomingRequestRow feeds cachedProfile(row.contactIdentityId)?.avatarUrl into DashPayAvatarView → SwiftUI AsyncImage. Any party that publishes a dashpay.profile and sends an unsolicited contact request causes the victim's wallet to issue a network request to the attacker's host the moment the Requests view renders — disclosing IP, online status, and TLS/UA fingerprint — and this side-channel now survives uninstall-resistant SwiftData persistence on the device and resumes immediately on the first relaunch. The Rust-side is_valid_avatar_url only checks scheme + length; that does not remove the tracking side-channel. Suppress remote avatars for unaccepted senders (initials only) until acceptance.

suggestion: [Carried forward — STILL VALID] First-sync/cold-start retrieve-all has no per-call or total document budget

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 42)

Re-verified at c1f3aa5. fetch_contact_requests_paginated drains every matching document with CONTACT_REQUEST_PAGE_SIZE = 100 per page but no per-call total cap, no elapsed-time budget, and no caller-visible continuation. On cold start, restore-from-seed, or any after_created_at == None path, a long-lived identity with thousands of historical contact requests forces an O(N/100) authenticated, proof-verified round-trip storm on every wallet open. Combined with the recurring DashPaySyncManager tick, one oversized identity (organic accumulation or attacker-spammed contactRequest documents varying accountReference) can monopolize the sync loop and starve every other identity in the wallet. Add a configurable total-document ceiling per sweep with a resumable cursor, or at minimum a logged warn + early break above a threshold so a runaway identity is observable.

suggestion: [Carried forward — STILL VALID] Same-$createdAt pagination boundary relies on undocumented Drive cursor secondary order

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 66)

Re-verified at c1f3aa5. The query orders only by $createdAt ascending and paginates with Start::StartAfter(last_document_id) where last_document_id = page.keys().last().copied(). When more than CONTACT_REQUEST_PAGE_SIZE (100) documents share the same $createdAt (sender bursts in one block, second-quantized mobile clients, attacker batches), correctness depends on Drive's cursor honoring an implicit (createdAt, document_id) tie-break that is not part of the documented public contract and is not pinned by any regression test in this PR. Silent skip-or-repeat at the boundary surfaces as contact requests permanently invisible to the victim's wallet. Add an explicit secondary OrderClause on $id, or add a >page_size same-$createdAt regression test.

suggestion: [Carried forward — STILL VALID, sharpened by delta] Contact-profile refresh timestamps are not durably persisted when the profile is unchanged

packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 588)

Re-verified at c1f3aa5: this delta only adds the durable persist+restore plumbing — it does not change apply_fetched_profile. The original gap stands and is now more visible because the restore path reads the same possibly-stale checked_at_ms. apply_fetched_profile updates checked_at_ms on every fetch but returns changed only when the stored profile field differs; sync_contact_profiles calls persister.store only when any_changed is true. An hourly re-check that confirms the same present profile, or the same confirmed-absent profile, refreshes checked_at_ms in memory only. After cold start should_fetch_profile immediately re-issues an In query for every unchanged contact — the negative-cache backoff effectively does not survive restart. Additionally, a failed earlier store is not retried because subsequent sweeps see changed = false and skip persistence. Return a separate needs_persist signal from apply_fetched_profile for timestamp-only refreshes.

suggestion: [Carried forward — STILL VALID] Accept-adopt branch can leave a reciprocal request unestablished

packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (line 1148)

Re-verified at c1f3aa5. When already_reciprocated is true because sent_contact_requests contains sender_id but established_contacts does not, the adopt branch (lines 1148-1166) calls register_contact_account for the receiving account, then accept_register_external_validated runs at lines 1181-1198. Neither path inserts into managed.established_contacts. Step 5 (lines 1200-1214) then looks up established_contacts.get(&sender_id) and returns ContactRequestNotFound, so the accept call fails even though both reciprocal documents exist locally. The common ingest paths auto-establish when both sides land atomically, so the trigger is narrow — persisted-and-rehydrated sent+incoming separately, or a sync/accept race — but the failure mode is sticky because the unique index forbids re-broadcast. Either drive the adopt branch through apply_established_contact for the pair, or make step 5 account for the sent_exists adoption case so the registered accounts are not orphaned by a misleading ContactRequestNotFound.

nitpick: [Carried forward + EXPANDED by delta — STILL VALID] Restore-side FFI structs (Payment, ContactProfile) and persist-side ContactProfileRowFFI lack the compile-time layout guards their siblings carry

packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs (line 339)

Re-verified at c1f3aa5 and expanded by this delta. IdentityEntryFFI's size guard was correctly bumped from 208 to 224 in this delta to account for the new contact_profiles pointer/count fields. But three #[repr(C)] structs that cross the same restore/persist boundary still lack the const _: [u8; N] = [0u8; std::mem::size_of::<T>()]; + align_of guards that sibling structs (ContactRequestFFI, IdentityKeyEntryFFI, IdentityEntryFFI, AccountAddressPoolFFI) carry: (1) the pre-existing PaymentRestoreEntryFFI (wallet_restore_types.rs:354-368); (2) the new ContactProfileRestoreEntryFFI (wallet_restore_types.rs:385-411); (3) the new persist-side ContactProfileRowFFI (identity_persistence.rs:181-211) — the parent IdentityEntryFFI guard pins the pointer field but not the inner row layout Swift indexes through. A field reorder, type widen, or padding change on either side would compile silently and shift offsets relative to the Swift mirror — the matching offsets feed contact_id (which contact in the cache), avatar_url (attacker-controlled URL), and the _present flag bytes for the byte-array fields. Mis-aligned restore would silently load profiles under the wrong contact id or interpret an unrelated u64 as a hash. Add the same const-assert idiom to all three structs.

nitpick: [Carried forward — STILL VALID] Profile-reader trio leaves out-params undefined on early-return paths

packages/rs-platform-wallet-ffi/src/dashpay_profile.rs (line 90)

Re-verified at c1f3aa5. All three profile readers — managed_identity_get_dashpay_profile (lines 90-112), platform_wallet_get_dashpay_profile (lines 116-145), and platform_wallet_get_contact_profile (lines 153-185) — only write *out_profile / *out_has_profile on the success/no-profile match arms inside the function body. After check_ptr!, the early-return paths taken by unwrap_option_or_return! (handle not in storage → NotFound) and unwrap_result_or_return! (invalid identifier bytes → InvalidParameter) leave both out pointers indeterminate. Sibling readers (established_contact_get_alias, the dashpay_sync readers) pre-write out values right after check_ptr!. A C caller that reads *out_profile regardless of result code may interpret garbage as valid pointer fields and later pass them to dashpay_profile_ffi_free, freeing arbitrary pointers.

_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3841 c1f3aa5 --dry-run --prior-sha c071782
STDOUT:

STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", li), so I posted the same verified findings as a top-level review body._

shumkov and others added 2 commits June 18, 2026 12:11
Reverse the prior "keep CBOR" call. The contract validates privateData by LENGTH
only (byteArray, 48..2048); the schema's "encoded as an array in cbor" text is
advisory documentation, NOT an enforced structural constraint. So the encrypted
plaintext format is a free writer/reader convention — and we use DIP-15 (the
authoritative protocol spec: version + var-int strings + displayHidden +
acceptedAccounts) for cross-client interop. No contract change needed; no client
decodes contactInfo today, so it's a free window.

Updated all specs consistently:
- CONTACTINFO_FORMAT_SPEC.md — ACTIVE again (DIP-15 varint); the keep-CBOR banner
  replaced with the length-only-validation rationale.
- TODO.md — Spec 1 = DIP-15 migration (active); Spec 2 ignore signal = DIP-15
  relationshipState minor-version field; research/01-vs-07 reconcile = DIP-15.
- SPEC.md, BLOCK_SPEC.md, research/07 — corrected the "schema mandates CBOR"
  framing to "schema enforces length only; follow DIP-15".

The crypto/contact_info.rs codec (still CBOR) is rewritten as part of Spec 1
implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(Spec 1)

Replace the CBOR `privateData` codec with the DIP-15 "Dash message data"
(Bitcoin P2P) serialization, so a future DIP-15-compliant client interops:

  version (u32 LE = major<<16|minor), aliasName (varstr), note (varstr),
  displayHidden (u8), acceptedAccounts (varInt count + u32 LE[])

- Tolerant decode per DIP-15 versioning: an unknown MAJOR discards the whole
  document; trailing bytes (padding, or a higher MINOR's extra fields) are
  ignored — the forward-compat seam Spec 2 (Ignore) will use for a
  `relationshipState` minor-1 field.
- The contract validates privateData by LENGTH only (48..2048; the schema's
  "array in cbor" description is advisory, not enforced), so tiny payloads pad
  with trailing zero bytes to the 48-byte ciphertext floor and no contract
  change is needed.
- ContactInfoPrivateData gains `accepted_accounts`; dropped the now-dead
  ciborium dependency.

Verified against canonical github.com/dashpay/dips/dip-0015.md with a
byte-vector test pinning the exact wire bytes, plus round-trip /
forward-compat-trailing / incompatible-major / truncation coverage (8 tests
green). Bounded varInt decode (no unbounded alloc), UTF-8-checked strings, no
panics on malformed input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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

Latest delta c1f3aa5..202b987 is documentation-only (docs/dashpay/CONTACTINFO_FORMAT_SPEC.md and TODO.md) recording the decision to keep CBOR contactInfo and drop the varint migration — no Rust/Swift/FFI source changed. No new latest-delta findings. All 10 prior findings re-validated against HEAD and carried forward as STILL VALID: spot-checked allocate_contact_profile_rows (still skips profile == None at lines 587-592), DashSDKContactRequestResult (still lacks entropy field), and established_contact_get_note (still missing the pre-null its sibling getter performs at line 108). Cumulative picture continues to be dominated by the durable attacker-controlled-avatar tracking side channel, the consensus-critical entropy missing from the C ABI, and several FFI hygiene gaps.

Reviewed commit: 202b987

🟡 6 suggestion(s) | 💬 4 nitpick(s)

Verified findings

suggestion: [Carried forward — STILL VALID at 202b987] Confirmed-absent contact profiles never delete their SwiftData row; stale display name/avatar resurrects on cold start

packages/rs-platform-wallet-ffi/src/identity_persistence.rs (line 577)

Re-verified at HEAD (docs-only delta). allocate_contact_profile_rows still explicitly continues past entries with profile == None at lines 587-592 ("Skip confirmed-absent entries — the negative cache is not persisted; it rebuilds on the next sweep."). Swift's upsertDashpayContactProfiles is append/refresh-only, and apply_contact_profile_rows rehydrates every restored row as ContactProfileEntry { profile: Some(..), checked_at_ms }. When a contact removes their dashpay.profile document, apply_fetched_profile flips the in-memory entry to None and triggers a persist round, but the now-absent entry is dropped and the stale PersistentDashpayContactProfile row survives. On cold start the cache rehydrates as Some(stale_profile) — the UI shows the old display name and re-issues a GET to the previously-attacker-controlled avatar URL until the next sweep re-confirms absence (which again does not delete). Project absences as a delete intent on the persist side (e.g. a removed_contact_profile_ids pointer+count) or have the restore fold prune rows the Rust side has since confirmed gone.

suggestion: [Carried forward — STILL VALID at 202b987] Create-only contact-request C ABI drops consensus-critical entropy

packages/rs-sdk-ffi/src/dashpay/contact_request.rs (line 181)

Re-verified at HEAD — struct unchanged. DashSDKContactRequestResult exposes only document_id, owner_id, and properties_json. The Rust SDK's ContactRequestResult carries a 32-byte entropy field because Drive recomputes the document id from (contract_id, owner_id, document_type_name, entropy) and rejects mismatches with InvalidDocumentTransitionIdError — the exact bug this PR fixes Rust-side. The atomic send_contact_request path is unaffected, but dash_sdk_dashpay_create_contact_request is a create-only export: any non-Swift C/FFI caller that creates here and broadcasts via a separate path (cold-signer / hardware-wallet flows) cannot reproduce the matching id at broadcast time, silently re-introducing the consensus rejection across the FFI boundary. Surface a 32-byte entropy field on the result struct so the C contract matches the Rust contract.

/// Result of creating a contact request
#[repr(C)]
pub struct DashSDKContactRequestResult {
    /// Document ID as hex string
    pub document_id: *mut std::os::raw::c_char,
    /// Owner ID (sender ID) as hex string
    pub owner_id: *mut std::os::raw::c_char,
    /// Document properties as JSON string
    pub properties_json: *mut std::os::raw::c_char,
    /// 32-byte creation entropy. Drive recomputes the document id from
    /// (contract_id, owner_id, document_type_name, entropy); the caller
    /// MUST reuse this exact entropy when later broadcasting the document
    /// or the transition is rejected with InvalidDocumentTransitionIdError.
    pub entropy: [u8; 32],
}
suggestion: [Carried forward — STILL VALID at 202b987] established_contact_get_note skips the pre-null its sibling getter performs

packages/rs-platform-wallet-ffi/src/established_contact.rs (line 156)

Re-verified at HEAD by reading the file. Sibling established_contact_get_alias at line 108 writes *out_alias = std::ptr::null_mut(); immediately after check_ptr!, so every early-return path (NotFound, None value, CString interior NUL) leaves the out pointer at defined null. established_contact_get_note (lines 156-169) skips this — on the same early-return paths *out_note is never written, and a C caller that does not pre-zero the slot reads whichever stale pointer value was there, risking double-free / invalid-free through platform_wallet_string_free. The exported C ABI should not depend on caller hygiene; mirror the sibling pattern.

#[no_mangle]
pub unsafe extern "C" fn established_contact_get_note(
    contact_handle: Handle,
    out_note: *mut *mut std::os::raw::c_char,
) -> PlatformWalletFFIResult {
    check_ptr!(out_note);
    *out_note = std::ptr::null_mut();

    let option =
        ESTABLISHED_CONTACT_STORAGE.with_item(contact_handle, |contact| contact.note.clone());
    let option = unwrap_option_or_return!(option);
    let note = unwrap_option_or_return!(option);
    let c_str = unwrap_result_or_return!(std::ffi::CString::new(note));
    unsafe { *out_note = c_str.into_raw() };
    PlatformWalletFFIResult::ok()
}
suggestion: [Carried forward — STILL VALID at 202b987] Pending incoming requests render attacker-controlled avatar URLs, persisted AND restored across restarts

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift (line 119)

Re-verified at HEAD (docs-only delta). sync_contact_profiles populates profiles for managed.incoming_contact_requests.keys() (unaccepted senders); IncomingRequestRow feeds cachedProfile(row.contactIdentityId)?.avatarUrl into DashPayAvatarView → SwiftUI AsyncImage. Any party that publishes a dashpay.profile and sends an unsolicited contact request causes the victim's wallet to issue a network request to the attacker's host the moment the Requests view renders — disclosing IP, online status, and TLS/UA fingerprint. With this PR's PersistentDashpayContactProfile plus the apply_contact_profile_rows restore, the side channel now survives uninstall-resistant SwiftData persistence and resumes immediately on relaunch. The Rust-side is_valid_avatar_url only checks scheme + length; that does not remove the tracking side channel. Suppress remote avatars for unaccepted senders (initials only) until acceptance, either at the Swift render site or by gating the persist FFI to only ship profiles for established contacts.

suggestion: [Carried forward — STILL VALID at 202b987] Contact-profile refresh timestamps are not durably persisted when the profile is unchanged

packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 588)

Re-verified at HEAD. apply_fetched_profile updates checked_at_ms on every fetch but returns changed only when the stored profile field differs; sync_contact_profiles calls persister.store only when any_changed is true. A re-check confirming the same present profile (or same confirmed-absent profile) refreshes checked_at_ms in memory only. After cold start should_fetch_profile immediately re-issues an In query for every unchanged contact — the negative-cache backoff effectively does not survive restart. A failed earlier store also is not retried because later sweeps see changed = false and skip persistence. Return a separate needs_persist signal from apply_fetched_profile for timestamp-only refreshes so the FFI callback fires and SwiftData absorbs the new checked_at_ms.

suggestion: [Carried forward — STILL VALID at 202b987] First-sync/cold-start retrieve-all has no per-call or total document budget

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 42)

Re-verified at HEAD. fetch_contact_requests_paginated drains every matching document with CONTACT_REQUEST_PAGE_SIZE = 100 per page but has no per-call total cap, elapsed-time budget, or caller-visible continuation. On cold start, restore-from-seed, or any after_created_at == None path, a long-lived identity with thousands of historical contact requests forces an O(N/100) authenticated, proof-verified round-trip storm on every wallet open. Combined with the recurring DashPaySyncManager tick, one oversized identity (organic accumulation or attacker-spammed contactRequest documents varying accountReference) can monopolize the sync loop and starve every other identity in the wallet. Add a configurable total-document ceiling per sweep with a resumable cursor, or at minimum a logged warn + early break above a threshold so a runaway identity is observable.

nitpick: [Carried forward — STILL VALID at 202b987] Restore-side FFI structs (Payment, ContactProfile) and persist-side ContactProfileRowFFI lack the compile-time layout guards their siblings carry

packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs (line 339)

Re-verified at HEAD. Sibling structs are guarded (AccountAddressPoolFFI, ContactRequestFFI, IdentityKeyEntryFFI, IdentityEntryFFI), but three #[repr(C)] structs that cross the same restore/persist boundary still have no const-size/align guard: (1) PaymentRestoreEntryFFI (wallet_restore_types.rs:354-368, pre-existing); (2) ContactProfileRestoreEntryFFI (wallet_restore_types.rs:385-411); (3) the persist-side ContactProfileRowFFI (identity_persistence.rs:181-211) — the parent IdentityEntryFFI guard pins the pointer field but not the inner row layout Swift indexes through. A field reorder, type widen, or padding change on either side would compile silently and shift offsets relative to the Swift mirror — feeding contact_id, avatar_url, and _present flag bytes. Mis-aligned restore could silently load profiles under the wrong contact id. Add the same const-assert idiom to all three structs.

nitpick: [Carried forward — STILL VALID at 202b987] Profile-reader trio leaves out-params undefined on early-return paths

packages/rs-platform-wallet-ffi/src/dashpay_profile.rs (line 90)

Re-verified at HEAD. managed_identity_get_dashpay_profile, platform_wallet_get_dashpay_profile, and platform_wallet_get_contact_profile only write *out_profile / *out_has_profile on the success/no-profile match arms. After check_ptr!, the early-return paths taken by unwrap_option_or_return! (handle not in storage → NotFound) and unwrap_result_or_return! (invalid identifier bytes → InvalidParameter) leave both out pointers indeterminate. Sibling readers (established_contact_get_alias, the dashpay_sync readers) pre-write out values right after check_ptr!. A C caller that reads *out_profile regardless of result code may interpret garbage as valid pointer fields and later pass them to dashpay_profile_ffi_free. Pre-write *out_profile = DashPayProfileFFI::empty() and *out_has_profile = false right after the check_ptr! calls.

nitpick: [Carried forward — STILL VALID at 202b987] Same-$createdAt pagination boundary relies on undocumented Drive cursor secondary order

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 66)

Re-verified at HEAD. The query orders only by $createdAt ascending and paginates with Start::StartAfter(last_document_id) where last_document_id = page.keys().last().copied(). When more than 100 documents share the same $createdAt (sender bursts in one block, second-quantized mobile clients, attacker batches), correctness depends on Drive's cursor honoring an implicit (createdAt, document_id) tie-break that is not part of the documented public contract and is not pinned by any regression test in this PR. Silent skip-or-repeat at the boundary surfaces as contact requests permanently invisible to the victim's wallet. Add an explicit secondary OrderClause on $id, or add a >page_size same-$createdAt regression test.

nitpick: [Carried forward — STILL VALID at 202b987] Accept-adopt branch can leave a reciprocal request unestablished

packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (line 1148)

Re-verified at HEAD. When already_reciprocated is true because sent_contact_requests contains sender_id but established_contacts does not, the adopt branch (lines 1148-1166) calls register_contact_account for the receiving account, then accept_register_external_validated runs at lines 1181-1198. Neither path inserts into managed.established_contacts. Step 5 (lines 1200-1214) then looks up established_contacts.get(&sender_id) and returns ContactRequestNotFound, so the accept call fails even though both reciprocal documents exist locally. The common ingest paths auto-establish when both sides land atomically, so the trigger is narrow — persisted-and-rehydrated sent+incoming separately, or a sync/accept race — but the failure mode is sticky because the unique index forbids re-broadcast. Either drive the adopt branch through apply_established_contact for the pair, or make step 5 account for the sent_exists adoption case.

_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3841 202b987 --dry-run --prior-sha c1f3aa5
STDOUT:

STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", li), so I posted the same verified findings as a top-level review body._

R1 investigation: a per-sender contactInfo about a NON-established ignored
sender leaks who you ignored. Its public $createdAt/$updatedAt (enumerable via
the contactInfo ownerIdAndUpdatedAt index) correlates with the inbound
contactRequest's $createdAt (public userIdCreatedAt index) to re-identify the
encrypted target, plus a count leak. DIP-15's >=2-established-contacts gate does
NOT cover a fresh non-established sender — it's exactly the "trivial linking"
DIP-15 warns about.

Decision (product): ignore is LOCAL-ONLY for now (per-device suppression, zero
on-chain artifact, zero leak). Cross-device sync is deferred to a future
encrypted ignored-contacts field on the PROFILE document — a single doc that
already updates for many reasons, so an update doesn't specifically signal an
ignore (bounded leak, no per-sender existence/count leak). Needs a registered
dashpay contract change (Contract track).

- TODO: R1 resolved; Spec 2 re-scoped to local-only; new contract-track item.
- CONTACTINFO_FORMAT_SPEC: dropped the "Spec 2 layers relationshipState on
  contactInfo" note (ignore does not ride contactInfo — it leaks).

Also stage Cargo.lock (drops platform-wallet's now-dead ciborium dep, from the
Spec 1 commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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 808b347. The latest 202b987..808b347 delta is the CBOR→DIP-15 varint codec migration for contactInfo.privateData plus docs. The new codec is well-bounded (checked_add Reader, version split, UTF-8 validation, tests). No new defects introduced by the delta itself. All 10 prior findings from review at 202b987 reconciled as STILL VALID and carried forward (spot-verified at established_contact.rs:108 vs 156-169, identity_persistence.rs:587-592, rs-sdk-ffi/.../contact_request.rs:181-188). Cumulative re-scan adds one new in-scope finding from Codex: contactInfo fetch caps at 100 docs and uses the truncated set for next-slot allocation. Total 11 findings retained (one over the 10-cap because the verifier mandate to carry forward all STILL VALID priors takes precedence).

🟡 7 suggestion(s) | 💬 4 nitpick(s)

11 additional finding(s)

suggestion: contactInfo fetch caps at 100 docs and reuses the truncated set for next-slot derivation-index allocation

packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs (line 94)

fetch_decrypted_contact_infos issues a single contactInfo query with limit: 100, start: None (lines 94-116) and treats the returned vector as complete. The same loop builds high_water per root_index from those truncated results (lines 143-166), and the publish path later chooses high_water + 1 for a fresh document. Once an identity has more than 100 contactInfo docs, the 101st+ documents are not applied during sync, and — more importantly — a publish can compute a next derivation index from only the first page. In the sequential case with 101 existing docs ordered by $updatedAt ascending, the first page contains indices 0..99, so the next write picks 100 even though index 100 already exists on-chain, causing the unique ($ownerId, rootEncryptionKeyIndex, derivationEncryptionKeyIndex) index to reject the update. Drain all pages with Start::StartAfter, or query by max(derivation_index) for the relevant root index, before allocating.

suggestion: [Carried forward — STILL VALID] Confirmed-absent contact profiles never delete their SwiftData row; stale display name/avatar resurrects on cold start

packages/rs-platform-wallet-ffi/src/identity_persistence.rs (line 577)

Verified at HEAD: allocate_contact_profile_rows explicitly continues past entries with profile == None at lines 587-592 ("Skip confirmed-absent entries — the negative cache is not persisted; it rebuilds on the next sweep."). Swift's upsertDashpayContactProfiles is append/refresh-only, and apply_contact_profile_rows rehydrates every restored row as ContactProfileEntry { profile: Some(..), checked_at_ms }. When a contact removes their dashpay.profile, apply_fetched_profile flips the in-memory entry to None and triggers a persist round, but the now-absent entry is dropped from the FFI projection and the stale PersistentDashpayContactProfile row survives. On cold start the cache rehydrates as Some(stale_profile): the UI shows the old display name and re-issues a GET to the previously-attacker-controlled avatar URL until the next sweep re-confirms absence (which again does not delete). Project absences as an explicit delete intent across the boundary (e.g. removed_contact_profile_ids: *const [u8;32], removed_count: usize) so the persistence side can prune confirmed-absent rows.

suggestion: [Carried forward — STILL VALID] Create-only contact-request C ABI drops consensus-critical entropy

packages/rs-sdk-ffi/src/dashpay/contact_request.rs (line 181)

Verified at HEAD: DashSDKContactRequestResult still exposes only document_id, owner_id, and properties_json (lines 181-188). The Rust SDK's ContactRequestResult carries a 32-byte entropy field because Drive recomputes the document id from (contract_id, owner_id, document_type_name, entropy) and rejects mismatches with InvalidDocumentTransitionIdError — the exact bug this PR fixes Rust-side. The in-process atomic send_contact_request path is unaffected, but dash_sdk_dashpay_create_contact_request is a create-only export across the C ABI: any non-Swift caller that creates here and broadcasts the resulting document via a separate path (cold-signer / hardware-wallet flows) cannot reproduce the matching id at broadcast time, silently re-introducing the consensus rejection across the FFI boundary. Surface a 32-byte entropy field on the result struct so the C contract matches the Rust contract.

/// Result of creating a contact request
#[repr(C)]
pub struct DashSDKContactRequestResult {
    /// Document ID as hex string
    pub document_id: *mut std::os::raw::c_char,
    /// Owner ID (sender ID) as hex string
    pub owner_id: *mut std::os::raw::c_char,
    /// Document properties as JSON string
    pub properties_json: *mut std::os::raw::c_char,
    /// 32-byte creation entropy. Drive recomputes the document id from
    /// (contract_id, owner_id, document_type_name, entropy); the caller
    /// MUST reuse this exact entropy when later broadcasting the document
    /// or the transition is rejected with InvalidDocumentTransitionIdError.
    pub entropy: [u8; 32],
}
suggestion: [Carried forward — STILL VALID] established_contact_get_note skips the pre-null its sibling getter performs

packages/rs-platform-wallet-ffi/src/established_contact.rs (line 156)

Verified at HEAD by reading both functions side-by-side. Sibling established_contact_get_alias writes *out_alias = std::ptr::null_mut(); at line 108 immediately after check_ptr!, so every early-return path (NotFound, None value, CString interior NUL) leaves the out pointer at defined null. established_contact_get_note (lines 156-169) only calls check_ptr! without the pre-null — on the same early-return paths *out_note is never written. A C caller that doesn't pre-zero the slot reads whichever stale pointer value was there, risking invalid-free / double-free through platform_wallet_string_free. The exported C ABI should not depend on caller hygiene; mirror the sibling pattern.

#[no_mangle]
pub unsafe extern "C" fn established_contact_get_note(
    contact_handle: Handle,
    out_note: *mut *mut std::os::raw::c_char,
) -> PlatformWalletFFIResult {
    check_ptr!(out_note);
    *out_note = std::ptr::null_mut();

    let option =
        ESTABLISHED_CONTACT_STORAGE.with_item(contact_handle, |contact| contact.note.clone());
    let option = unwrap_option_or_return!(option);
    let note = unwrap_option_or_return!(option);
    let c_str = unwrap_result_or_return!(std::ffi::CString::new(note));
    unsafe { *out_note = c_str.into_raw() };
    PlatformWalletFFIResult::ok()
}
suggestion: [Carried forward — STILL VALID] Pending incoming requests render attacker-controlled avatar URLs, persisted AND restored across restarts

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift (line 119)

Verified at HEAD. sync_contact_profiles populates profiles for managed.incoming_contact_requests.keys() (unaccepted senders); IncomingRequestRow feeds cachedProfile(row.contactIdentityId)?.avatarUrl into DashPayAvatarView → SwiftUI AsyncImage. Any party that publishes a dashpay.profile and sends an unsolicited contact request causes the victim's wallet to issue a network request to the attacker's host the moment the Requests view renders — disclosing IP, online status, and TLS/UA fingerprint, before any consent action. With this PR's PersistentDashpayContactProfile plus the apply_contact_profile_rows restore, the side channel survives SwiftData persistence and resumes immediately on relaunch (and is re-armed by the stale-row issue above). Rust-side is_valid_avatar_url only checks scheme + length; that does not remove the tracking side channel. Suppress remote avatars for unaccepted senders (initials only) until acceptance, either at the Swift render site or by gating the persist FFI to only ship profiles for established contacts.

suggestion: [Carried forward — STILL VALID] Contact-profile refresh timestamps are not durably persisted when the profile is unchanged

packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 588)

Verified at HEAD. apply_fetched_profile updates checked_at_ms on every fetch but returns changed only when the stored profile field differs; sync_contact_profiles calls persister.store only when any_changed is true. A re-check confirming the same present profile (or same confirmed-absent profile) refreshes checked_at_ms in memory only. After cold start should_fetch_profile immediately re-issues an In query for every unchanged contact — the negative-cache backoff effectively does not survive restart, amplifying the tracking-side-channel pressure on the Requests view and the cold-start fetch storm flagged below. A failed earlier store also is not retried because later sweeps see changed = false and skip persistence. Return a separate needs_persist signal from apply_fetched_profile for timestamp-only refreshes so the FFI callback fires and SwiftData absorbs the new checked_at_ms.

suggestion: [Carried forward — STILL VALID] First-sync/cold-start retrieve-all has no per-call or total document budget

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 42)

Verified at HEAD. fetch_contact_requests_paginated drains every matching document with CONTACT_REQUEST_PAGE_SIZE = 100 per page but no per-call total cap, no elapsed-time budget, and no caller-visible continuation. On cold start, restore-from-seed, or any after_created_at == None path, a long-lived identity with thousands of historical contact requests forces an O(N/100) authenticated, proof-verified round-trip storm on every wallet open. Combined with the recurring DashPaySyncManager tick, one oversized identity (organic accumulation or attacker-spammed contactRequest documents varying accountReference) can monopolize the sync loop and starve every other identity in the wallet — a cheap algorithmic-DoS surface. Add a configurable total-document ceiling per sweep with a resumable cursor, or at minimum a logged warn + early break above a threshold so a runaway identity is observable.

nitpick: [Carried forward — STILL VALID] Restore/persist FFI structs (Payment, ContactProfile, ContactProfileRow) lack compile-time layout guards

packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs (line 339)

Verified at HEAD. Sibling FFI structs are guarded (AccountAddressPoolFFI, ContactRequestFFI, ContactRequestRemovalFFI, ContactRequestRejectionFFI, IdentityKeyEntryFFI, IdentityEntryFFI). But three #[repr(C)] structs that cross the same restore/persist boundary still have no const _: [u8; N] = [0u8; size_of::<T>()] + align_of tripwires: (1) PaymentRestoreEntryFFI (wallet_restore_types.rs:354-368, pre-existing); (2) ContactProfileRestoreEntryFFI (wallet_restore_types.rs:385-411); (3) the persist-side ContactProfileRowFFI (identity_persistence.rs:181-211) — the parent IdentityEntryFFI guard pins the pointer field but not the inner row layout Swift indexes through. A field reorder, type widen, or padding change on either side would compile silently and shift offsets relative to the Swift mirror — feeding contact_id, avatar_url, and _present flag bytes. Mis-aligned restore could silently load profiles under the wrong contact id. Add the same const-assert idiom to all three structs.

nitpick: [Carried forward — STILL VALID] Profile-reader trio leaves out-params undefined on early-return paths

packages/rs-platform-wallet-ffi/src/dashpay_profile.rs (line 90)

Verified at HEAD. managed_identity_get_dashpay_profile, platform_wallet_get_dashpay_profile, and platform_wallet_get_contact_profile only write *out_profile / *out_has_profile on the success/no-profile match arms. After check_ptr!, the early-return paths taken by unwrap_option_or_return! (handle not in storage → NotFound) and unwrap_result_or_return! (invalid identifier bytes → InvalidParameter) leave both out pointers indeterminate. Sibling readers (established_contact_get_alias, the dashpay_sync readers) pre-write out values right after check_ptr!. A C caller that reads *out_profile regardless of result code may interpret garbage as valid pointer fields and later pass them to dashpay_profile_ffi_free, freeing arbitrary pointers. Pre-write *out_profile = DashPayProfileFFI::empty() and *out_has_profile = false right after the check_ptr! calls.

nitpick: [Carried forward — STILL VALID] Same-$createdAt pagination boundary relies on undocumented Drive cursor secondary order

packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 66)

Verified at HEAD. The query orders only by $createdAt ascending and paginates with Start::StartAfter(last_document_id) where last_document_id = page.keys().last().copied(). When more than 100 documents share the same $createdAt (sender bursts in one block, second-quantized mobile clients, attacker batches), correctness depends on Drive's cursor honoring an implicit (createdAt, document_id) tie-break that is not part of the documented public contract and is not pinned by any regression test in this PR. Silent skip-or-repeat at the boundary surfaces as contact requests permanently invisible to the victim's wallet — potentially exploitable by a sender who deliberately crafts a same-block burst. Add an explicit secondary OrderClause on $id, or add a >page_size same-$createdAt regression test.

nitpick: [Carried forward — STILL VALID] Accept-adopt branch can leave a reciprocal request unestablished

packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (line 1148)

Verified at HEAD. When already_reciprocated is true because sent_contact_requests contains sender_id but established_contacts does not, the adopt branch (lines 1148-1166) calls register_contact_account for the receiving account, then accept_register_external_validated runs at lines 1181-1198. Neither path inserts into managed.established_contacts. Step 5 (lines 1200-1214) then looks up established_contacts.get(&sender_id) and returns ContactRequestNotFound, so the accept call fails even though both reciprocal documents exist locally. The common ingest paths auto-establish when both sides land atomically, so the trigger is narrow (persisted-and-rehydrated sent+incoming separately, or a sync/accept race), but the failure mode is sticky because the unique index forbids re-broadcast. Either drive the adopt branch through apply_established_contact for the pair, or make step 5 account for the sent_exists adoption case.

🤖 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.

- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs`:94-166: contactInfo fetch caps at 100 docs and reuses the truncated set for next-slot derivation-index allocation
  `fetch_decrypted_contact_infos` issues a single `contactInfo` query with `limit: 100, start: None` (lines 94-116) and treats the returned vector as complete. The same loop builds `high_water` per `root_index` from those truncated results (lines 143-166), and the publish path later chooses `high_water + 1` for a fresh document. Once an identity has more than 100 contactInfo docs, the 101st+ documents are not applied during sync, and — more importantly — a publish can compute a next derivation index from only the first page. In the sequential case with 101 existing docs ordered by `$updatedAt` ascending, the first page contains indices 0..99, so the next write picks 100 even though index 100 already exists on-chain, causing the unique `($ownerId, rootEncryptionKeyIndex, derivationEncryptionKeyIndex)` index to reject the update. Drain all pages with `Start::StartAfter`, or query by max(derivation_index) for the relevant root index, before allocating.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/identity_persistence.rs`:577-621: [Carried forward — STILL VALID] Confirmed-absent contact profiles never delete their SwiftData row; stale display name/avatar resurrects on cold start
  Verified at HEAD: `allocate_contact_profile_rows` explicitly continues past entries with `profile == None` at lines 587-592 ("Skip confirmed-absent entries — the negative cache is not persisted; it rebuilds on the next sweep."). Swift's `upsertDashpayContactProfiles` is append/refresh-only, and `apply_contact_profile_rows` rehydrates every restored row as `ContactProfileEntry { profile: Some(..), checked_at_ms }`. When a contact removes their `dashpay.profile`, `apply_fetched_profile` flips the in-memory entry to `None` and triggers a persist round, but the now-absent entry is dropped from the FFI projection and the stale `PersistentDashpayContactProfile` row survives. On cold start the cache rehydrates as `Some(stale_profile)`: the UI shows the old display name and re-issues a GET to the previously-attacker-controlled avatar URL until the next sweep re-confirms absence (which again does not delete). Project absences as an explicit delete intent across the boundary (e.g. `removed_contact_profile_ids: *const [u8;32], removed_count: usize`) so the persistence side can prune confirmed-absent rows.
- [SUGGESTION] In `packages/rs-sdk-ffi/src/dashpay/contact_request.rs`:181-188: [Carried forward — STILL VALID] Create-only contact-request C ABI drops consensus-critical entropy
  Verified at HEAD: `DashSDKContactRequestResult` still exposes only `document_id`, `owner_id`, and `properties_json` (lines 181-188). The Rust SDK's `ContactRequestResult` carries a 32-byte `entropy` field because Drive recomputes the document id from `(contract_id, owner_id, document_type_name, entropy)` and rejects mismatches with `InvalidDocumentTransitionIdError` — the exact bug this PR fixes Rust-side. The in-process atomic `send_contact_request` path is unaffected, but `dash_sdk_dashpay_create_contact_request` is a create-only export across the C ABI: any non-Swift caller that creates here and broadcasts the resulting document via a separate path (cold-signer / hardware-wallet flows) cannot reproduce the matching id at broadcast time, silently re-introducing the consensus rejection across the FFI boundary. Surface a 32-byte `entropy` field on the result struct so the C contract matches the Rust contract.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:156-169: [Carried forward — STILL VALID] established_contact_get_note skips the pre-null its sibling getter performs
  Verified at HEAD by reading both functions side-by-side. Sibling `established_contact_get_alias` writes `*out_alias = std::ptr::null_mut();` at line 108 immediately after `check_ptr!`, so every early-return path (NotFound, `None` value, CString interior NUL) leaves the out pointer at defined null. `established_contact_get_note` (lines 156-169) only calls `check_ptr!` without the pre-null — on the same early-return paths `*out_note` is never written. A C caller that doesn't pre-zero the slot reads whichever stale pointer value was there, risking invalid-free / double-free through `platform_wallet_string_free`. The exported C ABI should not depend on caller hygiene; mirror the sibling pattern.
- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift`:119-129: [Carried forward — STILL VALID] Pending incoming requests render attacker-controlled avatar URLs, persisted AND restored across restarts
  Verified at HEAD. `sync_contact_profiles` populates profiles for `managed.incoming_contact_requests.keys()` (unaccepted senders); `IncomingRequestRow` feeds `cachedProfile(row.contactIdentityId)?.avatarUrl` into `DashPayAvatarView` → SwiftUI `AsyncImage`. Any party that publishes a `dashpay.profile` and sends an unsolicited contact request causes the victim's wallet to issue a network request to the attacker's host the moment the Requests view renders — disclosing IP, online status, and TLS/UA fingerprint, before any consent action. With this PR's `PersistentDashpayContactProfile` plus the `apply_contact_profile_rows` restore, the side channel survives SwiftData persistence and resumes immediately on relaunch (and is re-armed by the stale-row issue above). Rust-side `is_valid_avatar_url` only checks scheme + length; that does not remove the tracking side channel. Suppress remote avatars for unaccepted senders (initials only) until acceptance, either at the Swift render site or by gating the persist FFI to only ship profiles for established contacts.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs`:588-725: [Carried forward — STILL VALID] Contact-profile refresh timestamps are not durably persisted when the profile is unchanged
  Verified at HEAD. `apply_fetched_profile` updates `checked_at_ms` on every fetch but returns `changed` only when the stored `profile` field differs; `sync_contact_profiles` calls `persister.store` only when `any_changed` is true. A re-check confirming the same present profile (or same confirmed-absent profile) refreshes `checked_at_ms` in memory only. After cold start `should_fetch_profile` immediately re-issues an `In` query for every unchanged contact — the negative-cache backoff effectively does not survive restart, amplifying the tracking-side-channel pressure on the Requests view and the cold-start fetch storm flagged below. A failed earlier store also is not retried because later sweeps see `changed = false` and skip persistence. Return a separate `needs_persist` signal from `apply_fetched_profile` for timestamp-only refreshes so the FFI callback fires and SwiftData absorbs the new `checked_at_ms`.
- [SUGGESTION] In `packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs`:42-112: [Carried forward — STILL VALID] First-sync/cold-start retrieve-all has no per-call or total document budget
  Verified at HEAD. `fetch_contact_requests_paginated` drains every matching document with `CONTACT_REQUEST_PAGE_SIZE = 100` per page but no per-call total cap, no elapsed-time budget, and no caller-visible continuation. On cold start, restore-from-seed, or any `after_created_at == None` path, a long-lived identity with thousands of historical contact requests forces an O(N/100) authenticated, proof-verified round-trip storm on every wallet open. Combined with the recurring `DashPaySyncManager` tick, one oversized identity (organic accumulation or attacker-spammed `contactRequest` documents varying `accountReference`) can monopolize the sync loop and starve every other identity in the wallet — a cheap algorithmic-DoS surface. Add a configurable total-document ceiling per sweep with a resumable cursor, or at minimum a logged warn + early break above a threshold so a runaway identity is observable.
- [NITPICK] In `packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs`:339-411: [Carried forward — STILL VALID] Restore/persist FFI structs (Payment, ContactProfile, ContactProfileRow) lack compile-time layout guards
  Verified at HEAD. Sibling FFI structs are guarded (`AccountAddressPoolFFI`, `ContactRequestFFI`, `ContactRequestRemovalFFI`, `ContactRequestRejectionFFI`, `IdentityKeyEntryFFI`, `IdentityEntryFFI`). But three `#[repr(C)]` structs that cross the same restore/persist boundary still have no `const _: [u8; N] = [0u8; size_of::<T>()]` + `align_of` tripwires: (1) `PaymentRestoreEntryFFI` (wallet_restore_types.rs:354-368, pre-existing); (2) `ContactProfileRestoreEntryFFI` (wallet_restore_types.rs:385-411); (3) the persist-side `ContactProfileRowFFI` (identity_persistence.rs:181-211) — the parent `IdentityEntryFFI` guard pins the pointer field but not the inner row layout Swift indexes through. A field reorder, type widen, or padding change on either side would compile silently and shift offsets relative to the Swift mirror — feeding `contact_id`, `avatar_url`, and `_present` flag bytes. Mis-aligned restore could silently load profiles under the wrong contact id. Add the same const-assert idiom to all three structs.
- [NITPICK] In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:90-185: [Carried forward — STILL VALID] Profile-reader trio leaves out-params undefined on early-return paths
  Verified at HEAD. `managed_identity_get_dashpay_profile`, `platform_wallet_get_dashpay_profile`, and `platform_wallet_get_contact_profile` only write `*out_profile` / `*out_has_profile` on the success/no-profile match arms. After `check_ptr!`, the early-return paths taken by `unwrap_option_or_return!` (handle not in storage → NotFound) and `unwrap_result_or_return!` (invalid identifier bytes → InvalidParameter) leave both out pointers indeterminate. Sibling readers (`established_contact_get_alias`, the dashpay_sync readers) pre-write out values right after `check_ptr!`. A C caller that reads `*out_profile` regardless of result code may interpret garbage as valid pointer fields and later pass them to `dashpay_profile_ffi_free`, freeing arbitrary pointers. Pre-write `*out_profile = DashPayProfileFFI::empty()` and `*out_has_profile = false` right after the `check_ptr!` calls.
- [NITPICK] In `packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs`:66-109: [Carried forward — STILL VALID] Same-$createdAt pagination boundary relies on undocumented Drive cursor secondary order
  Verified at HEAD. The query orders only by `$createdAt` ascending and paginates with `Start::StartAfter(last_document_id)` where `last_document_id = page.keys().last().copied()`. When more than 100 documents share the same `$createdAt` (sender bursts in one block, second-quantized mobile clients, attacker batches), correctness depends on Drive's cursor honoring an implicit `(createdAt, document_id)` tie-break that is not part of the documented public contract and is not pinned by any regression test in this PR. Silent skip-or-repeat at the boundary surfaces as contact requests permanently invisible to the victim's wallet — potentially exploitable by a sender who deliberately crafts a same-block burst. Add an explicit secondary `OrderClause` on `$id`, or add a >page_size same-`$createdAt` regression test.
- [NITPICK] In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:1148-1214: [Carried forward — STILL VALID] Accept-adopt branch can leave a reciprocal request unestablished
  Verified at HEAD. When `already_reciprocated` is true because `sent_contact_requests` contains `sender_id` but `established_contacts` does not, the adopt branch (lines 1148-1166) calls `register_contact_account` for the receiving account, then `accept_register_external_validated` runs at lines 1181-1198. Neither path inserts into `managed.established_contacts`. Step 5 (lines 1200-1214) then looks up `established_contacts.get(&sender_id)` and returns `ContactRequestNotFound`, so the accept call fails even though both reciprocal documents exist locally. The common ingest paths auto-establish when both sides land atomically, so the trigger is narrow (persisted-and-rehydrated sent+incoming separately, or a sync/accept race), but the failure mode is sticky because the unique index forbids re-broadcast. Either drive the adopt branch through `apply_established_contact` for the pair, or make step 5 account for the `sent_exists` adoption case.

Inline dry-run could not load the GitHub PR diff because this PR exceeds GitHub's 20,000-line diff limit, so I posted the same verified findings as a top-level review body.

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.

3 participants