fix(platform-wallet)!: complete dashpay#3841
Conversation
…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>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds 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. ChangesDashPay Spec & Research
Crypto & SDK
Validation, State & Storage
FFI & Persistence
SDK writer seam & wallet integration
Contact flow refactor
Payments, reconciliation & event bridge
Recurring sync manager
Swift SDK and Example App
Estimated code review effort 🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
🕓 Ready for review — 6 ahead in queue (commit 0f7e598) |
There was a problem hiding this comment.
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 liftThe accept-adopt check is only local, not platform-aware.
already_reciprocatedis derived from localsent_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 whilealready_reciprocatedis 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
📒 Files selected for processing (38)
docs/dashpay/SPEC.mddocs/dashpay/research/01-dip-spec.mddocs/dashpay/research/02-rust-dashcore-keywallet.mddocs/dashpay/research/03-rs-platform-wallet.mddocs/dashpay/research/04-sdk-and-contract.mddocs/dashpay/research/05-swift-app.mddocs/dashpay/research/06-interop-desk-check.mdpackages/rs-platform-encryption/Cargo.tomlpackages/rs-platform-encryption/src/lib.rspackages/rs-platform-wallet-ffi/src/established_contact.rspackages/rs-platform-wallet-storage/migrations/V001__initial.rspackages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rspackages/rs-platform-wallet-storage/src/sqlite/schema/identities.rspackages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rspackages/rs-platform-wallet/src/changeset/changeset.rspackages/rs-platform-wallet/src/changeset/mod.rspackages/rs-platform-wallet/src/lib.rspackages/rs-platform-wallet/src/manager/accessors.rspackages/rs-platform-wallet/src/manager/dashpay_sync.rspackages/rs-platform-wallet/src/manager/mod.rspackages/rs-platform-wallet/src/wallet/apply.rspackages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rspackages/rs-platform-wallet/src/wallet/identity/crypto/validation.rspackages/rs-platform-wallet/src/wallet/identity/network/account_labels.rspackages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rspackages/rs-platform-wallet/src/wallet/identity/network/contacts.rspackages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rspackages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rspackages/rs-platform-wallet/src/wallet/identity/network/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/profile.rspackages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rspackages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rspackages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rspackages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rspackages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rspackages/rs-platform-wallet/src/wallet/platform_wallet.rspackages/rs-sdk-ffi/src/dashpay/contact_request.rspackages/rs-sdk/src/platform/dashpay/contact_request.rs
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## v3.1-dev #3841 +/- ##
=============================================
+ Coverage 52.54% 87.20% +34.65%
=============================================
Files 11 2632 +2621
Lines 1707 327792 +326085
=============================================
+ Hits 897 285848 +284951
- Misses 810 41944 +41134
🚀 New features to boost your workflow:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
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; }`).
…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.
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift (1)
421-465: ⚡ Quick winAlso assert the payment rows roll back in this atomicity test.
The doc comment says a mid-round
persistDashpayPaymentswrite must ride the open changeset and roll back with it, but the test only checksPersistentDashpayContactRequest. If payment persistence starts auto-saving again, this still passes. Add aPersistentDashpayPaymentfetch before and afterendChangeset(..., 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
📒 Files selected for processing (42)
docs/dashpay/SPEC.mdpackages/rs-platform-wallet-ffi/src/contact_persistence.rspackages/rs-platform-wallet-ffi/src/dashpay_payment.rspackages/rs-platform-wallet-ffi/src/dashpay_sync.rspackages/rs-platform-wallet-ffi/src/lib.rspackages/rs-platform-wallet-ffi/src/manager.rspackages/rs-platform-wallet-ffi/src/persistence.rspackages/rs-platform-wallet/src/changeset/core_bridge.rspackages/rs-platform-wallet/src/error.rspackages/rs-platform-wallet/src/manager/attach_seed.rspackages/rs-platform-wallet/src/manager/dashpay_sync.rspackages/rs-platform-wallet/src/manager/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/contacts.rspackages/rs-platform-wallet/src/wallet/identity/network/dashpay_sync.rspackages/rs-platform-wallet/src/wallet/identity/network/mod.rspackages/rs-platform-wallet/src/wallet/identity/network/payments.rspackages/rs-sdk-ffi/src/sdk.rspackages/rs-sdk/src/platform/dashpay/contact_request_queries.rspackages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swiftpackages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayPayment.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerDashPaySync.swiftpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactDetailView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayContactMeta.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/SendDashPayPaymentSheet.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FriendsView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleAppUITests/DashPayTabUITests.swiftpackages/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
There was a problem hiding this comment.
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 winScope the persisted active-identity key by network.
dashpay.activeIdentityIdis shared across every network, so selecting an identity on testnet/devnet overwrites the remembered choice for mainnet too. When the user switches back,activeIdentityfalls 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 winReset identity-scoped state before loading the next identity.
optimisticSentIdsandownProfilestill survive an identity switch, andloadOwnProfileFromCache()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 previousownProfilein 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
📒 Files selected for processing (4)
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swiftpackages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift
💤 Files with no reviewable changes (1)
- packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
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.
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
left a comment
There was a problem hiding this comment.
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.)
…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
left a comment
There was a problem hiding this comment.
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.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Incremental review at a425f70: the latest delta is an internal refactor that moves DashPay sync orchestration into DashPaySyncManager and introduces no new defects, but all 10 prior findings from df5f03e still reproduce verbatim, including the blocking contact-profile chunk query bug that prevents the new profile sync from returning any results on live DAPI. Carrying every STILL VALID prior finding forward; only codex agents produced output this run (claude lanes were unparseable).
🔴 1 blocking | 🟡 9 suggestion(s)
10 additional finding(s)
blocking: Contact-profile chunk query omits required orderBy for In clause
packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 752)
fetch_contact_profiles_chunk builds $ownerId In [...] with order_by_clauses: vec![]. Drive treats In as a range operator and requires a matching orderBy — the unique ownerId index does not bypass that validation, so DAPI rejects the query with the missing-order-by-for-range error. The caller logs the chunk failure and continues, so the new DashPay contact-profile sync silently fails for every owner and the UI never gets remote profiles. Add an ascending $ownerId order clause.
order_by_clauses: vec![dash_sdk::drive::query::OrderClause {
field: "$ownerId".to_string(),
ascending: true,
}],
suggestion: established_contact_get_note leaves out_note undefined on early returns
packages/rs-platform-wallet-ffi/src/established_contact.rs (line 156)
The C ABI validates that out_note is non-null, then performs fallible storage lookup, option unwrap, and CString construction before writing the output pointer. NotFound, no-note, or interior-NUL paths return without defining *out_note, so a foreign caller that did not pre-zero the slot can read or free a stale pointer through platform_wallet_string_free. The sibling alias getter already pre-nulls its out-param; this getter should match.
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: Incoming requests load sender-controlled avatars before consent
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift (line 119)
sync_contact_profiles fetches profiles for managed.incoming_contact_requests.keys() (profile.rs:640-642), and ContactRequestsView passes cachedProfile(row.contactIdentityId)?.avatarUrl into IncomingRequestRow / DashPayAvatarView, which loads via SwiftUI AsyncImage. An unsolicited sender can publish a per-victim HTTPS avatar URL, send a contact request, and learn when the victim opens the Requests tab from the outbound image fetch — before any accept/ignore action. Suppress remote avatar URLs for pending incoming senders, or drop avatarUrl from the Rust→Swift projection until the contact is established.
suggestion: DashPay profile getters leave owned out-params undefined on early returns
packages/rs-platform-wallet-ffi/src/dashpay_profile.rs (line 90)
managed_identity_get_dashpay_profile, platform_wallet_get_dashpay_profile, and platform_wallet_get_contact_profile validate out_profile/out_has_profile but only assign them after fallible identifier parsing and handle/storage lookups. DashPayProfileFFI contains owned C string pointers later released by dashpay_profile_ffi_free, so a foreign caller that reuses an uninitialized slot can free garbage or stale pointers when a NotFound path fires. Initialize *out_profile = DashPayProfileFFI::empty() and *out_has_profile = false immediately after pointer validation in all three functions.
suggestion: InstantSend transaction records are dropped on restore
packages/rs-platform-wallet-ffi/src/persistence.rs (line 2125)
Swift PlatformWalletPersistenceHandler.getCoreTxRecordCallback returns context kind 1 along with transaction bytes for InstantSend rows, but FFIPersister maps context_kind == 1 to Ok(None) before decoding them. The DashPay sent-payment reconciliation path treats InstantSend as a display-ready state, so a payment persisted after InstantSend but before any block context disappears across the boundary on relaunch and remains stuck pending. Return a recoverable context when the transaction bytes are present, or expose a separate persisted state reconciliation can consume without the IS-lock blob.
suggestion: Confirmed-absent contact profiles cannot delete persisted rows
packages/rs-platform-wallet-ffi/src/identity_persistence.rs (line 597)
ContactProfileEntry { profile: None } represents a confirmed absence, but allocate_contact_profile_rows skips those entries entirely (line 610-612). Swift only ever receives present profiles and restore rehydrates every persisted row as profile: Some(...), so if a contact removes their on-chain DashPay profile the stale avatarUrl (potentially attacker-controlled) survives in SwiftData and reappears after cold start, keeping the pre-consent tracking channel alive after the public profile is gone. Project explicit delete/removal intents across the FFI boundary for absent profiles.
suggestion: autoAcceptProof is copied before validating the proof size
packages/rs-platform-wallet-ffi/src/dashpay.rs (line 241)
platform_wallet_send_contact_request_with_signer reads auto_accept_proof_len bytes into a Vec via slice::from_raw_parts before the DashPay proof-size contract (38–102 bytes) is enforced. A malformed binding or import path can trigger oversized allocation or an out-of-bounds FFI read before the proof is rejected. Reject lengths outside 38..=102 before forming the slice.
let proof: Option<Vec<u8>> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 {
None
} else {
if !(38..=102).contains(&auto_accept_proof_len) {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!(
"autoAcceptProof must be 38-102 bytes, got {}",
auto_accept_proof_len
),
);
}
Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec())
};
suggestion: changeset-end callback failure is not propagated into round_success
packages/rs-platform-wallet-ffi/src/persistence.rs (line 1493)
Per-kind callbacks set round_success = false on failure, but the final on_changeset_end_fn result is only printed to stderr (line 1495-1497). If the Swift commit fails or rolls back during changeset-end, Rust still sees round_success == true, returns success, and merges the changeset into its long-lived pending accumulator — durable SwiftData state and Rust in-memory pending state silently diverge. Treat a non-zero changeset-end result as a failed round before the !round_success check (and have Swift propagate save failure as a non-zero return).
suggestion: Profile refresh timestamps are not persisted when content is unchanged
packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 588)
apply_fetched_profile refreshes checked_at_ms in memory on every successful fetch but returns true only when profile content changes; sync_contact_profiles invokes the persister only when any_changed is true. Unchanged present profiles and confirmed-absent negative-cache refreshes never reach SwiftData, so after restart the stale checkedAtMs is restored and the client can refetch immediately, defeating both the refresh window and the negative-cache backoff across cold starts. Either return a separate needs_persist signal for timestamp-only refreshes or persist on every successful refresh.
suggestion: Contact-request pagination has no per-sweep budget
packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 41)
fetch_contact_requests_paginated drains every matching contactRequest page until exhaustion, accumulating all documents in memory and exposing no continuation. ContactRequest documents are public, so a hostile sender can spam a target identity and force cold-start, restore, unignore, or the recurring DashPay sync loop to spend unbounded time and proof-verification work in one sweep, starving other wallet sync. Add a per-sweep document or elapsed-time cap with a resumable cursor (or at minimum a defensive threshold that aborts and warns).
🤖 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.
- [BLOCKING] In `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs`:752-766: Contact-profile chunk query omits required orderBy for In clause
fetch_contact_profiles_chunk builds `$ownerId In [...]` with `order_by_clauses: vec![]`. Drive treats `In` as a range operator and requires a matching orderBy — the unique ownerId index does not bypass that validation, so DAPI rejects the query with the missing-order-by-for-range error. The caller logs the chunk failure and continues, so the new DashPay contact-profile sync silently fails for every owner and the UI never gets remote profiles. Add an ascending `$ownerId` order clause.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:156-169: established_contact_get_note leaves out_note undefined on early returns
The C ABI validates that `out_note` is non-null, then performs fallible storage lookup, option unwrap, and CString construction before writing the output pointer. NotFound, no-note, or interior-NUL paths return without defining `*out_note`, so a foreign caller that did not pre-zero the slot can read or free a stale pointer through `platform_wallet_string_free`. The sibling alias getter already pre-nulls its out-param; this getter should match.
- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift`:119-128: Incoming requests load sender-controlled avatars before consent
sync_contact_profiles fetches profiles for `managed.incoming_contact_requests.keys()` (profile.rs:640-642), and ContactRequestsView passes `cachedProfile(row.contactIdentityId)?.avatarUrl` into IncomingRequestRow / DashPayAvatarView, which loads via SwiftUI AsyncImage. An unsolicited sender can publish a per-victim HTTPS avatar URL, send a contact request, and learn when the victim opens the Requests tab from the outbound image fetch — before any accept/ignore action. Suppress remote avatar URLs for pending incoming senders, or drop avatarUrl from the Rust→Swift projection until the contact is established.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:90-185: DashPay profile getters leave owned out-params undefined on early returns
managed_identity_get_dashpay_profile, platform_wallet_get_dashpay_profile, and platform_wallet_get_contact_profile validate `out_profile`/`out_has_profile` but only assign them after fallible identifier parsing and handle/storage lookups. DashPayProfileFFI contains owned C string pointers later released by dashpay_profile_ffi_free, so a foreign caller that reuses an uninitialized slot can free garbage or stale pointers when a NotFound path fires. Initialize `*out_profile = DashPayProfileFFI::empty()` and `*out_has_profile = false` immediately after pointer validation in all three functions.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/persistence.rs`:2125-2133: InstantSend transaction records are dropped on restore
Swift PlatformWalletPersistenceHandler.getCoreTxRecordCallback returns context kind 1 along with transaction bytes for InstantSend rows, but FFIPersister maps `context_kind == 1` to `Ok(None)` before decoding them. The DashPay sent-payment reconciliation path treats InstantSend as a display-ready state, so a payment persisted after InstantSend but before any block context disappears across the boundary on relaunch and remains stuck pending. Return a recoverable context when the transaction bytes are present, or expose a separate persisted state reconciliation can consume without the IS-lock blob.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/identity_persistence.rs`:597-641: Confirmed-absent contact profiles cannot delete persisted rows
`ContactProfileEntry { profile: None }` represents a confirmed absence, but allocate_contact_profile_rows skips those entries entirely (line 610-612). Swift only ever receives present profiles and restore rehydrates every persisted row as `profile: Some(...)`, so if a contact removes their on-chain DashPay profile the stale avatarUrl (potentially attacker-controlled) survives in SwiftData and reappears after cold start, keeping the pre-consent tracking channel alive after the public profile is gone. Project explicit delete/removal intents across the FFI boundary for absent profiles.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:241-245: autoAcceptProof is copied before validating the proof size
platform_wallet_send_contact_request_with_signer reads `auto_accept_proof_len` bytes into a Vec via `slice::from_raw_parts` before the DashPay proof-size contract (38–102 bytes) is enforced. A malformed binding or import path can trigger oversized allocation or an out-of-bounds FFI read before the proof is rejected. Reject lengths outside 38..=102 before forming the slice.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/persistence.rs`:1493-1504: changeset-end callback failure is not propagated into round_success
Per-kind callbacks set `round_success = false` on failure, but the final `on_changeset_end_fn` result is only printed to stderr (line 1495-1497). If the Swift commit fails or rolls back during changeset-end, Rust still sees `round_success == true`, returns success, and merges the changeset into its long-lived pending accumulator — durable SwiftData state and Rust in-memory pending state silently diverge. Treat a non-zero changeset-end result as a failed round before the `!round_success` check (and have Swift propagate save failure as a non-zero return).
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs`:588-731: Profile refresh timestamps are not persisted when content is unchanged
apply_fetched_profile refreshes `checked_at_ms` in memory on every successful fetch but returns `true` only when profile content changes; sync_contact_profiles invokes the persister only when `any_changed` is true. Unchanged present profiles and confirmed-absent negative-cache refreshes never reach SwiftData, so after restart the stale `checkedAtMs` is restored and the client can refetch immediately, defeating both the refresh window and the negative-cache backoff across cold starts. Either return a separate `needs_persist` signal for timestamp-only refreshes or persist on every successful refresh.
- [SUGGESTION] In `packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs`:41-112: Contact-request pagination has no per-sweep budget
fetch_contact_requests_paginated drains every matching contactRequest page until exhaustion, accumulating all documents in memory and exposing no continuation. ContactRequest documents are public, so a hostile sender can spam a target identity and force cold-start, restore, unignore, or the recurring DashPay sync loop to spend unbounded time and proof-verification work in one sweep, starving other wallet sync. Add a per-sweep document or elapsed-time cap with a resumable cursor (or at minimum a defensive threshold that aborts and warns).
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.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Incremental review of delta a425f70..c583c78: prior-1 (contact-profile chunk query missing orderBy) is FIXED with an extracted contact_profiles_chunk_query plus regression test. prior-5 is now INTENTIONALLY DEFERRED — the code comment at persistence.rs:2127-2132 explicitly documents that InstantSend rows are treated as miss so the SPV wait path completes the proof. The remaining eight prior findings (prior-2, prior-3, prior-4, prior-6, prior-7, prior-8, prior-9, prior-10) are STILL VALID and carried forward; no new in-scope issues were introduced in this delta. Only suggestion-level FFI/security/correctness hardening remains, so the review action is COMMENT.
🟡 8 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-platform-wallet-ffi/src/dashpay.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay.rs:241-245: Validate auto_accept_proof length before crossing the FFI boundary
`platform_wallet_send_contact_request_with_signer` calls `std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec()` as soon as the pointer is non-null and the length is non-zero. The DashPay proof-size contract (38–102 bytes) is only enforced later inside the SDK, after a full copy. A malformed binding or buggy/hostile caller passing an oversized or inconsistent (ptr, len) pair triggers an oversized allocation or an out-of-bounds FFI read before validation runs. Enforce the size range at the entry point, before constructing the slice, so the boundary stays safe regardless of downstream checks.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:1493-1504: Fold on_changeset_end_fn failure into round_success
Per-kind callbacks correctly set `round_success = false` on failure, but `on_changeset_end_fn`'s non-zero return is only printed via `eprintln!`. If the host's COMMIT for the round fails or rolls back at changeset-end, Rust still observes `round_success == true`, returns `Ok(())`, and merges the in-memory changeset into `pending` — leaving Rust's view of persisted state strictly ahead of what the backing store actually committed. Downgrade `round_success` on a non-zero changeset-end result so the existing `!round_success` branch triggers the rollback path.
In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:156-169: Initialize *out_note before fallible work in established_contact_get_note
`established_contact_get_note` validates `out_note` is non-null, then runs three fallible stages (storage `with_item` → `Option` → `CString::new`) before assigning `*out_note`. Each early return leaves the output pointer untouched. The sibling `established_contact_get_alias` at line 108 already establishes the right contract (`*out_alias = std::ptr::null_mut();` immediately after `check_ptr!`); apply the same one-liner here so a C caller that did not pre-zero its slot cannot observe stale memory and pass it to `platform_wallet_string_free`.
In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:88-185: Initialize DashPay profile out-params before fallible work
`managed_identity_get_dashpay_profile`, `platform_wallet_get_dashpay_profile`, and `platform_wallet_get_contact_profile` all validate `out_profile`/`out_has_profile` with `check_ptr!`, then perform fallible identifier parsing and storage lookups via `unwrap_result_or_return!` / `unwrap_option_or_return!` before assigning. `DashPayProfileFFI` owns four C-string pointers, so a C caller that frees a partially-populated struct on error frees stale or garbage pointers. Write `*out_profile = DashPayProfileFFI::empty(); *out_has_profile = false;` immediately after the null-checks in all three entry points so every error path is deterministic and safe to free.
In `packages/rs-platform-wallet-ffi/src/identity_persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/identity_persistence.rs:597-641: Confirmed-absent contact profiles cannot delete persisted Swift rows
`allocate_contact_profile_rows` `continue`s past every `ContactProfileEntry { profile: None }`, with the rationale that the negative cache rebuilds on the next sweep. But the FFI re-emits the full `contact_profile_rows` array on every persist, and Swift's persistence handler upserts only the rows it received — a previously-present-now-absent contact (someone who deleted their profile, or a now-confirmed absence) is invisible to Swift, so a stale display name / avatar URL row survives restarts indefinitely. Either emit absent entries with an `is_present: false` discriminator so Swift can DELETE, or pair the array with a per-row tombstone list.
In `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:588-731: Profile refresh timestamps are lost across restart when content is unchanged
`apply_fetched_profile` refreshes `checked_at_ms` in memory on every successful fetch but returns `true` only when profile *content* changes (line 594 compares profiles, not timestamps). `sync_contact_profiles` invokes the persister only when `any_changed` is true (line 718). Result: unchanged present profiles and confirmed-absent negative-cache refreshes — i.e. the steady-state case — keep their refreshed timestamp only in RAM. After restart the persisted `checked_at_ms` reverts to the last content-changing sweep, so `should_fetch_profile` over-refetches on cold start and re-pulls every cached profile (including unsolicited senders'). Persist on timestamp-only refresh past a debounce, or stamp `checked_at_ms` from a separate per-owner heartbeat row.
In `packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs:41-111: Contact-request pagination has no per-sweep budget
`fetch_contact_requests_paginated` drains every page until exhaustion and returns one combined `ContactRequestDocuments` map with no continuation token. ContactRequest documents are public and freely indexable by `toUserId`, so a hostile sender can spam a target identity with cheap throwaway requests and force every cold-start / restore sweep to fetch and hold the entire spam set in memory before any per-row logic runs. With the new 15s `DashPaySyncManager` cadence this becomes a recurring memory-amplification surface. Add a per-sweep page or document budget and surface a continuation cursor so the next sweep resumes instead of re-paginating from scratch.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift:119-128: Incoming-request rows fetch sender-controlled avatar URLs before consent
`sync_contact_profiles` now refreshes profiles for `incoming_contact_requests.keys()` every sweep, and `ContactRequestsView` passes `cachedProfile(row.contactIdentityId)?.avatarUrl` into `IncomingRequestRow` / `DashPayAvatarView`, which loads via SwiftUI `AsyncImage`. An unsolicited contact request therefore triggers an unauthenticated HTTPS GET to a URL the sender chose, before the user has accepted — leaking the recipient's IP and approximate online time and giving the sender a tracking pixel into anyone who opens the Requests tab. The `is_valid_avatar_url` filter only constrains the scheme/extension, not who controls the URL. Suppress the avatar (use the placeholder) or proxy through an allow-list until the request is accepted.
| @@ -239,14 +245,26 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( | |||
| }; | |||
There was a problem hiding this comment.
🟡 Suggestion: Validate auto_accept_proof length before crossing the FFI boundary
platform_wallet_send_contact_request_with_signer calls std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec() as soon as the pointer is non-null and the length is non-zero. The DashPay proof-size contract (38–102 bytes) is only enforced later inside the SDK, after a full copy. A malformed binding or buggy/hostile caller passing an oversized or inconsistent (ptr, len) pair triggers an oversized allocation or an out-of-bounds FFI read before validation runs. Enforce the size range at the entry point, before constructing the slice, so the boundary stays safe regardless of downstream checks.
| }; | |
| let proof: Option<Vec<u8>> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 { | |
| None | |
| } else { | |
| if !(38..=102).contains(&auto_accept_proof_len) { | |
| return PlatformWalletFFIResult::err( | |
| PlatformWalletFFIResultCode::InvalidArgument, | |
| "auto_accept_proof length must be between 38 and 102 bytes", | |
| ); | |
| } | |
| Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec()) | |
| }; |
source: ['claude', 'codex']
| @@ -1459,6 +1504,20 @@ impl PlatformWalletPersistence for FFIPersister { | |||
| } | |||
There was a problem hiding this comment.
🟡 Suggestion: Fold on_changeset_end_fn failure into round_success
Per-kind callbacks correctly set round_success = false on failure, but on_changeset_end_fn's non-zero return is only printed via eprintln!. If the host's COMMIT for the round fails or rolls back at changeset-end, Rust still observes round_success == true, returns Ok(()), and merges the in-memory changeset into pending — leaving Rust's view of persisted state strictly ahead of what the backing store actually committed. Downgrade round_success on a non-zero changeset-end result so the existing !round_success branch triggers the rollback path.
| } | |
| if let Some(cb) = self.callbacks.on_changeset_end_fn { | |
| let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), round_success) }; | |
| if result != 0 { | |
| eprintln!("Changeset-end callback returned error code {}", result); | |
| round_success = false; | |
| } | |
| } |
source: ['claude', 'codex']
| PlatformWalletFFIResult::ok() | ||
| } | ||
|
|
||
| /// Read the cached profile of a **contact** (by contact identity id) under | ||
| /// the given owner identity. `out_has_profile` is false when the owner has no | ||
| /// cached entry for that contact, or the entry is confirmed-absent (the | ||
| /// contact published no profile on Platform). Populated by the background | ||
| /// contact-profile sync; covers established contacts and pending senders. | ||
| #[no_mangle] | ||
| pub unsafe extern "C" fn platform_wallet_get_contact_profile( | ||
| wallet_handle: Handle, | ||
| owner_identity_id: *const u8, | ||
| contact_identity_id: *const u8, | ||
| out_profile: *mut DashPayProfileFFI, | ||
| out_has_profile: *mut bool, | ||
| ) -> PlatformWalletFFIResult { | ||
| check_ptr!(out_profile); | ||
| check_ptr!(out_has_profile); | ||
|
|
||
| let owner = unwrap_result_or_return!(unsafe { read_identifier(owner_identity_id) }); | ||
| let contact = unwrap_result_or_return!(unsafe { read_identifier(contact_identity_id) }); | ||
|
|
||
| let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { | ||
| let wm = wallet.wallet_manager().blocking_read(); | ||
| let info = wm.get_wallet_info(&wallet.wallet_id())?; | ||
| info.identity_manager | ||
| .managed_identity(&owner) | ||
| .and_then(|m| m.contact_profiles.get(&contact).cloned()) | ||
| }); | ||
| let entry = unwrap_option_or_return!(option); | ||
| match entry.and_then(|e| e.profile) { | ||
| Some(profile) => unsafe { | ||
| *out_profile = DashPayProfileFFI::from_profile(&profile); | ||
| *out_has_profile = true; | ||
| }, | ||
| None => unsafe { | ||
| *out_profile = DashPayProfileFFI::empty(); | ||
| *out_has_profile = false; | ||
| }, | ||
| } | ||
| PlatformWalletFFIResult::ok() | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Initialize DashPay profile out-params before fallible work
managed_identity_get_dashpay_profile, platform_wallet_get_dashpay_profile, and platform_wallet_get_contact_profile all validate out_profile/out_has_profile with check_ptr!, then perform fallible identifier parsing and storage lookups via unwrap_result_or_return! / unwrap_option_or_return! before assigning. DashPayProfileFFI owns four C-string pointers, so a C caller that frees a partially-populated struct on error frees stale or garbage pointers. Write *out_profile = DashPayProfileFFI::empty(); *out_has_profile = false; immediately after the null-checks in all three entry points so every error path is deterministic and safe to free.
source: ['claude', 'codex']
| fn allocate_contact_profile_rows( | ||
| contact_profiles: &std::collections::BTreeMap< | ||
| dpp::prelude::Identifier, | ||
| platform_wallet::ContactProfileEntry, | ||
| >, | ||
| ) -> (*const ContactProfileRowFFI, usize) { | ||
| if contact_profiles.is_empty() { | ||
| return (ptr::null(), 0); | ||
| } | ||
| let mut rows: Vec<ContactProfileRowFFI> = Vec::with_capacity(contact_profiles.len()); | ||
| for (contact_id, entry) in contact_profiles { | ||
| // Skip confirmed-absent entries — the negative cache is not | ||
| // persisted; it rebuilds on the next sweep. | ||
| let Some(profile) = entry.profile.as_ref() else { | ||
| continue; | ||
| }; | ||
| let (avatar_hash, avatar_hash_present) = match profile.avatar_hash { | ||
| Some(h) => (h, true), | ||
| None => ([0u8; 32], false), | ||
| }; | ||
| let (avatar_fingerprint, avatar_fingerprint_present) = match profile.avatar_fingerprint { | ||
| Some(f) => (f, true), | ||
| None => ([0u8; 8], false), | ||
| }; | ||
| rows.push(ContactProfileRowFFI { | ||
| contact_id: contact_id.to_buffer(), | ||
| display_name: optional_c_string(profile.display_name.as_deref()), | ||
| bio: optional_c_string(profile.bio.as_deref()), | ||
| avatar_url: optional_c_string(profile.avatar_url.as_deref()), | ||
| avatar_hash, | ||
| avatar_hash_present, | ||
| avatar_fingerprint, | ||
| avatar_fingerprint_present, | ||
| public_message: optional_c_string(profile.public_message.as_deref()), | ||
| checked_at_ms: entry.checked_at_ms, | ||
| }); | ||
| } | ||
| if rows.is_empty() { | ||
| // Every entry was confirmed-absent — nothing present to carry. | ||
| return (ptr::null(), 0); | ||
| } | ||
| let count = rows.len(); | ||
| let rows_ptr = Box::into_raw(rows.into_boxed_slice()) as *const ContactProfileRowFFI; | ||
| (rows_ptr, count) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Confirmed-absent contact profiles cannot delete persisted Swift rows
allocate_contact_profile_rows continues past every ContactProfileEntry { profile: None }, with the rationale that the negative cache rebuilds on the next sweep. But the FFI re-emits the full contact_profile_rows array on every persist, and Swift's persistence handler upserts only the rows it received — a previously-present-now-absent contact (someone who deleted their profile, or a now-confirmed absence) is invisible to Swift, so a stale display name / avatar URL row survives restarts indefinitely. Either emit absent entries with an is_present: false discriminator so Swift can DELETE, or pair the array with a per-row tombstone list.
source: ['claude', 'codex']
| .all_identities() | ||
| .into_iter() | ||
| .map(|i| i.id()) | ||
| .collect(); | ||
|
|
||
| own.iter() | ||
| .filter_map(|owner_id| { | ||
| let managed = info.identity_manager.managed_identity(owner_id)?; | ||
| let mut targets: std::collections::BTreeSet<Identifier> = | ||
| managed.established_contacts.keys().copied().collect(); | ||
| targets.extend(managed.incoming_contact_requests.keys().copied()); | ||
| let to_fetch: Vec<Identifier> = targets | ||
| .into_iter() | ||
| .filter(|id| !own.contains(id)) | ||
| .filter(|id| should_fetch_profile(managed.contact_profiles.get(id), now_ms)) | ||
| .collect(); | ||
| (!to_fetch.is_empty()).then_some((*owner_id, to_fetch)) | ||
| }) | ||
| .collect() | ||
| }; | ||
|
|
||
| if plan.is_empty() { | ||
| return Ok(0); | ||
| } | ||
|
|
||
| // 2. Fetch (no guard held). Per chunk: one `In` query over ≤IN_CAP | ||
| // owner ids; a chunk failure logs and continues so the others | ||
| // still land. An id present in the chunk but absent from the | ||
| // result is confirmed-absent (cached as `None` — the negative | ||
| // cache). | ||
| // One owner's fetched contacts: each contact id paired with its profile, or | ||
| // `None` when confirmed-absent (the negative cache). | ||
| type OwnerContactProfiles = Vec<(Identifier, Option<DashPayProfile>)>; | ||
| let mut results: Vec<(Identifier, OwnerContactProfiles)> = Vec::new(); | ||
| for (owner_id, to_fetch) in plan { | ||
| let mut owner_results: OwnerContactProfiles = Vec::new(); | ||
| for chunk in to_fetch.chunks(CONTACT_PROFILE_IN_CAP) { | ||
| match self | ||
| .fetch_contact_profiles_chunk(&dashpay_contract, chunk) | ||
| .await | ||
| { | ||
| Ok(found) => { | ||
| for id in chunk { | ||
| owner_results.push((*id, found.get(id).cloned().flatten())); | ||
| } | ||
| } | ||
| Err(e) => { | ||
| tracing::warn!( | ||
| owner = %owner_id, | ||
| error = %e, | ||
| "Failed to fetch a contact-profile chunk; will retry next sweep" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| if !owner_results.is_empty() { | ||
| results.push((owner_id, owner_results)); | ||
| } | ||
| } | ||
|
|
||
| // 3. Under the write guard: full-replace, persist-on-change. | ||
| let mut written = 0u32; | ||
| { | ||
| let mut wm = self.wallet_manager.write().await; | ||
| let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { | ||
| return Ok(0); | ||
| }; | ||
| for (owner_id, owner_results) in results { | ||
| let Some(managed) = info.identity_manager.managed_identity_mut(&owner_id) else { | ||
| continue; | ||
| }; | ||
| let mut any_changed = false; | ||
| for (contact_id, profile) in owner_results { | ||
| if apply_fetched_profile( | ||
| &mut managed.contact_profiles, | ||
| contact_id, | ||
| profile, | ||
| now_ms, | ||
| ) { | ||
| written += 1; | ||
| any_changed = true; | ||
| } | ||
| } | ||
| // Persist one changeset per owner, only when something changed — | ||
| // the refetch-all-each-sweep first cut stays a persistence | ||
| // fixpoint. A failed store self-heals on the next sweep. | ||
| if any_changed { | ||
| if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { | ||
| tracing::warn!( | ||
| owner = %owner_id, | ||
| error = %e, | ||
| "Failed to persist contact profiles; will retry next sweep" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Ok(written) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Profile refresh timestamps are lost across restart when content is unchanged
apply_fetched_profile refreshes checked_at_ms in memory on every successful fetch but returns true only when profile content changes (line 594 compares profiles, not timestamps). sync_contact_profiles invokes the persister only when any_changed is true (line 718). Result: unchanged present profiles and confirmed-absent negative-cache refreshes — i.e. the steady-state case — keep their refreshed timestamp only in RAM. After restart the persisted checked_at_ms reverts to the last content-changing sweep, so should_fetch_profile over-refetches on cold start and re-pulls every cached profile (including unsolicited senders'). Persist on timestamp-only refresh past a debounce, or stamp checked_at_ms from a separate per-owner heartbeat row.
source: ['claude', 'codex']
| async fn fetch_contact_requests_paginated( | ||
| &self, | ||
| filter_field: &str, | ||
| identity_id: Identifier, | ||
| limit: Option<u32>, | ||
| after_created_at: Option<u64>, | ||
| ) -> Result<ContactRequestDocuments, Error> { | ||
| // Fetch the DashPay contract | ||
| let dashpay_contract = self.fetch_dashpay_contract().await?; | ||
|
|
||
| // Query for sent contact requests (where this identity is the owner) | ||
| // Note: We need to filter by $ownerId to get only this identity's sent requests | ||
| let query = DocumentQuery { | ||
| select: drive::query::SelectProjection::documents(), | ||
| data_contract: dashpay_contract, | ||
| document_type_name: "contactRequest".to_string(), | ||
| where_clauses: vec![WhereClause { | ||
| field: "$ownerId".to_string(), | ||
| operator: WhereOperator::Equal, | ||
| value: platform_value!(identity_id), | ||
| }], | ||
| group_by: vec![], | ||
| having: vec![], | ||
| order_by_clauses: vec![], | ||
| limit: limit.unwrap_or(100), | ||
| start: None, | ||
| }; | ||
| let mut where_clauses = vec![WhereClause { | ||
| field: filter_field.to_string(), | ||
| operator: WhereOperator::Equal, | ||
| value: platform_value!(identity_id), | ||
| }]; | ||
| if let Some(after) = after_created_at { | ||
| where_clauses.push(WhereClause { | ||
| field: "$createdAt".to_string(), | ||
| operator: WhereOperator::GreaterThan, | ||
| value: platform_value!(after), | ||
| }); | ||
| } | ||
|
|
||
| let mut all: ContactRequestDocuments = Default::default(); | ||
| let mut start: Option<Start> = None; | ||
|
|
||
| // Fetch the documents | ||
| Document::fetch_many(self, query).await | ||
| loop { | ||
| let query = DocumentQuery { | ||
| select: drive::query::SelectProjection::documents(), | ||
| data_contract: dashpay_contract.clone(), | ||
| document_type_name: "contactRequest".to_string(), | ||
| where_clauses: where_clauses.clone(), | ||
| group_by: vec![], | ||
| having: vec![], | ||
| // Load-bearing: a bare secondary-index equality with no | ||
| // order-by is silently proven ABSENT by drive (observed | ||
| // against drive 4.0.0-rc.2: `toUserId ==` returned a verified | ||
| // empty result for an existing document). The clause also | ||
| // pins the query to the contract's `(field, $createdAt)` | ||
| // index, giving the deterministic order pagination relies on. | ||
| order_by_clauses: vec![OrderClause { | ||
| field: "$createdAt".to_string(), | ||
| ascending: true, | ||
| }], | ||
| limit: CONTACT_REQUEST_PAGE_SIZE, | ||
| start: start.clone(), | ||
| }; | ||
|
|
||
| let page = Document::fetch_many(self, query).await?; | ||
| let page_len = page.len(); | ||
| // The last document id in query order seeds the next page's | ||
| // cursor (distinct from the `$createdAt` high-water the caller | ||
| // tracks — this id cursor is ephemeral, per-loop). Relies on | ||
| // `Documents` being insertion-ordered (`IndexMap`) so `keys().last()` | ||
| // is the `$createdAt`-ascending last doc; a `BTreeMap` here would | ||
| // silently reorder by doc id and break pagination. | ||
| let last_id = page.keys().last().copied(); | ||
| for (id, doc) in page { | ||
| all.insert(id, doc); | ||
| } | ||
|
|
||
| // A short page proves exhaustion (a full page may have more). | ||
| if page_len < CONTACT_REQUEST_PAGE_SIZE as usize { | ||
| break; | ||
| } | ||
| match last_id { | ||
| Some(id) => start = Some(Start::StartAfter(id.to_buffer().to_vec())), | ||
| None => break, | ||
| } | ||
| } | ||
|
|
||
| Ok(all) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Contact-request pagination has no per-sweep budget
fetch_contact_requests_paginated drains every page until exhaustion and returns one combined ContactRequestDocuments map with no continuation token. ContactRequest documents are public and freely indexable by toUserId, so a hostile sender can spam a target identity with cheap throwaway requests and force every cold-start / restore sweep to fetch and hold the entire spam set in memory before any per-row logic runs. With the new 15s DashPaySyncManager cadence this becomes a recurring memory-amplification surface. Add a per-sweep page or document budget and surface a continuation cursor so the next sweep resumes instead of re-paginating from scratch.
source: ['claude', 'codex']
| ForEach(incomingPending, id: \.contactIdentityId) { row in | ||
| IncomingRequestRow( | ||
| displayName: displayName(for: row.contactIdentityId), | ||
| avatarUrl: cachedProfile(row.contactIdentityId)?.avatarUrl, | ||
| createdAtMillis: row.createdAtMillis, | ||
| isInFlight: inFlightIds.contains(row.contactIdentityId), | ||
| errorMessage: rowErrors[row.contactIdentityId], | ||
| onAccept: { accept(contactId: row.contactIdentityId) }, | ||
| onIgnore: { ignore(contactId: row.contactIdentityId) } | ||
| ) |
There was a problem hiding this comment.
🟡 Suggestion: Incoming-request rows fetch sender-controlled avatar URLs before consent
sync_contact_profiles now refreshes profiles for incoming_contact_requests.keys() every sweep, and ContactRequestsView passes cachedProfile(row.contactIdentityId)?.avatarUrl into IncomingRequestRow / DashPayAvatarView, which loads via SwiftUI AsyncImage. An unsolicited contact request therefore triggers an unauthenticated HTTPS GET to a URL the sender chose, before the user has accepted — leaking the recipient's IP and approximate online time and giving the sender a tracking pixel into anyone who opens the Requests tab. The is_valid_avatar_url filter only constrains the scheme/extension, not who controls the URL. Suppress the avatar (use the placeholder) or proxy through an allow-list until the request is accepted.
source: ['claude', 'codex']
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ds-unlock signal, Rust+FFI) Foundation for the needs-unlock / verify-failed UI signal (DashPay seedless wallets). Surfaces how many contacts are waiting for a Keychain unlock to finish payment-account setup, so the UI can show a banner instead of the current silent print(). - platform-wallet: `IdentityWallet::pending_contact_crypto_count()` reads the in-memory deferred-crypto queue via `count_account_build_ops`, counting only RegisterReceiving / RegisterExternal. ContactInfoDecrypt is excluded because it is re-enqueued on every signerless sweep (no already-decrypted gate), so counting it would make the signal a permanent ">0" and re-trip the banner ~15s after every unlock. Account-build ops converge to 0 once drained. - platform-wallet-ffi: `platform_wallet_pending_contact_crypto_count` getter (signerless, pollable), mirroring the drain FFI minus the signer. - Fix doc-rot on PlatformWalletInfo.pending_contact_crypto: cold-load restore is blocked upstream; the queue is in-memory + re-enqueued, not restored. Tests (the exclusion is non-tautological — fails against a naive len()): - account_build_count_excludes_contact_info_decrypt (platform-wallet) - pending_contact_crypto_count null-out / unknown-handle (platform-wallet-ffi) Design + 4-lens review in docs/dashpay/NEEDS_UNLOCK_SIGNAL_SPEC.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consumes the new platform_wallet_pending_contact_crypto_count getter and the existing verify/drain paths to show a per-wallet banner instead of the silent print() that was the only signal before. PlatformWalletManager: - DashPayUnlockStatus (one Equatable per-wallet snapshot: pendingAccountBuilds, seedMismatch, draining) + @published dashPayUnlockStatus, keyed by walletId. One struct, not parallel dicts, per the one-snapshot-per-wallet convention. - pendingAccountBuildCount(for:) thin FFI wrapper; refreshed in the 1 Hz poller (per-wallet, gated on change) with ghost-key pruning for unloaded wallets. - seedMismatch set from the verify FFI result, scoped to that call only — so the 32-byte walletId precondition (also .invalidParameter) can't be mistaken for a seed mismatch. Set at unlock; loadFromPersistor's handling is unchanged. - drain: an in-flight guard (don't stack a second drain on a banner Unlock tap), a draining flag for the UI, and a MainActor hop so the detached drain's failure lands on lastError + clears draining (no more print()-only swallow). - deleteWallet purges the status so a re-created wallet (deterministic id) can't inherit a stale banner. DashPayTabView: a banner between the balance row and the segment Picker. Priority red seedMismatch (signing disabled) > orange draining ("Finishing…", no action) > orange "N contact(s) waiting to finish setup" + Unlock. Count is wallet-scoped (may include sibling identities), so copy says "waiting." Design + 4-lens review: docs/dashpay/NEEDS_UNLOCK_SIGNAL_SPEC.md. SwiftDashSDK + SwiftExampleApp build green (iPhone 17 Pro sim). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… verified) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The API-narrowing prerequisite for removing it is already done (all DashPay signer params are concrete KeychainSigner; zero any-Signer call sites), leaving only a harmless ~6-line vestigial canSign shim. Not worth a churn commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Incremental + cumulative review at 8b4f110. The latest delta (pending_contact_crypto_count FFI + Swift needs-unlock banner) is small, well-formed, tested, and introduces no new in-scope defects. All eight prior findings against c583c78 remain STILL VALID against head — none of the cited lines were modified by this delta. They are carried forward unchanged. No findings warrant blocking; review_action is COMMENT.
🟡 8 suggestion(s)
GitHub cannot serve this PR diff through the review API because it exceeds the 20,000-line diff limit, so I’m posting the verified findings as a top-level exact-SHA review body instead of inline comments.
Findings
SUGGESTION [carried prior-1, STILL VALID] Validate auto_accept_proof length before copying across the FFI boundary
packages/rs-platform-wallet-ffi/src/dashpay.rs:241-245
Verified at head: platform_wallet_send_contact_request_with_signer still calls std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec() as soon as the pointer is non-null and the length is non-zero. The DashPay 38–102 byte proof contract is enforced only inside the Rust SDK validation downstream, after the unsafe read and allocation have already happened. A malformed C ABI caller passing auto_accept_proof_len = isize::MAX (or a short buffer with a large length) forces an oversized allocation or out-of-bounds read across the boundary before any check runs. Add a (38..=102).contains(&auto_accept_proof_len) precondition immediately after the null/zero gate and return ErrorInvalidParameter.
let proof: Option<Vec<u8>> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 {
None
} else {
if !(38..=102).contains(&auto_accept_proof_len) {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!(
"auto_accept_proof_len must be between 38 and 102 bytes, got {}",
auto_accept_proof_len
),
);
}
Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec())
};
SUGGESTION [carried prior-2, STILL VALID] Fold on_changeset_end_fn failure into round_success
packages/rs-platform-wallet-ffi/src/persistence.rs:1493-1504
Verified at head (persistence.rs:1493–1497): per-kind callbacks correctly set round_success = false on failure, but on_changeset_end_fn's non-zero return is only eprintln!'d and is not folded into round_success. The end-of-round callback is documented as the host's COMMIT/ROLLBACK signal. If the host's atomic SwiftData commit fails or rolls back at this exact step, Rust still observes round_success == true, returns Ok(()), merges the changeset into pending (lines 1521–1525), and fires on_store_fn — so an in-memory wallet mutation is treated as durably persisted despite the host-side rollback. Set round_success = false (in addition to logging) when the end-of-round callback returns non-zero, so the existing if !round_success early-return triggers.
if let Some(cb) = self.callbacks.on_changeset_end_fn {
let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), round_success) };
if result != 0 {
eprintln!("Changeset-end callback returned error code {}", result);
round_success = false;
}
}
SUGGESTION [carried prior-3, STILL VALID] Initialize *out_note before fallible work in established_contact_get_note
packages/rs-platform-wallet-ffi/src/established_contact.rs:156-169
Verified at head. After check_ptr!(out_note), established_contact_get_note runs three fallible stages (with_item storage lookup, Option unwrap for the absent-note case, CString::new) before assigning *out_note. Each early return via unwrap_*_or_return! leaves the caller's out-pointer untouched. The sibling established_contact_get_alias (lines 103–117) correctly sets *out_alias = std::ptr::null_mut() immediately after the null check; align get_note with that pattern so C/Swift callers don't observe stale pointers on error paths.
check_ptr!(out_note);
unsafe { *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 prior-4, STILL VALID] Initialize DashPay profile out-params before fallible work
packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:88-185
Verified at head. managed_identity_get_dashpay_profile (88–112), platform_wallet_get_dashpay_profile (114–145), and platform_wallet_get_contact_profile (152–185) all check_ptr! out_profile/out_has_profile and only assign them on the success branch of match. Any early return through unwrap_option_or_return!/unwrap_result_or_return! (handle missing, malformed identifier, managed identity not found) leaves the owned DashPayProfileFFI (which holds four CString::into_raw pointers on the success path) and the bool undefined. C/Swift callers must defensively pre-zero — make the contract self-enforcing by initializing *out_profile = DashPayProfileFFI::empty() and *out_has_profile = false immediately after the null checks, mirroring get_alias. The newly added platform_wallet_pending_contact_crypto_count (dashpay.rs:670–686) follows the same leave-untouched-on-error shape and is explicitly pinned by a unit test, so the FFI surface is now inconsistent across getters; standardize on pre-init while you are here.
SUGGESTION [carried prior-5, STILL VALID] Confirmed-absent contact profiles cannot delete persisted Swift rows
packages/rs-platform-wallet-ffi/src/identity_persistence.rs:597-641
Verified at head. allocate_contact_profile_rows (identity_persistence.rs:607–612) still continues past every ContactProfileEntry { profile: None }. The negative cache rebuilds in Rust on the next sweep — but the FFI re-emits the full contact_profile_rows array on every persist and Swift's persistence handler upserts the rows it receives; confirmed absence is never delivered as a delete or tombstone. A profile row persisted before a contact removed/rotated their profile therefore survives indefinitely in SwiftData, including a stale sender-controlled avatar_url (compounds the privacy concern in prior-8). Either emit an absent marker row (e.g. present == false) and have Swift treat it as a delete, or carry the absent ids in a sidecar tombstone slice.
SUGGESTION [carried prior-6, STILL VALID] Profile refresh timestamps are lost across restart when content is unchanged
packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:588-731
Verified at head. apply_fetched_profile (588–603) refreshes checked_at_ms in memory unconditionally but returns changed only when content differs (line 594). sync_contact_profiles only persists when any_changed is true (lines 715–726). Successful unchanged-profile refreshes and negative-cache refreshes therefore never persist the new timestamp. After process restart the wallet replays them as stale on the next sweep, re-running the same In-chunk fetch indefinitely — amplified by every spammed sender. Either persist when checked_at_ms crosses the staleness threshold, or carry a coarser per-owner 'last successful sweep' watermark through the persister.
SUGGESTION [carried prior-7, STILL VALID] Contact-request pagination has no per-sweep budget
packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs:41-111
Verified at head. fetch_contact_requests_paginated still drains every 100-doc page until exhaustion (while page_len == CONTACT_REQUEST_PAGE_SIZE) and accumulates the full result into a single ContactRequestDocuments map before the caller can apply per-row ignore/dedup logic. contactRequest documents are public and indexable by toUserId, so a sender (or many) can cheaply spam a target identity with unsolicited requests. Every cold-start, restore-from-seed sweep, and now every interval of the recurring DashPaySyncManager pays the full memory/CPU cost. Add a per-sweep page or document cap, return a resumable continuation cursor so the next sweep continues from where the previous stopped, and consider a per-(owner, counterparty) high-water mark on the caller side.
SUGGESTION [carried prior-8, STILL VALID] Incoming-request rows fetch sender-controlled avatar URLs before consent
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift:119-128
Verified at head. sync_contact_profiles refreshes profiles for incoming_contact_requests.keys() (rs-platform-wallet/.../profile.rs:642) and IncomingRequestRow (ContactRequestsView.swift:119–128) passes cachedProfile(row.contactIdentityId)?.avatarUrl straight into DashPayAvatarView, which renders via SwiftUI AsyncImage(url:). An unsolicited contact request therefore triggers an unauthenticated HTTPS GET to a sender-chosen URL the moment the Requests tab renders — leaking recipient IP, user-agent, and an 'online and looking' timing signal before the user accepts (or even sees) the request. The Rust-side https/length filter narrows the host shape but does not gate disclosure on consent. Gate avatar/network fetches behind explicit Accept (or a user-revealed 'show preview' tap) for incoming-pending senders.
🤖 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-ffi/src/dashpay.rs:241-245`: [carried prior-1, STILL VALID] Validate auto_accept_proof length before copying across the FFI boundary
Verified at head: `platform_wallet_send_contact_request_with_signer` still calls `std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec()` as soon as the pointer is non-null and the length is non-zero. The DashPay 38–102 byte proof contract is enforced only inside the Rust SDK validation downstream, after the unsafe read and allocation have already happened. A malformed C ABI caller passing `auto_accept_proof_len = isize::MAX` (or a short buffer with a large length) forces an oversized allocation or out-of-bounds read across the boundary before any check runs. Add a `(38..=102).contains(&auto_accept_proof_len)` precondition immediately after the null/zero gate and return `ErrorInvalidParameter`.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/persistence.rs:1493-1504`: [carried prior-2, STILL VALID] Fold on_changeset_end_fn failure into round_success
Verified at head (persistence.rs:1493–1497): per-kind callbacks correctly set `round_success = false` on failure, but `on_changeset_end_fn`'s non-zero return is only `eprintln!`'d and is not folded into `round_success`. The end-of-round callback is documented as the host's COMMIT/ROLLBACK signal. If the host's atomic SwiftData commit fails or rolls back at this exact step, Rust still observes `round_success == true`, returns `Ok(())`, merges the changeset into `pending` (lines 1521–1525), and fires `on_store_fn` — so an in-memory wallet mutation is treated as durably persisted despite the host-side rollback. Set `round_success = false` (in addition to logging) when the end-of-round callback returns non-zero, so the existing `if !round_success` early-return triggers.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/established_contact.rs:156-169`: [carried prior-3, STILL VALID] Initialize *out_note before fallible work in established_contact_get_note
Verified at head. After `check_ptr!(out_note)`, `established_contact_get_note` runs three fallible stages (`with_item` storage lookup, `Option` unwrap for the absent-note case, `CString::new`) before assigning `*out_note`. Each early return via `unwrap_*_or_return!` leaves the caller's out-pointer untouched. The sibling `established_contact_get_alias` (lines 103–117) correctly sets `*out_alias = std::ptr::null_mut()` immediately after the null check; align `get_note` with that pattern so C/Swift callers don't observe stale pointers on error paths.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:88-185`: [carried prior-4, STILL VALID] Initialize DashPay profile out-params before fallible work
Verified at head. `managed_identity_get_dashpay_profile` (88–112), `platform_wallet_get_dashpay_profile` (114–145), and `platform_wallet_get_contact_profile` (152–185) all `check_ptr!` `out_profile`/`out_has_profile` and only assign them on the success branch of `match`. Any early return through `unwrap_option_or_return!`/`unwrap_result_or_return!` (handle missing, malformed identifier, managed identity not found) leaves the owned `DashPayProfileFFI` (which holds four `CString::into_raw` pointers on the success path) and the `bool` undefined. C/Swift callers must defensively pre-zero — make the contract self-enforcing by initializing `*out_profile = DashPayProfileFFI::empty()` and `*out_has_profile = false` immediately after the null checks, mirroring `get_alias`. The newly added `platform_wallet_pending_contact_crypto_count` (dashpay.rs:670–686) follows the same leave-untouched-on-error shape and is explicitly pinned by a unit test, so the FFI surface is now inconsistent across getters; standardize on pre-init while you are here.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/identity_persistence.rs:597-641`: [carried prior-5, STILL VALID] Confirmed-absent contact profiles cannot delete persisted Swift rows
Verified at head. `allocate_contact_profile_rows` (identity_persistence.rs:607–612) still `continue`s past every `ContactProfileEntry { profile: None }`. The negative cache rebuilds in Rust on the next sweep — but the FFI re-emits the full `contact_profile_rows` array on every persist and Swift's persistence handler upserts the rows it receives; confirmed absence is never delivered as a delete or tombstone. A profile row persisted before a contact removed/rotated their profile therefore survives indefinitely in SwiftData, including a stale sender-controlled `avatar_url` (compounds the privacy concern in prior-8). Either emit an absent marker row (e.g. `present == false`) and have Swift treat it as a delete, or carry the absent ids in a sidecar tombstone slice.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:588-731`: [carried prior-6, STILL VALID] Profile refresh timestamps are lost across restart when content is unchanged
Verified at head. `apply_fetched_profile` (588–603) refreshes `checked_at_ms` in memory unconditionally but returns `changed` only when content differs (line 594). `sync_contact_profiles` only persists when `any_changed` is true (lines 715–726). Successful unchanged-profile refreshes and negative-cache refreshes therefore never persist the new timestamp. After process restart the wallet replays them as stale on the next sweep, re-running the same `In`-chunk fetch indefinitely — amplified by every spammed sender. Either persist when `checked_at_ms` crosses the staleness threshold, or carry a coarser per-owner 'last successful sweep' watermark through the persister.
- [SUGGESTION] In `packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs:41-111`: [carried prior-7, STILL VALID] Contact-request pagination has no per-sweep budget
Verified at head. `fetch_contact_requests_paginated` still drains every 100-doc page until exhaustion (`while page_len == CONTACT_REQUEST_PAGE_SIZE`) and accumulates the full result into a single `ContactRequestDocuments` map before the caller can apply per-row ignore/dedup logic. `contactRequest` documents are public and indexable by `toUserId`, so a sender (or many) can cheaply spam a target identity with unsolicited requests. Every cold-start, restore-from-seed sweep, and now every interval of the recurring `DashPaySyncManager` pays the full memory/CPU cost. Add a per-sweep page or document cap, return a resumable continuation cursor so the next sweep continues from where the previous stopped, and consider a per-(owner, counterparty) high-water mark on the caller side.
- [SUGGESTION] In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift:119-128`: [carried prior-8, STILL VALID] Incoming-request rows fetch sender-controlled avatar URLs before consent
Verified at head. `sync_contact_profiles` refreshes profiles for `incoming_contact_requests.keys()` (rs-platform-wallet/.../profile.rs:642) and `IncomingRequestRow` (ContactRequestsView.swift:119–128) passes `cachedProfile(row.contactIdentityId)?.avatarUrl` straight into `DashPayAvatarView`, which renders via SwiftUI `AsyncImage(url:)`. An unsolicited contact request therefore triggers an unauthenticated HTTPS GET to a sender-chosen URL the moment the Requests tab renders — leaking recipient IP, user-agent, and an 'online and looking' timing signal before the user accepts (or even sees) the request. The Rust-side https/length filter narrows the host shape but does not gate disclosure on consent. Gate avatar/network fetches behind explicit Accept (or a user-revealed 'show preview' tap) for incoming-pending senders.
…oritized Full DIP-15 + reference-client audit (dashj / android-dashpay / dash-wallet) of the dormant autoAcceptProof / auto_accept.rs. Verdict: unimplemented in every reference client (zero refs in dashj; field+unused-builder with 0 callers in android-dashpay; dormant DB pass-through in dash-wallet whose QR scanner only does payments). No interoperable counterparty, and DIP-15 leaves verification / signed-byte serialization / expiry / replay undefined. Our crypto also models the wrong actor for the scanner side. Recommendation: don't wire it; keep auto_accept.rs tested+dormant. The shipped, interoperable 'scan/link to onboard a friend' feature is Invitations (DIP-13 sub-feature 3', dashpay://invite + AssetLock), a separate larger feature. Findings: docs/dashpay/QR_AUTO_ACCEPT_RESEARCH.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Latest delta (8b4f110..c8e4065) is documentation-only (docs/dashpay/QR_AUTO_ACCEPT_RESEARCH.md and TODO.md edits). No executable surface changed, and no new in-scope findings were introduced. All eight prior findings were re-verified against HEAD and are STILL VALID — the code at dashpay.rs:241-245, persistence.rs:1493-1498, established_contact.rs:156-169, dashpay_profile.rs:88-185, identity_persistence.rs:597-641, profile.rs:588-731, contact_request_queries.rs:41-111, and ContactRequestsView.swift:119-128 is byte-identical to 8b4f110. None are blocking; the cumulative recommendation remains COMMENT.
🟡 1 suggestion(s)
1 additional finding(s) omitted (not in diff).
7 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-ffi/src/established_contact.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:156-169: Initialize *out_note before fallible work in established_contact_get_note
[Carried prior-3, STILL VALID at c8e40657 — unchanged.] After `check_ptr!(out_note)`, `established_contact_get_note` runs three fallible stages (`with_item` storage lookup, `Option` unwrap for absent-note, `CString::new`) before assigning `*out_note`. Each early return via `unwrap_*_or_return!` leaves the caller's out-pointer untouched. The sibling `established_contact_get_alias` at lines 103–117 correctly sets `*out_alias = std::ptr::null_mut()` immediately after the null check; align `get_note` with that pattern so C/Swift callers do not observe stale pointers (potentially freed via `platform_wallet_string_free`) on error paths.
…rdened Spec + 4-lens review (DIP-fidelity / security / feasibility / scope). Folds the must-fixes: verify via provider.receiving_xpub pubkey (no &Wallet in the seedless drain); drain gains the identity signer + FFI signer_handle; sweep parser must read autoAcceptProof; consensus-authenticated sender binding; no expired-but-valid foot-gun; drain verdict mapping; queue bound + verify-before-fetch. Decisions: TTL 1h fixed, always-automatic, du-only, whole feature in one pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…accept) Foundation for QR auto-accept. Adds the scanner/owner-split primitives the dormant auto_accept.rs lacked, faithful to DIP-15 wire formats: - sign_auto_accept_proof(secret_key, …): the SCANNER signs with the key handed out in the QR (the owner can't pre-sign — it doesn't know the scanner's id). - verify_auto_accept_proof_with_pubkey(pubkey, …): the seedless verify path — the owner re-derives its auto-accept PUBLIC key (via the provider in the drain, no resident seed) and checks the signature. Pure, never errors on bad input. - auto_accept_proof_expiry(): extract the embedded expiry (same value that keys verification, so it can't be lied about independently of the signature). - encode/decode_auto_accept_key_blob: the 38-byte dapk blob (carries the expiry-bounded bearer private key). - encode/parse_dashpay_contact_uri: dash:?du=…&dapk=… (du+dapk required, base58 dapk, tolerates a merchant address prefix). - AUTO_ACCEPT_TTL_SECS = 3600 (DIP-silent on the value; short since the QR is a bearer credential and auto-accept is always-on). - Refactor generate → derive+sign; verify(wallet) → derive-pubkey + verify-with- pubkey; module doc pins the DIP role mapping (sender=scanner, recipient=owner); removed the stale TODO + fixed the docstring. 10/10 auto_accept tests green (incl. a cross-actor sign→verify-with-pubkey that exercises the full blob→sign→pubkey-verify round-trip + per-sender binding). Spec: docs/dashpay/QR_AUTO_ACCEPT_SPEC.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ drain) The owner side of QR auto-accept: ingest an inbound contactRequest's autoAcceptProof, then verify + auto-accept it at the next signer-present drain. - changeset: PendingContactCryptoOp::AutoAccept (+ kind), payload-free (the contact_id is the request sender; the drain re-loads the proof). Storage kind label + the labels-match-enum test extended. - parse_contact_request_doc now READS autoAcceptProof off the inbound doc (previously hard-coded None, so the proof was dropped before the queue). - sync sweep (signerless) enqueues AutoAccept for proof-bearing inbound requests via enqueue_pending_auto_accepts — structural pre-check only (size + ECDSA key-type byte), bounded to MAX_AUTO_ACCEPT_QUEUED_PER_OWNER (64) so a junk-proof flood can't grow the queue unboundedly; dedup makes it idempotent. - drain_auto_accepts(signer, provider): the signer-present pass. Per entry, local checks BEFORE any network (anti-DoS): proof present → expiry (vs the proof's own index) → derive OUR auto-accept pubkey via provider.receiving_xpub (seedless, no resident wallet) → verify_with_pubkey, binding sender to the consensus- authenticated request.sender_id. Valid+unexpired → accept (reciprocal send). Verdict mapping: invalid/expired/malformed/bad-index → permanent-clear; provider-unavailable/send-fail → transient-leave. The provider-only drain_pending_contact_crypto skips AutoAccept (no identity signer). - auto_accept_derivation_path extracted (shared by derive + the drain). - needs-unlock count now includes AutoAccept (a contact waiting to finish setup). platform-wallet 299 + storage 129 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… raw-key export) The owner side: derive the auto-accept key + build the dash:?du=&dapk= QR. - ContactCryptoProvider gains export_auto_accept_private_key(path) — the ONE deliberate raw-scalar export (every other method returns only derived products). Documented as the bearer-credential exception; impls on SeedCryptoProvider (resident/test), UnusedProvider (test), and ResolverContactCryptoProvider (FFI). - MnemonicResolverCoreSigner::export_auto_accept_private_key validates the path is an auto-accept path (4 components, 9' purpose + 16' feature) before exporting, so it can't be repurposed to exfiltrate a signing/identity key; returns the Zeroizing scalar. - IdentityWallet::build_auto_accept_qr(username, provider): expiry = now + 1h, derive at m/9'/coin'/16'/expiry' via the provider, encode the 38-byte dapk blob + dash:?du=&dapk= URI. Requires a DPNS username. - FFI platform_wallet_build_auto_accept_qr → heap C string (free with platform_wallet_string_free). platform-wallet + rs-sdk-ffi + platform-wallet-ffi compile clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Cumulative review at a714b4e. New latest-delta blocking finding (convergent: claude-general + codex-general + codex-ffi-engineer): the new drain_auto_accepts function has zero callers in the tree — no FFI export, no sync-manager invocation, no unlock hook — while the enqueue side is wired into the sweep and count_account_build_ops was extended to count AutoAccept. The provider-only drain_pending_contact_crypto explicitly skips AutoAccept entries, so the first inbound contactRequest with a structurally-plausible proof permanently latches the needs-unlock signal with no path to clear it, and the advertised QR auto-accept receive flow never actually completes. All eight prior findings re-validated as STILL VALID against current head (none of the affected files/lines were materially changed in c8e4065..a714b4e) and are carried forward.
Reviewed commit: a714b4e
🔴 1 blocking | 🟡 9 suggestion(s)
Verified findings
blocking: drain_auto_accepts has no caller — AutoAccept queue is never drained and permanently latches the needs-unlock signal
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (line 1794)
STATUS: NEW in latest delta. Verified by repository-wide grep: drain_auto_accepts is referenced only in comments and its own definition (contact_requests.rs:1081, 1163, 1760, 1806). There is no FFI export, no DashPaySyncManager invocation, and no unlock-path call site.
Meanwhile the enqueue side is fully wired:
sync_contact_requestsenqueuesPendingContactCryptoOp::AutoAcceptfor any inbound request whoseauto_accept_proofpasses the cheap structural check (length 38..=102, first byte 0x00 — trivial to satisfy without a valid signature) — contact_requests.rs:1208–1217.- The provider-only
drain_pending_contact_cryptodeliberately skips theAutoAcceptarm (contact_requests.rs:1757–1763, comment: "Handled bydrain_auto_acceptsat a signer-present moment; skip here so the entry stays queued"). count_account_build_opswas extended in this same commit to includeAutoAccept(contact_requests.rs:785–789), so the queue drives the needs-unlock banner.
Net effect: the first inbound contactRequest carrying any byte run that passes the structural pre-check (a) is enqueued, (b) is never touched by the only drain Swift can invoke (platform_wallet_drain_pending_contact_crypto), and (c) permanently keeps the needs-unlock signal non-zero. The commit subject claims "wire DIP-15 auto-accept receive flow (enqueue + drain)" and QR_AUTO_ACCEPT_SPEC.md §4.2/§5.3 direct that drain_pending_contact_crypto itself gain a signer_handle and the AutoAccept arm — but neither happened. Fold AutoAccept handling back into drain_pending_contact_crypto (per spec, adding the signer-handle to the FFI) or expose drain_auto_accepts via FFI and wire it into the on-unlock / recurring-sync path before this merges.
suggestion: [Carried prior-1, STILL VALID] Validate auto_accept_proof length before copying across the FFI boundary
packages/rs-platform-wallet-ffi/src/dashpay.rs (line 241)
Re-validated at a714b4e: STILL VALID, unchanged. platform_wallet_send_contact_request_with_signer still calls std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec() as soon as the pointer is non-null and the length is non-zero. The DIP-15 proof envelope is 38..=102 bytes (the same range the Rust enqueue side enforces structurally), and a valid ECDSA proof is exactly 70 bytes. A caller passing an oversized length (memory corruption, sign-extension bug, hostile embedding) forces Rust to materialize an arbitrary-size allocation and read across an unverified range of caller memory before DashPay validation runs. Reject lengths outside 38..=102 at the FFI boundary, before forming the slice.
let proof: Option<Vec<u8>> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 {
None
} else {
if !(38..=102).contains(&auto_accept_proof_len) {
return PlatformWalletFFIResult::error(
"auto_accept_proof_len outside DIP-15 envelope (38..=102)",
);
}
Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec())
};
suggestion: [Carried prior-2, STILL VALID] Fold on_changeset_end_fn failure into round_success
packages/rs-platform-wallet-ffi/src/persistence.rs (line 1493)
Re-validated at a714b4e: STILL VALID, unchanged. Per-kind persistence callbacks set round_success = false on non-zero return, but a non-zero return from on_changeset_end_fn — documented two lines above as the atomic commit/rollback boundary — is only eprintln!'d. If the host's commit fails, store() returns Ok(()) and the in-memory state advances even though the durable persistence step failed. After restart the Rust state can re-emerge inconsistent with what SwiftData actually committed (e.g. cleared PendingContactCrypto entries replaying).
if let Some(cb) = self.callbacks.on_changeset_end_fn {
let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), round_success) };
if result != 0 {
eprintln!("Changeset-end callback returned error code {}", result);
round_success = false;
}
}
suggestion: [Carried prior-3, STILL VALID] Initialize *out_note before fallible work
packages/rs-platform-wallet-ffi/src/established_contact.rs (line 156)
Re-validated at a714b4e: STILL VALID, unchanged. After check_ptr!(out_note), the function performs storage lookup, two unwrap_option_or_return! unwraps, and CString::new before assigning *out_note. Any early return leaves the caller-owned out pointer holding whatever was there before — typically a stale c_char * from a prior call, which Swift may try to free again. Initialize *out_note = std::ptr::null_mut() immediately after the pointer check.
check_ptr!(out_note);
unsafe { *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 prior-4, STILL VALID] Initialize DashPay profile out-params before fallible work
packages/rs-platform-wallet-ffi/src/dashpay_profile.rs (line 88)
Re-validated at a714b4e: STILL VALID, unchanged. managed_identity_get_dashpay_profile, platform_wallet_get_dashpay_profile, and platform_wallet_get_contact_profile validate out_profile/out_has_profile with check_ptr! but assign them only on the success branch. Early returns from identifier decoding or storage lookup leave caller-owned out values untouched — Swift can render stale DashPayProfileFFI C-strings from a prior call as the profile of a different contact, and dashpay_profile_ffi_free may then double-free those stale pointers. Assign *out_has_profile = false and *out_profile = DashPayProfileFFI::empty() immediately after the check_ptr! calls.
suggestion: [Carried prior-5, STILL VALID] Confirmed-absent contact profiles cannot delete persisted Swift rows
packages/rs-platform-wallet-ffi/src/identity_persistence.rs (line 597)
Re-validated at a714b4e: STILL VALID, unchanged (line 610: let Some(profile) = entry.profile.as_ref() else { continue; };). allocate_contact_profile_rows skips ContactProfileEntry { profile: None } entirely, so a confirmed-absent negative-cache entry emits no row to the Swift persister. When a contact deletes their profile on platform, the Rust-side negative cache rebuilds each sweep but the FFI never tells Swift to remove the previously-persisted row — stale display name/avatar persist across restarts. Emit an absence/tombstone row (e.g. add present: bool to ContactProfileRowFFI) or emit a parallel deletion-key array.
suggestion: [Carried prior-6, STILL VALID] Profile refresh timestamps lost across restart when content is unchanged
packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 588)
Re-validated at a714b4e: STILL VALID, unchanged. apply_fetched_profile refreshes checked_at_ms in memory on every fetch but returns changed = true only on content difference; sync_contact_profiles calls persister.store(...) only when any_changed is true. Successful timestamp-only refreshes therefore never cross the FFI to SwiftData, and after restart should_fetch_profile re-issues fetches immediately because the persisted timestamp is stale — defeating the throttle on every restart and amplifying both network traffic and the prior-8 sender-controlled avatar exposure. Persist the timestamp bump even on no-content-change (cheap lightweight delta).
suggestion: [Carried prior-7, STILL VALID] Contact-request pagination has no per-sweep budget
packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 41)
Re-validated at a714b4e: STILL VALID, unchanged. fetch_contact_requests_paginated still loops Document::fetch_many 100 docs at a time until a short page proves exhaustion and accumulates the full ContactRequestDocuments map before returning. No per-sweep document/page budget, no cursor checkpointing, no yield. For an identity targeted by a flood of contactRequests — newly relevant given auto-accept enqueue (each request carrying a structurally-valid byte run gets queued) — a single sweep can hold the network stage open across thousands of fetches and force the caller to retry from scratch on any transient error. Add a max_pages_per_sweep cap and persist the last $createdAt watermark to resume.
suggestion: [Carried prior-8, STILL VALID] Incoming-request rows fetch sender-controlled avatar URLs before consent
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift (line 119)
Re-validated at a714b4e: STILL VALID, unchanged. sync_contact_profiles still refreshes profiles for incoming_contact_requests.keys(), and IncomingRequestRow still receives avatarUrl: cachedProfile(row.contactIdentityId)?.avatarUrl for unaccepted incoming requests (line 122). The remote URL is sender-controlled (can encode a per-scan tracker) and the load fires as soon as the user opens the Requests tab — before any accept/ignore decision. This is now amplified by the auto-accept path, which silently transitions some senders to established before the user sees any UI. Gate the contact-profile fetch + AsyncImage load on accept, or proxy through a sanitiser that withholds the avatar until the user opts in.
suggestion: [New] Structural pre-check lower bound (38) admits non-proof byte runs into the AutoAccept queue
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (line 1208)
STATUS: NEW in latest delta. The enqueue pre-check (38..=102).contains(&p.len()) && p[0] == 0x00 is meant to gate "plausibly a DIP-15 proof," but 38 is the key blob length, not a proof length. The minimum well-formed ECDSA auto-accept proof is key_type(1) + timestamp(4) + sig_size(1) + signature(64) = 70 bytes (matching what sign_auto_accept_proof in auto_accept.rs emits — exactly 70). Bytes in 38..70 cannot satisfy verify_auto_accept_proof_with_pubkey and will only ever clear as malformed at drain time — but until then they occupy queue slots and (per the blocking finding above) indefinitely latch the needs-unlock signal. Tighten the lower bound to 70.
let structurally_ok = request
.auto_accept_proof
.as_deref()
.is_some_and(|p| (70..=102).contains(&p.len()) && p[0] == 0x00);
_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3841 a714b4e --dry-run --prior-sha c8e4065
STDOUT:
STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", line 854, in
result =), so I posted the same verified findings as a top-level review body._
…into drain
- AutoAcceptProofSource enum (None / Provided / SignWithKey{key,expiry}): the
proof binds (sender,recipient,accountReference), and accountReference is
computed inside the send, so the QR-scanner variant is signed IN-send once the
reference is known — binding it to the exact value the document carries (M3).
send_contact_request_with_external_signer takes the enum; FFI/legacy callers
use AutoAcceptProofSource::from_option.
- IdentityWallet::send_contact_request_from_qr(uri, signer, crypto): parse
dash:?du=&dapk=, resolve du→identity (resolve_name), decode dapk, send with
SignWithKey. (Non-generic impl since resolve_name is.)
- FFI platform_wallet_send_contact_request_from_qr.
- platform_wallet_drain_pending_contact_crypto gains signer_handle and now runs
drain_auto_accepts(signer, provider) after the provider-only drain; returns the
combined count. (The Swift unlock call-site updates next.)
platform-wallet 299 + ffi 117 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n identity signer - ManagedPlatformWallet.buildAutoAcceptQR(username:) and sendContactRequestFromQR(senderIdentityId:uri:signer:) wrap the new FFIs. - PlatformWalletManager: store the modelContainer/network at configure; the unlock-time drain now builds a KeychainSigner (the identity document signer) and passes it to platform_wallet_drain_pending_contact_crypto (optional — null still runs the provider-only ops), so auto-accepts complete on unlock. - DashPayProfileView: 'Add me (DIP-15 QR)' section renders the auto-accept QR (+ the URI text) via buildAutoAcceptQR; requires a DPNS name. - DashPayTabView: a qrcode.viewfinder toolbar button → AddViaQRSheet, where the user pastes a dash:?du=&dapk= URI (a camera scan produces the same string; simulators can't cross-scan) → sendContactRequestFromQR. - Fix a latent try?-result-unused error in the needs-unlock banner action (only caught under build_ios's -warnings-as-errors). build_ios.sh (sim): BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… follow-up Feature implemented across Rust+FFI+Swift (5 commits), build_ios green, platform-wallet 299 + ffi 117 tests green. On-device: My-QR UI + DPNS-name guard verified; full QR→scan→auto-accept loop pending a DPNS-named LOCAL identity (the devnet wallets' names are on-chain but not cached in PersistentIdentity.dpnsName). P3 follow-up: resolve the owner's DPNS name on-chain in build_auto_accept_qr when the local field is empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Incremental review at e6b7468. The prior blocking finding (drain_auto_accepts had no caller) is FIXED — the new platform_wallet_drain_pending_contact_crypto threads an identity signer and invokes drain_auto_accepts (dashpay.rs:701-733). All 9 other prior findings re-verified STILL VALID and are carried forward. One new latest-delta interop bug: Swift passes a nullable identitySigner?.handle and the doc comment promises a provider-only fallback, but the Rust ABI rejects null signer_handle at check_ptr! — so an unconfigured modelContainer would silently fail the entire drain instead of running provider-only.
🟡 10 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift:591-620: [New] Nullable identitySigner?.handle violates the FFI's non-null signer_handle contract — entire drain fails instead of skipping auto-accept
NEW in latest delta. `runDashPayUnlockDrain` builds `identitySigner` only when `self.modelContainer` exists and the comment (lines 591–595) states: "Nil when no SwiftData container is configured → the drain runs provider-only (account build / contactInfo) and skips auto-accept." It then passes `identitySigner?.handle` (nullable) to `platform_wallet_drain_pending_contact_crypto`. The Rust FFI (rs-platform-wallet-ffi/src/dashpay.rs:707) opens with `check_ptr!(signer_handle)`, which returns `ErrorNullPointer` before any drain runs — neither the provider-only ops (RegisterReceiving / RegisterExternal / ContactInfoDecrypt) nor the AutoAccept pass execute. Today `modelContainer` is always set during `configure()` so this is dormant, but: (a) the documented graceful-degradation path is unreachable, (b) any future configure-ordering bug silently fails the entire drain (including provider-only work) and surfaces as `lastError`, and (c) the Swift comment and Rust ABI now disagree. Either drop the Swift optionality (assert and call directly) or relax the FFI to accept a null signer_handle and run only the provider-only pass.
In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay.rs:241-245: [Carried prior-1, STILL VALID] Validate auto_accept_proof_len before copying across the FFI boundary
Re-verified at e6b7468d (unchanged). `platform_wallet_send_contact_request_with_signer` calls `std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec()` as soon as the pointer is non-null and the length is non-zero, with no upper bound. The DIP-15 ECDSA proof envelope is bounded (≤102 bytes; the only well-formed proof is exactly 70). A caller passing an oversized length (memory corruption, sign-extension bug, hostile embedding) forces Rust to read across an unverified caller-memory range and allocate an arbitrary-size Vec before DashPay validation runs. Reject lengths outside the envelope at the FFI boundary before forming the slice.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:1493-1498: [Carried prior-2, STILL VALID] Fold on_changeset_end_fn failure into round_success
Re-verified at e6b7468d (unchanged). Per-kind persistence callbacks set `round_success = false` on non-zero return, but a non-zero return from `on_changeset_end_fn` — documented as the atomic commit/rollback boundary — is only `eprintln!`'d. If the host commit fails, `store()` returns `Ok(())` and in-memory state advances even though the durable persistence step failed. After restart, Rust state can re-emerge inconsistent with what SwiftData actually committed (e.g. cleared `PendingContactCrypto` entries replaying — which now matters more because the AutoAccept queue is being drained).
In `packages/rs-platform-wallet-ffi/src/established_contact.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/established_contact.rs:156-169: [Carried prior-3, STILL VALID] Initialize *out_note before fallible work
Re-verified at e6b7468d (unchanged). After `check_ptr!(out_note)`, the function performs storage lookup, two `unwrap_option_or_return!` unwraps, and `CString::new` before assigning `*out_note`. Any early return leaves the caller-owned out pointer holding whatever was there before — typically a stale `c_char *` from a prior call, which Swift may try to free again. Initialize `*out_note = std::ptr::null_mut()` immediately after the pointer check.
In `packages/rs-platform-wallet-ffi/src/dashpay_profile.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay_profile.rs:88-185: [Carried prior-4, STILL VALID] Initialize DashPay profile out-params before fallible work
Re-verified at e6b7468d (unchanged). `managed_identity_get_dashpay_profile`, `platform_wallet_get_dashpay_profile`, and `platform_wallet_get_contact_profile` validate `out_profile`/`out_has_profile` with `check_ptr!` but assign them only on the success branch. Early returns from identifier decoding or storage lookup leave caller-owned out values untouched — Swift can render stale `DashPayProfileFFI` C-strings from a prior call as the profile of a different contact, and `dashpay_profile_ffi_free` may double-free those stale pointers. Assign `*out_has_profile = false` and `*out_profile = DashPayProfileFFI::empty()` immediately after the `check_ptr!` calls. Note: the new `platform_wallet_build_auto_accept_qr` (dashpay.rs:795) follows the same pattern and should also clear `*out_uri = ptr::null_mut()` before fallible work.
In `packages/rs-platform-wallet-ffi/src/identity_persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/identity_persistence.rs:597-641: [Carried prior-5, STILL VALID] Confirmed-absent contact profiles cannot delete persisted Swift rows
Re-verified at e6b7468d (unchanged; line 610: `let Some(profile) = entry.profile.as_ref() else { continue; };`). `allocate_contact_profile_rows` skips `ContactProfileEntry { profile: None }` entirely, so a confirmed-absent negative-cache entry emits no row to the Swift persister. When a contact deletes their profile on platform, the Rust-side negative cache rebuilds each sweep but the FFI never tells Swift to remove the previously-persisted row — stale display name/avatar persist across restarts. Emit an absence/tombstone row (e.g. add `present: bool` to `ContactProfileRowFFI`) or emit a parallel deletion-key array.
In `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:588-731: [Carried prior-6, STILL VALID] Profile refresh timestamps lost across restart when content is unchanged
Re-verified at e6b7468d (unchanged). `apply_fetched_profile` refreshes `checked_at_ms` in memory on every fetch but returns `changed = true` only on content difference; `sync_contact_profiles` calls `persister.store(...)` only when `any_changed` is true. Successful timestamp-only refreshes never cross the FFI to SwiftData, and after restart `should_fetch_profile` re-issues fetches immediately because the persisted timestamp is stale — defeating the throttle on every restart and amplifying the sender-controlled-avatar exposure noted below. Persist the timestamp bump even on no-content-change.
In `packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs`:
- [SUGGESTION] packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs:41-111: [Carried prior-7, STILL VALID] Contact-request pagination has no per-sweep budget
Re-verified at e6b7468d (unchanged). `fetch_contact_requests_paginated` loops `Document::fetch_many` 100 docs at a time until a short page proves exhaustion, accumulating the full `ContactRequestDocuments` map before returning. No per-sweep budget, no cursor checkpointing, no yield. Newly relevant now that auto-accept enqueue is wired and actually drained: a flood of inbound contact-requests targeting an auto-accept QR owner can hold the network stage open across thousands of fetches and force the caller to retry from scratch on any transient error. Add a `max_pages_per_sweep` cap and persist the last `$createdAt` watermark to resume.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift:119-128: [Carried prior-8, STILL VALID] Incoming-request rows fetch sender-controlled avatar URLs before consent
Re-verified at e6b7468d (unchanged). `sync_contact_profiles` refreshes profiles for `incoming_contact_requests.keys()`, and `IncomingRequestRow` receives `avatarUrl: cachedProfile(row.contactIdentityId)?.avatarUrl` for unaccepted incoming requests (line 122). The remote URL is sender-controlled (can encode a per-scan tracker) and the load fires as soon as the user opens the Requests tab — before any accept/ignore decision. Amplified by the new auto-accept path, which can silently transition some senders to established before the user sees the row at all. Gate the contact-profile fetch + AsyncImage load on accept, or proxy through a sanitiser that withholds the avatar until the user opts in.
In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1326-1329: [Carried prior-9, STILL VALID] Structural pre-check lower bound (38) admits non-proof byte runs into the AutoAccept queue
Re-verified at e6b7468d (unchanged). The enqueue pre-check `(38..=102).contains(&p.len()) && p[0] == 0x00` uses 38 — the *key blob* length — as the lower bound, not a *proof* length. A well-formed ECDSA auto-accept proof is exactly `key_type(1) + timestamp(4) + sig_size(1) + signature(64) = 70` bytes (matching what `sign_auto_accept_proof` emits and what `verify_auto_accept_proof_with_pubkey` requires). Any byte run in 38..70 with a leading 0x00 will get enqueued and discarded at drain time as malformed. Now that `drain_auto_accepts` is actually wired these clear on the next drain (good), but they still waste queue slots and consume a signer/provider round-trip per drain iteration. Tighten the lower bound to 70.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Latest delta e6b7468..b513b1c is documentation-only (docs/dashpay/QR_AUTO_ACCEPT_SPEC.md and docs/dashpay/TODO.md mark the QR auto-accept milestone implemented and queue DIP-13 invitations next). No code, FFI, schema, or crypto paths changed. No new findings in the latest delta. All ten prior findings re-verified at b513b1c and carried forward as STILL VALID at identical line ranges. Highest-confidence carried items remain the unbounded FFI slice copy in dashpay.rs:241-245 and the Swift/Rust signer_handle nullability contract mismatch. CodeRabbit posted no inline findings (paused due to active development), so coderabbit_reactions is empty.
Reviewed commit: b513b1c
🟡 10 suggestion(s)
Verified findings
suggestion: [Carried prior-1, STILL VALID at b513b1c] Validate auto_accept_proof_len before copying across the FFI boundary
packages/rs-platform-wallet-ffi/src/dashpay.rs (line 241)
Re-verified at HEAD b513b1c (lines 241-245 unchanged; latest delta is docs-only). platform_wallet_send_contact_request_with_signer calls std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec() as soon as the pointer is non-null and the length is non-zero, with no upper bound. The DIP-15 ECDSA proof envelope is bounded (≤102 bytes; the only well-formed proof is exactly 70). A caller passing an oversized length (memory corruption, sign-extension bug, hostile embedding) forces Rust to read across an unverified caller-memory range and allocate an arbitrary-size Vec before DashPay validation runs. Reject lengths outside the envelope at the FFI boundary before forming the slice.
let proof: Option<Vec<u8>> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 {
None
} else {
if !(38..=102).contains(&auto_accept_proof_len) {
return PlatformWalletFFIResult::from(
"auto_accept_proof_len outside DIP-15 envelope (38..=102)".to_string(),
);
}
Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec())
};
suggestion: [Carried prior, STILL VALID at b513b1c] Nullable identitySigner?.handle violates the FFI's non-null signer_handle contract — entire drain fails instead of skipping auto-accept
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift (line 591)
Re-verified at HEAD b513b1c (unchanged; latest delta is docs-only). runDashPayUnlockDrain builds identitySigner only when self.modelContainer exists and the comment (lines 591–595) states the drain should run provider-only and skip auto-accept when nil. It then passes identitySigner?.handle (nullable) to platform_wallet_drain_pending_contact_crypto. The Rust FFI (rs-platform-wallet-ffi/src/dashpay.rs:707) opens with check_ptr!(signer_handle), which returns ErrorNullPointer before any drain runs — neither the provider-only ops (RegisterReceiving / RegisterExternal / ContactInfoDecrypt) nor the AutoAccept pass execute. Today modelContainer is always set during configure() so this is dormant, but (a) the documented graceful-degradation path is unreachable, (b) any future configure-ordering bug silently fails the entire drain and surfaces as lastError, and (c) the Swift comment and Rust ABI disagree. Either drop the Swift optionality (assert and call directly) or relax the FFI to accept a null signer_handle and run only the provider-only pass.
suggestion: [Carried prior-2, STILL VALID at b513b1c] Fold on_changeset_end_fn failure into round_success
packages/rs-platform-wallet-ffi/src/persistence.rs (line 1493)
Re-verified at HEAD b513b1c (unchanged; latest delta is docs-only). Per-kind persistence callbacks set round_success = false on non-zero return, but a non-zero return from on_changeset_end_fn — documented as the atomic commit/rollback boundary — is only eprintln!'d. If the host commit fails, store() returns Ok(()) and in-memory state advances even though the durable persistence step failed. After restart, Rust state can re-emerge inconsistent with what SwiftData actually committed (e.g. cleared PendingContactCrypto entries replaying — which now matters more because the AutoAccept queue is being drained).
if let Some(cb) = self.callbacks.on_changeset_end_fn {
let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), round_success) };
if result != 0 {
eprintln!("Changeset-end callback returned error code {}", result);
round_success = false;
}
}
suggestion: [Carried prior-3, STILL VALID at b513b1c] Initialize *out_note before fallible work
packages/rs-platform-wallet-ffi/src/established_contact.rs (line 156)
Re-verified at HEAD b513b1c (unchanged; latest delta is docs-only). After check_ptr!(out_note), the function performs storage lookup, two unwrap_option_or_return! unwraps, and CString::new before assigning *out_note. Any early return leaves the caller-owned out pointer holding whatever was there before — typically a stale c_char * from a prior call, which Swift may try to free again. Initialize *out_note = std::ptr::null_mut() immediately after the pointer check.
check_ptr!(out_note);
unsafe { *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 prior-4, STILL VALID at b513b1c] Initialize DashPay profile (and auto-accept QR) out-params before fallible work
packages/rs-platform-wallet-ffi/src/dashpay_profile.rs (line 88)
Re-verified at HEAD b513b1c (unchanged; latest delta is docs-only). managed_identity_get_dashpay_profile, platform_wallet_get_dashpay_profile, and platform_wallet_get_contact_profile validate out_profile/out_has_profile with check_ptr! but assign them only on the success branch — early returns from identifier decoding or storage lookup leave caller-owned out values untouched. Swift can render stale DashPayProfileFFI C-strings from a prior call as the profile of a different contact, and dashpay_profile_ffi_free may double-free those stale pointers. Assign *out_has_profile = false and *out_profile = DashPayProfileFFI::empty() immediately after the check_ptr! calls. Same pattern in platform_wallet_build_auto_accept_qr (dashpay.rs:795): *out_uri is checked but not cleared before CStr::from_ptr(username).to_str() / read_identifier / storage lookups can early-return — clear *out_uri = ptr::null_mut() too.
suggestion: [Carried prior-5, STILL VALID at b513b1c] Confirmed-absent contact profiles cannot delete persisted Swift rows
packages/rs-platform-wallet-ffi/src/identity_persistence.rs (line 597)
Re-verified at HEAD b513b1c (unchanged; latest delta is docs-only; line 610: let Some(profile) = entry.profile.as_ref() else { continue; };). allocate_contact_profile_rows skips ContactProfileEntry { profile: None } entirely, so a confirmed-absent negative-cache entry emits no row to the Swift persister. When a contact deletes their profile on platform, the Rust-side negative cache rebuilds each sweep but the FFI never tells Swift to remove the previously-persisted row — stale display name/avatar persist across restarts. Emit an absence/tombstone row (e.g. add present: bool to ContactProfileRowFFI) or emit a parallel deletion-key array.
suggestion: [Carried prior-6, STILL VALID at b513b1c] Profile refresh timestamps lost across restart when content is unchanged
packages/rs-platform-wallet/src/wallet/identity/network/profile.rs (line 588)
Re-verified at HEAD b513b1c (unchanged; latest delta is docs-only). apply_fetched_profile refreshes checked_at_ms in memory on every fetch but returns changed = true only on content difference; sync_contact_profiles calls persister.store(...) only when any_changed is true. Successful timestamp-only refreshes never cross the FFI to SwiftData, and after restart should_fetch_profile re-issues fetches immediately because the persisted timestamp is stale — defeating the throttle on every restart and amplifying the sender-controlled-avatar exposure noted below. Persist the timestamp bump even on no-content-change.
suggestion: [Carried prior-7, STILL VALID at b513b1c] Contact-request pagination has no per-sweep budget
packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs (line 41)
Re-verified at HEAD b513b1c (unchanged; latest delta is docs-only). fetch_contact_requests_paginated loops Document::fetch_many 100 docs at a time until a short page proves exhaustion, accumulating the full ContactRequestDocuments map before returning. No per-sweep budget, no cursor checkpointing, no yield. Now that auto-accept enqueue is wired and actually drained, a flood of inbound contact-requests targeting an auto-accept QR owner can hold the network stage open across thousands of fetches and force the caller to retry from scratch on any transient error. Add a max_pages_per_sweep cap and persist the last $createdAt watermark to resume.
suggestion: [Carried prior-8, STILL VALID at b513b1c] Incoming-request rows fetch sender-controlled avatar URLs before consent
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift (line 119)
Re-verified at HEAD b513b1c (unchanged; latest delta is docs-only). sync_contact_profiles refreshes profiles for incoming_contact_requests.keys(), and IncomingRequestRow receives avatarUrl: cachedProfile(row.contactIdentityId)?.avatarUrl for unaccepted incoming requests (line 122). The remote URL is sender-controlled (can encode a per-scan tracker) and the load fires as soon as the user opens the Requests tab — before any accept/ignore decision. Amplified by the now-shipped auto-accept path, which can silently transition some senders to established before the user sees the row at all. Gate the contact-profile fetch + AsyncImage load on accept, or proxy through a sanitiser that withholds the avatar until the user opts in.
suggestion: [Carried prior-9, STILL VALID at b513b1c] Structural pre-check lower bound (38) admits non-proof byte runs into the AutoAccept queue
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs (line 1326)
Re-verified at HEAD b513b1c (unchanged; latest delta is docs-only). The enqueue pre-check (38..=102).contains(&p.len()) && p[0] == 0x00 uses 38 — the key-blob length — as the lower bound, not a proof length. A well-formed ECDSA auto-accept proof is exactly key_type(1) + timestamp(4) + sig_size(1) + signature(64) = 70 bytes (matching what sign_auto_accept_proof emits and what verify_auto_accept_proof_with_pubkey requires). Any byte run in 38..70 with a leading 0x00 will get enqueued and discarded at drain time as malformed. Now that drain_auto_accepts is actually wired these clear on the next drain, but they still waste queue slots and consume a signer/provider round-trip per drain iteration. Tighten the lower bound to 70.
let structurally_ok = request
.auto_accept_proof
.as_deref()
.is_some_and(|p| (70..=102).contains(&p.len()) && p[0] == 0x00);
_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3841 b513b1c --dry-run --prior-sha e6b7468
STDOUT:
STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", line 854, in
result =), so I posted the same verified findings as a top-level review body._
…an spec Re-audit the DashPay implementation against DIP-15 (DashPay) and DIP-16 (headers-first SPV sync) from scratch, grounded in the actual code rather than self-reported status. DIP-15 core flow fully conforms; DIP-16 SPV implements the hard parts (headers/checkpoints/masternode-quorum verify/ compact filters) but deliberately diverges from literal phasing. Two under-tracked gaps surfaced and filed in TODO.md: - coreHeight block-rescan (DIP-15 §8.7/§12.6) — never previously tracked; an incoming payment to a contact's receival address that landed before the address was watched is silently missed (payment-loss). - encryptedAccountLabel padding — the prior 'DONE' fix is dead code; the live send path passes the label unpadded so a short label errors the whole send. Corrects the stale SPEC.md G3 claim (accountReference is computed + version-rotated, not hardcoded 0). Adds CORE_HEIGHT_RESCAN_SPEC.md (reviewed by a 4-lens panel: deletes the proposed upstream change, restructures the trigger to a reconcile over established contacts, resolves synced_height regression as safe). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…imitive
DIP-15 fixes encryptedAccountLabel at 48..=80 bytes, but the live send path
(rs-sdk create_contact_request -> platform_encryption::encrypt_account_label)
encrypted the raw host label and hard-rejected out-of-range output, so a short
label (<16 chars -> 32 bytes) errored the entire contact-request broadcast. A
padding helper existed in rs-platform-wallet but was dead code (no live callers).
Move the DIP-15 length normalization into the single primitive
platform_encryption::{encrypt,decrypt}_account_label: space-pad short/empty
labels to clear the 48-byte floor, and truncate over-long labels (on a char
boundary) to stay under the 80-byte cap, so no host-supplied label can error
the send. The dead rs-platform-wallet helper is deleted (it duplicated the
convention). The live rs-sdk path is fixed with no SDK change (it calls the
primitive).
A code review caught the symmetric ceiling case: the floor fix alone still let
a >=64-byte label produce a 96-byte field and fail the same way; the truncation
closes it.
Test would have caught this in CI: the new red->green test
account_label_is_always_a_valid_48_to_80_byte_field FAILED before the fix
(empty label -> 32 bytes) and passes after; it also pins the 80-byte ceiling,
multi-byte UTF-8, and the exact-48 boundary.
Receive-side label surfacing (decrypt + show to the user) stays a follow-up;
the label was already write-only before this change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…contact was watched DIP-15 §8.7 + §12.6: a DashPay payment that landed on a contact's receival address *before* that address was being watched (restore-from-seed, second device, or the offline-accept->pay window) was silently missed. Receival accounts are built lazily, so by the time one exists SPV has often already scanned past the contact's funding height, and the new addresses then only join the compact-filter match set forward-only. dash-spv's FiltersManager already backfills when a wallet's synced_height drops below its scan pointer, but nothing drove it for this case. Add IdentityWallet::reconcile_dashpay_rescan (a local-only step of the recurring dashpay_sync): for established receival contacts not yet rescanned this lifetime, lower synced_height to the minimum min(outgoing,incoming) coreHeightCreatedAt below the tip, so the filter manager re-matches the now-watched addresses against blocks it already scanned. The regression is safe - synced_height is the filter-scan checkpoint, decoupled from the monotonic last_processed_height, and every persisted cursor is monotonic-max guarded (see docs/dashpay/CORE_HEIGHT_RESCAN_SPEC.md §3.4). It lowers via the inner unconditional setter, intentionally bypassing the WalletManager forward-only wrapper. A new in-memory ManagedIdentity::dashpay_rescan_triggered guard makes the rewind single-shot per contact per lifetime, so the recurring sweep can't re-lower the height and reset an in-flight backfill (which would keep it from ever completing). Because the guard is in-memory and synced_height restores at its high-water, an interrupted backfill self-heals on the next launch. Code review hardening: also mark contacts funded at/above the tip (covered by the forward scan), so once the pointer later climbs past such a contact the sweep does not redundantly rewind to an already-scanned range. Tests pin the invariants (the anti-thrash guard is the load-bearing one): - lower-to-funding-floor + single-contact idempotency - a forward-covered contact is never redundantly rewound after sync climbs past it - floor = min over multiple contacts; a later older contact re-lowers once then settles - synced_height == 0 is a no-op The live payment-recovery proof rides the devnet e2e harness (#3549, funding-gated); these unit tests pin the decision logic. 301/301 lib tests, clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ng wallet A freshly installed app importing or recovering an EXISTING seed called the no-birth-height create-wallet FFI, which defaults the SPV scan window to the current chain tip — so on-chain history from before this device (incl. DashPay payments) was never scanned, and the coreHeight rescan backfill was clamped to the tip. (Companion to the platform-wallet coreHeight rescan: that backfill can only reach down to the wallet's birth height, so an imported wallet needs a low birth height for it to work.) PlatformWalletManager.createWallet(mnemonic:)/(seed:) gain a "birthHeight: UInt32? = nil" parameter routed through the existing *_with_birth_height FFI variants. The nil case is behavior-preserving (the variant's has_override=false path is identical to the old export). The app passes: - nil for a freshly GENERATED mnemonic (nothing funded before now); - 0 for an IMPORTED mnemonic (CreateWalletView, via showImportOption) and for enabling an existing wallet on another network (WalletDetailView, where the per-network birth height is unknown); - the wallet's persisted birth height (genesis fallback) on RECOVERY (ContentView), so a known wallet isn't re-scanned from genesis needlessly. The import-vs-generate decision stays in the app (user intent); the SDK only marshals the optional, per the swift-sdk thin-bridge rule. No automated test: createWallet needs a live configured Rust manager handle, so the marshalling isn't unit-testable without an FFI seam that doesn't exist, and the on-device payment-recovery proof is devnet-gated. Verified by a clean SwiftExampleApp build (BUILD SUCCEEDED, iPhone 17 Pro simulator). A code review caught the recovery call hardcoding 0 instead of the persisted birth height; fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot cached The DIP-15 auto-accept "My QR" requires the owner's DPNS name for the URI's `du`. The Swift view read it from the locally-cached PersistentIdentity.dpnsName, which is empty for imported/restored/devnet identities whose name exists on-chain but was never synced into that field — so the view bailed with "Register a DPNS username" and the on-device QR full-loop test was blocked. build_auto_accept_qr now takes the owner identity id and, when the caller passes an empty username, resolves the name on-chain via get_dpns_usernames_by_identity (read-only — building a QR shouldn't mutate/cache state). When an identity has multiple names it picks the lexicographically smallest for a deterministic, stable QR (any name resolves back to the same identity; the cached-name path still honors the app's "main name" choice). It errors clearly only when no name is registered at all. Threaded through all four layers: lib (owner_identity_id + on-chain fallback), FFI (new owner_identity_id param; the only consumer is our Swift wrapper), the buildAutoAcceptQR wrapper (ownerIdentityId: Identifier), and the My-QR view (passes identity.identityId + the cached name or "", no longer bailing on an empty cache). Resolution policy stays in Rust (thin-bridge rule); Swift only marshals. No unit test: the fallback needs a live SDK + on-chain query + wallet handle, which a unit test can't supply cheaply; the on-device full-loop is devnet-gated. Verified: Rust compiles + 301/301 platform-wallet lib tests + clippy clean; xcframework regenerated (cbindgen header shows the new signature); SwiftExampleApp BUILD SUCCEEDED. A code review hardened name-selection determinism and trimmed a stale doc comment + a redundant null check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rain platform_wallet_drain_pending_contact_crypto required a non-null identity document signer. Make it optional: pass null to run only the provider-derived ops (account build / contactInfo decrypt) and skip the auto-accept pass; pass a valid signer to also auto-accept proof-bearing inbound requests. The core HD resolver signer stays mandatory. This lets the recurring provider-only drain run when no identity document signer is available (e.g. before unlock / no model container) without failing the whole drain at the null-pointer check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…to-accept Track c6a9c4a (make the identity signer optional in platform_wallet_drain_pending_contact_crypto) as a follow-up of the QR auto-accept item, since ff2403d's signer threading is what made it mandatory and broke the background provider-only drain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrote a full implementation spec for DashPay simultaneous multi-account contacts and ran a 4-lens review (DIP-15 fidelity / state-machine feasibility / scope / security). The reviews converged: the spec is an accurate scope map, but the feature is NOT buildable as designed and there is no requirement — keep it deferred. Key blockers found: - Channel identity is information-theoretically unresolvable from the wire: accountReference is a sender-private one-time pad, and a rotation ships a new xpub, so "rotation of an added account" is indistinguishable from a new account. Channel identity must be out-of-band/user-assigned. - Keying the sweep collapse by accountReference re-opens the PR #3841 thrash. - A fabricated receiving-account index corrupts the hardened derivation path (advertised != watched addresses) -> invisible incoming payments. - Collision-unsafe BTreeMap<u32> keying, no per-sender flood cap, and an "Add account" phishing surface. Also recorded that rust-dashcore#813 is merged AND already in our pinned rev b4779fc, so the upstream blocker is gone; the friendship-path item is subsumed by the (deferred) multi-account item. Next step only if a requirement appears: a channel-identity design note, then re-spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…receive The DIP-15 §8.5 encryptedAccountLabel — the label a contact attaches to the receiving account they share — was captured on ingest but never decrypted or shown (write-only). This was the last partial item on §8.5. Decrypt it in Rust at the two signer-bearing register sites (the deferred drain's RegisterExternal arm and accept_register_external_validated), where the ECDH shared key already lives — the recurring sweep has no signer, so a reconcile-only approach is impossible. The plaintext is stored on a new derived EstablishedContact.contact_account_label and surfaced through the existing established-contact transport to a read-only "Their account" row in ContactDetailView. The label is direction-specific: it is the contact's label, derived strictly from the incoming request, so it is projected onto the incoming FFI row only — not stamped onto both rows like the symmetric alias / payment_channel_broken (otherwise our own outgoing label could surface as the contact's). The reset on rotation lives in apply_rotated_incoming_request (the in-place re-key path), not the constructor, which is unreachable for an already-established contact — so a stale label can never show against new key material. A decrypt failure or non-printable garbage (AES-CBC carries no integrity) coerces to None and never breaks the payment channel. Followed research -> spec -> 5-lens review -> code; spec and review resolutions in docs/dashpay/ACCOUNT_LABEL_SURFACING_SPEC.md. Backfill of pre-feature established contacts is deferred (dev-only; DashPay is unreleased). Tests: platform-wallet 306 + ffi 118 green (incoming-vs-outgoing, undecryptable -> None, rotation reset, incoming-row-only FFI projection); clippy clean; SwiftExampleApp build succeeded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…send it in-app The receive-side account-label surfacing was dead end-to-end: the recurring sweep's ingest parser `parse_contact_request_doc` read `encryptedPublicKey` and `autoAcceptProof` but silently dropped `encryptedAccountLabel`, so the contact's label never reached the recipient's stored request and there was nothing to decrypt. The send always attached the label (verified on-chain: a 48-byte ciphertext) and the decrypt was always correct — only the ingest was lossy. Fixes: - `parse_contact_request_doc` now reads `encryptedAccountLabel` (mirroring the `autoAcceptProof` read) and sets it on the `ContactRequest`. This is the recurring-sweep receive path; the sent-request parser already read it. - The send-side local bookkeeping now stores `encryptedAccountLabel` off the broadcast document onto the sender's own outgoing row (it previously read only `encryptedPublicKey`), so the local row matches what landed on Platform. - `AddContactView` gains an optional "Account label" field so a label can be attached when sending a contact request in-app (the app previously always passed nil, so the receive path was never exercised — which is why this latent ingest bug shipped). The unit tests missed this because they construct the incoming request with the label already attached, bypassing the parser. Closed with a red→green regression test `parse_contact_request_doc_carries_encrypted_account_label`. Verified full end-to-end on the paloma devnet (two simulators): send a labeled request (48-byte `encryptedAccountLabel` on-chain) -> the recipient's recurring sweep ingests it (`enc=48`, was `enc=0` before the fix) -> accept decrypts it (`contactAccountLabel="Bob savings acct"` on the incoming row only, outgoing null) -> ContactDetail renders "Their account: Bob savings acct", distinct from the owner-private alias/note. Tests: platform-wallet 308, ffi 118, encryption 16, rs-sdk 7 — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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:send_contact_requestwas 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 withInvalidDocumentTransitionIdError.ExtendedPubKey::encode()form; DIP-15 and both reference mobile clients (iOS dash-shared-core, Android dashj) use the 69-byte compactfingerprint‖chaincode‖pubkey. Our send failed its own 96-byte ciphertext check; our receive couldn't parse mobile payloads.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.entropyreused at broadcast), the DIP-15 69-byte compact-xpub codec inplatform-encryption+ the SDK callback contract switched to it, and the recipient key-purpose assertion relaxed to DECRYPTION-or-ENCRYPTION.fix(platform-wallet)— new recurringDashPaySyncManager(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_brokenflag, 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;DashPaySdkWriterseam 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 checkclean onrs-sdk-ffi,platform-wallet-ffi,platform-wallet-storage; clippy clean on touched cratesdp_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 withExternal 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, theplatform-wallet-family feature unification, under
cargo llvm-covcoverage, and theexact CI package set (
drive+dpp+drive-abci+…--all-featuresunder 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'sinsert_walletstores it verbatim;get_walletreturns a&Wallet),yet only the CI runner reads it as
ExternalSignable. Root cause is a use-after-zeroize inthe
key-walletgit dependency:Wallethas aDropthat zeroizes itsZeroize-derivedwallet_type, so the discriminant can corrupt under a particular memory layout (UB isenvironment-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
get_extended_public_keycallback contract forcreate_contact_request/send_contact_requestis now "return the 69-byte DIP-15 compact form" (was an encodedExtendedPubKey); validated before encryption.ContactRequestResultgains a publicentropy: Bytes32field. Thers-sdk-ffiC ABI is unchanged (caller doc contract tightened).contacts.payment_channel_brokencolumn,rejected_contact_requeststable) in the initial migration;ContactChangeSetgains arejectedfield.Checklist:
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation