Skip to content

feat(platform-wallet): send, receive and accept DashPay private contact requests - #5810

Open
QuantumExplorer wants to merge 59 commits into
feat/sdk-private-documents-send-and-scanfrom
feat/platform-wallet-private-contact-requests
Open

QuantumExplorer wants to merge 59 commits into
feat/sdk-private-documents-send-and-scanfrom
feat/platform-wallet-private-contact-requests

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Basic explanation

What this does: the wallet can now send, receive and accept DashPay contact requests that nobody else can see.

  • A private request is sealed to the recipient's DashPay key and paid from the wallet's shielded (Orchard) notes, so nothing on chain names either identity.
  • It is signed with the identity signer the wallet signs public requests with.
  • The wallet reads DashPay's private store, keeps the requests addressed to its identities after checking each sender, and recovers the ones it sent after a restore from seed.
  • Accepting a private request replies privately. A contact whose requests came or went privately is never sent a public request.

Value: private requests make DashPay contacts without publishing who knows whom. Once received, a private request establishes a contact and gets its payment accounts exactly as a public one does, so payments, rotation and restore keep working.

Risks: low to medium.

  • Client code only, no consensus change.
  • Sending spends shielded notes. It follows the existing shielded spend path: reserve, build, broadcast with redrive, then mark spent only once proven.
  • The document send, accept and sync were refactored to share code with the private ones. The existing contact and DashPay tests pass unchanged.
  • Deliberate behaviour changes:
    • the public send refuses a contact whose requests came or went privately, which also refuses a public accept that would broadcast a reply;
    • ingest, public and private, passes over a request older than the one tracked with the same contact. Public documents arrive in time order, so this only acts on the private store and on replays.

Issue being fixed or feature implemented

Part of anonymous DashPay contact requests for 5.1. It builds on the SDK calls in #5809 (send, scan and read private contact requests). Sam's decisions applied here:

  • accepting a private request replies with a private request;
  • the outgoing viewing key comes from the sender identity's DashPay encryption key;
  • the request is signed by the sender's signing key, as a public request is.

What was done?

Sending: PlatformWallet::send_private_contact_request(coordinator, seed, account, sender, recipient, label, auto_accept_proof, shielded_address, signer, crypto, prover)

  • It picks the keys, our receiving xpub for the pair, the masked account reference and the auto-accept proof exactly as a document send does. Those steps now live in DashPayView::prepare_contact_request, shared by both sends. A later request to the same recipient, either way, rotates past this one.
  • signer signs the request with the first of the sender's keys it holds among private_contact_request_signing_keys: enabled AUTHENTICATION keys at a level DashPay's documents are signed at (HIGH or CRITICAL), bound to no other contract or document type.
  • The crypto provider seals it. It keeps the encryption key, which the outgoing viewing key comes from.
  • The pair's shared key is computed before paying, so nothing that can fail is left after the payment.
  • The new shielded operation private_document_create_from_pool_with_metadata appends the entry with a type 29 transition paid from the account's notes:
    • new ShieldedFeeKind::PrivateDocumentCreate { entry_count, entry_size };
    • notes are reserved for the fee alone (amount 0);
    • it records as an own spend with its fee.
  • Once the payment is proven, a short private sync reads the entry back and records it, and our receiving account for the contact is registered. An entry not read back yet leaves the request pending (see Changes from review).
send_private_contact_request(Alice -> Bob, label "Alice", shielded address A)
  chain: one 589-byte entry in DashPay's privateContactRequest store; the fee for 2 actions
         is 114,140,000 + (589 + 32) x 27,400 + 10,000,000 = 141,155,400 credits
  Alice's wallet: sent request to Bob (encryption key 1, recipient key 2, signed by key 0),
                  core height 0

Receiving: DashPayView::sync_private_contact_requests(crypto, max_pages)

  • It reads the store from the earliest of the identities' cursors.
  • The provider opens the page. The wallet fetches the senders, its own identities and the recipients of its sent requests once a pass, and checks each entry against them (private_contact_requests_from_scan). That needs no secret key.
  • Received requests and sent ones (recovered with the outgoing viewing keys) are ingested with the same functions the document sync uses: newest per sender, rotation, auto-establish against a matching sent request.
  • Account builds are queued as the document sync queues them.
  • An identity's cursor advances only when its ingest reached disk. A pass that left one behind does not report itself complete.

How a private request is recorded: as a ContactRequest.

  • The entry carries the compact xpub and label in the clear. The wallet encrypts them under the pair's DIP-15 shared key, as a contactRequest document carries them, with IVs taken from the entry's commitment. So:
    • the existing decryption drain registers the contact's account unchanged;
    • a rescan rebuilds the same bytes, so the wallet's comparisons of a contact's encrypted xpub hold;
    • nothing persisted changes shape: no new rows, FFI structs or migrations.
  • The record's core height is 0. DashPay requires every contactRequest document to carry its Platform-assigned $createdAtCoreBlockHeight, and no Core chain puts that at 0. So ContactRequest::came_privately() reads the channel from the record itself, and it holds across restarts. Our receiving account for the contact is scanned from the wallet's birth.
  • DashPayState::private_contact(contact) derives received_privately and sent_privately from the tracked requests. Only the contact's shielded address stays in memory, noted from the request tracked from that contact and rebuilt by the scan a cold start runs.
Bob syncs; the store holds Alice's entry
  before: no way to read it
  after:  Bob's incoming requests: Alice (account reference r, created at the block's time, core height 0);
          private_contact(Alice) = { received_privately, shielded_address: A }; cursor 1
Bob restarts
  private_contact(Alice) still says received_privately: it comes from the stored record

Accepting: PlatformWallet::accept_private_contact_request(coordinator, seed, account, request, shielded_address, signer, crypto, prover)

  • It replies privately, then registers the contact's account as the document accept does.
  • The accept body is now accept_contact_request_replying_with, shared by both accepts.
Bob accepts Alice's private request
  accept_contact_request_with_external_signer:
    before: would publish a contactRequest document naming Alice and Bob
    after:  Err(PrivateContactRequestNeedsPrivateReply(Alice))
  accept_private_contact_request: a private reply; Alice established; Bob's account paying her registered
Alice, who sent privately, tries a public request to Bob
  send_contact_request_with_external_signer: Err(PrivateContactRequestNeedsPrivateReply(Bob))

Older requests are no rotation. ingest_received_requests and ingest_sent_requests pass over a request older than the one tracked with the same contact. Older is compared by the account reference's version, which a rotation bumps, then by creation time. A cold start reads the private store from its start, and could otherwise replay a request that a public one already superseded. The same goes for an old request appended to the store again.

tracked from Carol: version 1, created at 100
  Carol's version 0 request, appended again at 200 -> passed over (before: taken as a rotation back)
  Carol's version 2 request, created at 300        -> rotation, as before

Auto-accept. A request that came privately carries no auto-accept proof into the wallet. Accepting it pays from the shielded pool, which the unattended auto-accept drain cannot do, so it is accepted by hand.

Crypto provider. ContactCryptoProvider gains two methods:

  • scan_private_document_page
  • seal_private_contact_request, which takes the signature from the identity signer

They take Rust-built key paths (PrivateDocumentKeyPaths), so the scalars never leave the signer. Both default to an "unsupported" error, so existing providers compile unchanged. The test SeedCryptoProvider implements them. The Keychain-backed provider comes with the FFI PR.

Unignoring a sender rewinds the private cursor, as it rewinds the document cursor, so the skipped requests are read again.

Refactor. The document sync's teardown of stale external accounts moved into tear_down_stale_external_accounts, which both syncs call. The cursor advance moved ahead of it. That is behaviour-neutral, as all of it runs under one lock.

Changes from review

  • One accept for hosts. accept_contact_request_replying_in_kind (on DashPayView, and on PlatformWallet with shielded) replies the way a request came. It replies privately when the request came privately and no reply exists yet, and otherwise with a document or by adopting the reply already sent. Hosts no longer catch code 68 and choose. PlatformWallet's version takes what a private reply is paid with as a closure (PrivateReplyPayment), called only for a private reply, so a public reply needs neither shielded support nor the seed.
  • Recorded only once read back. The type 29 proof shows the notes spent, not the entry appended. So after paying, a send reads the store back with a short private sync and returns the request recorded from it. If the entry isn't visible yet, it returns PrivateContactRequestPending.
  • One private request in flight per recipient. It is reserved before sealing. A retried or concurrent send or accept gets PrivateContactRequestPending instead of paying again, while the payment is in flight or unconfirmed, and until a sync reads the entry back. A payment that definitely failed releases it.
    • Each reservation belongs to the attempt that made it: an older attempt's cleanup can't release a newer one's.
    • A send dropped before it pays releases its reservation.
    • A reservation settles only once its request is recorded, not when the entry is merely seen.
    • After a restart, reservations are rebuilt from the shielded store's saved unconfirmed spends. Each DashPay private entry is opened with the wallet's outgoing viewing keys. This happens in PlatformWallet::restore_pending_private_contact_requests and right before every private payment, so no second request to that pair is paid for.
    • Not restored: a pending rotation to a contact that already tracks an outgoing request, and a reservation whose spend failed for good. The latter stays until the next restart.
  • Public sends wait too. A public request to a recipient with a pending private request is refused with PrivateContactRequestPending, since the private one may already be on chain.
  • No paid duplicate at the rotation limit. A request whose account reference would repeat the tracked one, once all 16 rotation versions are used, is refused before anything is signed or paid.
  • The sync report lists recorded requests. PrivateContactSyncReport::requests holds what the ingests recorded. Not included: a request already tracked, an ignored sender's, a replay, or one whose store failed.
  • Sync.
    • Identities, recipients included, are proved at or after the entries they check.
    • A recipient that can't be read holds back its senders' cursors instead of skipping the request.
    • A cursor rewound mid-pass (unignoring a sender) stays rewound for that pass.
    • Collapsing a sweep orders by the reference's version first, as passing over older requests does.
Alice sends Bob a private request; the proof comes back but a competing spend took the notes
  before: recorded as sent, the contact established on the strength of the spend proof
  after:  the read-back finds no entry: Err(PrivateContactRequestPending(Bob)), nothing recorded;
          a second send to Bob: Err(PrivateContactRequestPending(Bob)) before anything is paid
The app restarts before Bob's entry lands, and Alice accepts again
  before: the reservation was gone, so a second entry was paid for
  after:  the unconfirmed spend is read back, Bob is reserved again, nothing is paid

How Has This Been Tested?

  • cargo test -p platform-wallet --features shielded --lib -- private_contact private_document_create contact dashpay older_request: 233 passed.
  • New tests:
    • should_send_read_and_accept_a_private_contact_request. Two wallets on mock SDKs:
      • Alice sends, signed with her signing key;
      • Alice's public request to Bob is then refused;
      • Bob reads it (sender checked, shielded address kept, cursor advanced);
      • both sides record the same ciphertext at core height 0, which Bob's shared key opens;
      • the public accept is refused;
      • the private accept establishes the contact and registers Bob's account paying Alice;
      • unignoring rewinds the cursor.
    • should_recover_a_sent_private_contact_request_from_the_store: a wallet restored from seed rebuilds its sent record byte for byte from the store alone.
    • should_record_a_private_request_the_same_way_every_time
    • an_older_request_than_the_tracked_one_is_no_rotation: replays and earlier requests of the same version are passed over on both sides, and a newer rotation is still taken.
    • test_select_for_a_private_document_create_covers_its_fee_alone: the fee kind equals the consensus fee, 141,155,400 credits for one DashPay entry.
  • cargo clippy -p platform-wallet --features shielded --all-targets -- -D warnings: clean.
  • cargo check -p platform-wallet-ffi, with and without --features shielded.
  • Not run: a real Orchard proof. The payment step is a closure, which the tests replace. The operation follows the identity top-up path line for line.

Breaking Changes

None for consensus. Two host-visible behaviour changes, both PlatformWalletError::PrivateContactRequestNeedsPrivateReply: the public accept refuses a request that came privately, and the public send refuses a contact whose requests came or went privately. A host routes those to the private accept and send.

Checklist:

  • I have performed a self-review of my own code
  • My Rust, FFI, WASM, Swift or Kotlin changes follow the coding conventions
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed
  • If I added or changed GroveDB structure, I described it in the area's structure.rs, regenerated grovedb-structure.json, and checked the structure viewer link posted on this pull request
  • If I added or changed a system contract's document schemas, I checked the contract viewer link posted on this pull request

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

QuantumExplorer and others added 3 commits November 8, 2026 11:35
…requests

The wallet sends, reads and accepts DashPay private contact requests
(protocol version 15), paying for each from the shielded pool:

- `PlatformWallet::send_private_contact_request` builds the request a
  `contactRequest` document would carry (receiving xpub, masked account
  reference, label, auto-accept proof) plus a shielded address, has the
  crypto provider seal it, and appends it with a type 29 transition paid
  from an account's notes (new `ShieldedFeeKind::PrivateDocumentCreate`,
  reserved for the fee alone).
- `DashPayView::sync_private_contact_requests` reads DashPay's private
  store from each identity's cursor: received requests with their
  senders checked, and sent ones recovered from the store alone.
- `accept_private_contact_request` replies privately. The public accept
  refuses a request that came privately
  (`PrivateContactRequestNeedsPrivateReply`), as a document reply would
  publish the relationship.

A private request is recorded as a `ContactRequest`, its xpub and label
encrypted under the pair's DIP-15 shared key with IVs from the entry's
commitment, so contacts establish, rotate and get their accounts as they
do from documents, and nothing persisted changes shape. Whether a request
came privately and the contact's shielded address are kept in memory
(`PrivateContact`), with the store cursor, and rebuilt by the scan a cold
start runs. Requests that came privately are not auto-accepted.

`ContactCryptoProvider` gains `scan_private_document_page`,
`read_private_contact_requests` and `seal_private_contact_request`, all
defaulted to unsupported, so the keys stay in the signer. The document
send and accept share their preparation and accept steps with the private
ones, and the document sync's stale-account teardown moved into
`tear_down_stale_external_accounts`, which both syncs call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vate across restarts

- Private requests are signed by the identity signer, with a signing key of
  the sender at a level DashPay's documents are signed at, as public requests
  are. `send_private_contact_request` and `accept_private_contact_request`
  take the signer. The crypto provider seals with the signature and keeps
  the encryption key; `read_private_contact_requests` is gone, as checking an
  entry needs no secret key.
- A private request is recorded at core height 0, which no contactRequest
  document has (DashPay requires `$createdAtCoreBlockHeight`).
  `ContactRequest::came_privately` and `DashPayState::private_contact` derive
  from the records, so the privacy of a contact holds after a restart; only
  the shielded addresses stay in memory, noted from the tracked request.
- A public send to a contact whose requests came or went privately is
  refused, as is a public accept that would broadcast a reply.
- Ingest passes over a request older than the tracked one (by its
  reference's version, then its time) on both sides, so a cold start's
  rescan of the private store, or a replayed entry, does not roll a contact
  back to a retired xpub.
- Sending computes the shared key before paying, and nothing after paying
  fails the send; a record that does not reach disk is recovered by the next
  sync. Sent private records note core height 0 for the receiving account's
  scan checkpoint.
- A sync whose ingest did not reach disk for an identity is not complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer added this to the v5.1.0 milestone Nov 8, 2026
@coderabbitai

coderabbitai Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

Share

@thepastaclaw

thepastaclaw commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

🔍 Review in progress — actively reviewing now (commit 03cea26) · triage: critical
█████████░░░░░░░░░░░ 43% · about 10 min left · running for 9 min
✅ triage → ⏳ Phase 2 (5/6 lanes) → ⏳ Phase 1 (1/2 lanes) → ▫️ verify 2 → ▫️ publish
Estimated from recent reviews of this tier · updated 06:19 UTC · live progress

QuantumExplorer and others added 6 commits November 8, 2026 19:09
…lows

The private send picks the first signing key the signer holds among
`private_contact_request_signing_keys`, which adds the contract bounds rule
of #5809 to the level and type rules: a key bound to another contract or
document type, or to a contract group, is never chosen, as the seal would
refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`accept_contact_request_replying_in_kind` (on `DashPayView`, and on
`PlatformWallet` with the `shielded` feature) is the one accept a host
calls: a request that came privately, with no reply sent yet, is accepted
with a private reply paid from the shielded pool; any other is accepted
with a document, or by adopting the reply already sent. The choice was the
hosts' (catch code 68, then call the private accept); it is the wallet's
now. `PlatformWallet` derives the spend authority only for a private reply.
The explicitly public and private accepts are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes (with the SDK changes of #5809 merged in):

- A private request whose store fails after it was paid for is kept in
  memory (`add_paid_sent_contact_request`): the contact it answers
  establishes, so a retried accept adopts it instead of paying for another
  reply, and the next add of the same request (the private sync recovers it
  from the store) stores it. Before, the failed store left no request and no
  contact, so the accept failed and a retry paid again.
- `PlatformWallet::accept_contact_request_replying_in_kind` takes what a
  private reply is paid with as `payment`, a closure called only for a
  private reply (`PrivateReplyPayment`: coordinator, seed, account, prover).
  A public reply or an adopted one needs neither shielded support nor the
  seed.
- The private sync checks each page against identities proved at or after
  its entries (`ProvedIdentity`), fetching a few at a time only those it
  lacks or holds from an older proof.
- Scan keys are built with the SDK's `PrivateDocumentScanKeys::derive`; the
  crypto provider's seal takes `platform_version`, which the SDK's schema
  validation needs.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Final validation — Phase 1 + Phase 2

Static verification of head 7bd302a confirms five blocking issues involving publication confirmation, duplicate private-reply payments, rotation selection, cursor rewinds, and stale recipient identities. Additional suggestions address persistence recovery, failure isolation, entropy errors, and the public-send privacy precondition. Current CI shows Kotlin build/tests and policy checks passing, with the automated review pending and no targeted Rust validation check visible; no builds or tests were run.

🔴 5 blocking | 🟡 4 suggestion(s)

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 5: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate change directly modifies shielded funds movement and note selection in shielded/operations.rs and shielded/note_selection.rs, as well as signing-key selection and encryption-key handling in private_contact_requests.rs.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1678-1686: Verify entry inclusion before recording a private request as sent
  This branch accepts an AffectedState result and returns success to send_private_contact_request, which records the outgoing request and can establish the contact. However, the SDK explicitly documents that type 29 proves only spent nullifiers, and Drive verifies only those nullifiers and classifies the result as non-execution-proving. With two real input notes, a competing spend by another device can consume the same nullifiers without appending this entry; an untrusted DAPI can then supply a genuine spent-nullifier snapshot for a request that was never published. Subsequent accepts adopt the nonexistent reciprocal instead of sending one. Keep consumed-note reconciliation separate from publication confirmation, and require a proof-verified private-store read locating the exact sealed entry before recording the request as delivered. If inclusion remains unknown, retain a typed unresolved outcome rather than returning publication success.

In `packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:315-320: Retain and serialize pending private replies before paying
  When pay returns ShieldedSpendUnconfirmed, this function exits without associating an outgoing request with the contact, while the shielded operation retains reserved notes and a byte-identical redrive that may still execute. An accept retry before private sync therefore sees no reciprocal, seals another entry, and can pay again from other available notes. Concurrent accepts have the same gap because accept_contact_request_replying_with releases its state lock before invoking the reply closure. The private store is an opaque append-only log with no cross-transition uniqueness constraint on the contact pair or account reference, so both payments can execute. The existing unstored_sent handling protects only failures after payment has returned success; later sync recovery does not prevent these duplicate fees. Reserve a contact-scoped pending reply before payment, serialize the decision for that pair, and associate the sealed entry with its shielded operation so retries resume or report the existing attempt instead of funding another one.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:728-740: Apply version-first ordering before collapsing scan candidates
  Both collapse helpers select by (created_at, account_reference), but the new predates guard selects freshness by (account_reference_version, created_at). The guard cannot recover a rotation already discarded during collapse. For example, after an earlier page established version 0, a later page can contain a legitimate version 1 rotation followed by a replay of the version 0 ciphertext. Both survive SDK filtering because their account references differ, and the replay receives the later append timestamp. Collapse keeps version 0, ingest skips its already-tracked reference, and the cursor advances past version 1. This affects both received requests and recovered sent requests, leaving retired payment material or an obsolete outgoing rotation version tracked. Use one version-first ordering for collapse and supersession, and test an earlier-page original followed by a same-page rotation and replay in both directions.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:747-752: Preserve a concurrent cursor rewind across the entire scan pass
  The compare-and-advance helper correctly refuses to overwrite a concurrent unignore rewind, but the assignment immediately afterward adopts the rewound cursor as this pass's new baseline. If a pass starts at position 100 and unignore_sender resets the live cursor to 0 while the first page is fetched, the first advance is refused and identity.position becomes 0. The next page is still fetched beyond 100 using read.next_position, yet its advancement now compares successfully against 0 and overwrites the rewind. The earlier ignored entries were never reread, so the newly unignored sender can remain missing for the session. Once a cursor mismatch is detected, stop advancing that identity for the rest of the pass or restart its scan at the rewind. Add a two-page regression with unignore occurring during the first fetch.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:599-602: Height-pin recipient identities used for outgoing recovery
  Unlike proved_identities, this recipient cache discards proof metadata and never refreshes an identity during the pass. A later page can contain a request sealed to a key added after the cached recipient snapshot; even the initial fetch can return a proof older than sent.block_height within the SDK's tolerance. The lookup below then treats the stale snapshot as evidence that the recipient does not hold the key, skips the valid outgoing request, and advances the sender's cursor past it. A restored request or rotation is consequently lost for the running session. Fetch these recipients with the existing height-aware SDK mechanism, requiring proof height at least sent.block_height and refreshing when later pages require it. Failure to obtain a sufficiently recent snapshot must preserve the affected sender's cursor rather than become a permanent skip.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:599-602: Isolate recipient-fetch failures without advancing affected senders
  An error fetching any recovered outgoing recipient returns from the whole sync before the current page's unrelated incoming or outgoing requests can be ingested. Earlier pages remain committed, but one failing recovery lookup prevents progress for every identity on this page. The public sweep already supports per-identity degradation. Apply equivalent isolation here: report the failure, hold back cursors for identities whose outgoing records could not be reconstructed, and continue ingesting unaffected identities. Do not simply convert fetch errors to None and advance normally, because that would permanently skip recoverable outgoing requests for the session.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:286-287: Return entropy failures through the send result
  StdRng::from_os_rng panics when the operating system cannot supply entropy; rand_core explicitly provides try_from_os_rng for callers that must handle this failure. This introduces a panic into a fallible wallet-library send operation and can terminate an embedding application rather than returning PlatformWalletError. Seed through try_from_os_rng and map the error before filling esk_seed, retaining the existing zeroization after sealing.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1305-1311: Let same-reference recovery retry an unstored paid request
  A freshly paid request uses the confirmation proof's metadata.time_ms, whereas scan recovery uses the entry's block_time_ms. GetProofs proves current state, so confirmation can occur in a later block than the append. If initial persistence failed, add_paid_sent_contact_request keeps that later-timestamped request in memory and marks it unstored. This guard then rejects recovery of the identical account reference as older, preventing add_sent_contact_request from reaching its same-reference persistence retry. Sync can advance and report completion while the request remains unstored until a cold rescan. Exempt the same account reference from the freshness rejection so the existing retry persists the tracked record without replacing metadata. Cover a later confirmation timestamp and a failing-then-successful persister.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:583-594: Document or enforce the privacy gate's synchronization precondition
  This gate checks only locally tracked requests. A restored or second device that has not completed private sync has no private records for the pair, so it can publish a public contactRequest even though the relationship was previously private. There is no private-sync readiness check in this send path, and its API documentation does not state that the privacy guarantee depends on synchronization order. Document that limitation and the required completed scan before public sends on restored state, or enforce an explicit readiness policy in Rust. The local gate alone does not establish the PR's unconditional claim that contacts exchanged privately are never sent public requests.

QuantumExplorer and others added 2 commits November 9, 2026 00:29
…ne at a time

Review fixes (with the SDK changes of #5809 merged in):

- A paid private request is recorded only as the store proves it: the
  type 29 proof shows the notes spent, not the entry appended, so a send
  reads the store back with a short private sync and returns the request
  recorded from it. Not read back yet, it returns the new
  `PrivateContactRequestPending`.
- One private request to a recipient is in flight at a time: reserved
  before sealing, it keeps a retried or concurrent send or accept from
  paying again (`PrivateContactRequestPending`) while it is being paid
  for, while its payment is unconfirmed, and until a sync reads its entry
  back, which settles it. A payment that definitely failed releases it.
  Pending requests are kept in memory.
- The keep-in-memory recording of a paid request whose store failed is
  gone: nothing is recorded outside the sync, whose failed ingest holds the
  identity's cursor while the request stays pending.
- Collapsing a sweep's requests orders by the reference's version first,
  as passing over older requests does (`freshness`), so a rotation and a
  later replay of the request it rotated past collapse to the rotation.
- A cursor rewound under a pass (unignoring a sender) is not advanced again
  that pass.
- The recipients of recovered sent requests are proved at or after their
  entries, each fetched on its own: one that cannot be read holds back its
  senders' cursors rather than failing the pass or skipping the request.
- An OS RNG failure is an error, not a panic.
- The public send documents that its privacy refusal needs the private
  sync to have read the store on a restored device.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

Verified the exact head d0db59f against the source: eight prior findings are fixed, but pending-operation lifecycle gaps still permit duplicate payments or stranded reservations. The public-send gate also overlooks unresolved private attempts, and exhausted rotation versions can consume fees without producing a usable request. This was a static review with no local builds or tests; current head-specific CI shows the Kotlin build/tests passing and no failed code checks, with the review check still pending.

🔴 3 blocking | 🟡 1 suggestion(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

🔴 Blocking: Reject exhausted rotation versions before funding a private send
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:926-935

This helper now prepares private sends as well as public documents, but saturation still relies on the public document's unique index to reject a repeated reference. With a tracked outgoing version of 15 and unchanged encryption key and receiving xpub, another private send derives the same account reference and pays for a fresh sealed entry. The append-only private store accepts it, while received ingest and add_sent_contact_request treat the reference as already tracked and ignore the new request. The sender's readback check then fails because the fresh commitment produces different encrypted-xpub bytes, so the operation spends its fee but returns pending without applying the request. Sync can clear the reservation, allowing further charged retries. Reject version exhaustion before sealing/payment, or implement a supported supersession scheme that does not depend on the public index.

source: gpt-6.1-sol (phase2-reviewer: general)

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

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 5: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate change directly modifies shielded funds movement and note selection in shielded/operations.rs and shielded/note_selection.rs, and signing and encryption-key handling in network/private_contact_requests.rs.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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:591-601: Include unresolved private sends in the public-send privacy gate
  This gate checks only recorded incoming/outgoing requests through `private_contact`; it does not check `private_reply_pending`. On a device whose initial private sync is already complete, a first private send can return `ShieldedSpendUnconfirmed` or `PrivateContactRequestPending` while retaining its commitment but creating no outgoing record. A subsequent public send passes this gate and publishes both identities even though the private transition may already have executed or may still be published by redrive. This is separate from the documented restored-device synchronization precondition: scanning cannot recover an entry that has not executed yet, and this wallet already knows about the attempt. Make public-channel eligibility include unresolved private operations for the pair, retaining the refusal until definitive failure or reconciliation.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:926-935: Reject exhausted rotation versions before funding a private send
  This helper now prepares private sends as well as public documents, but saturation still relies on the public document's unique index to reject a repeated reference. With a tracked outgoing version of 15 and unchanged encryption key and receiving xpub, another private send derives the same account reference and pays for a fresh sealed entry. The append-only private store accepts it, while received ingest and `add_sent_contact_request` treat the reference as already tracked and ignore the new request. The sender's readback check then fails because the fresh commitment produces different encrypted-xpub bytes, so the operation spends its fee but returns pending without applying the request. Sync can clear the reservation, allowing further charged retries. Reject version exhaustion before sealing/payment, or implement a supported supersession scheme that does not depend on the public index.

In `packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:888-889: Report committed requests rather than pre-ingest candidates
  `PrivateContactSyncReport::requests` is documented as requests ingested, but these extensions run before collapsing candidates, filtering ignored senders, rejecting older requests, or attempting persistence. An ignored request or a request whose persistence failed is therefore returned as ingested even though it never became wallet state. The received helper already returns successful ingestions through `ingested`, but that vector is discarded here. Populate the report from successful ingestions and add equivalent sent-side tracking; cover ignored senders and a failing persister so callers can distinguish fetched candidates from committed state.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:260-264: Retain and serialize pending private replies before paying
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4506856812)
  The pre-seal reservation closes the straightforward overlapping-call case, but it is not owned and reconciled with the payment throughout its lifecycle. Four verified paths remain:

  - Restart loses `pending_private_replies`, while `FileBackedShieldedStore::rehydrate_pending_spends` restores the original signed transition and its note reservations. If the original request has not landed, even a completed private scan finds no reciprocal. Retrying accept can fund another entry from other notes while the original remains executable or is redriven. GroveDB's private store appends opaque entries without contact-pair/reference uniqueness, so both fees can execute.
  - Lines 912–920 settle every matching item in `read.sent`, including entries omitted from `records` because the recipient fetch failed at lines 799–801. Both ingest helpers can succeed on empty inputs, removing the reservation without storing an outgoing request. The send returns pending, but an accept retry can pay again.
  - After sync settles attempt A, it awaits account building, and A subsequently awaits account registration. Attempt B can reserve the same pair during that interval. A's recipient-only cleanup at lines 288–292 then removes B's reservation, admitting another payment while B is unresolved.
  - Dropping the send future during preparation bypasses cleanup. Cancellation before `note_private_reply_commitment` leaves a `None` reservation that no store scan can settle, blocking further sends and accepts for that pair until the wallet state is recreated.

  Use an attempt-owned lifecycle in the Rust wallet: retain the pair-to-attempt association durably before submission can occur, compare attempt identities when updating or releasing reservations, release abandoned pre-payment preparation safely, and settle only after the corresponding request has been durably reconciled or the payment is proven unable to execute. Add regressions for reopening with an unresolved redrive, recipient-fetch failure after payment, A-settles/B-reserves/A-cleans-up, and cancellation during preparation.

QuantumExplorer and others added 10 commits November 9, 2026 01:09
…pending, and report only recorded requests

A public `contactRequest` to a recipient a private request is still pending
to is refused with `PrivateContactRequestPending`: that private request may
already be on chain, and the document would publish the pair.

`PrivateContactSyncReport::requests` now lists the requests the pass
recorded, received and sent, instead of every candidate the page opened:
`ingest_sent_requests` reports what it recorded, as the received ingest
already did, so an ignored sender's request, a replay older than the tracked
one, one already tracked, or one whose store failed is not reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and restore it after a restart

The reservation that keeps a second private request to a recipient from
being paid for, and a public one from publishing the pair, had four gaps.

- Attempt tokens: a reservation is a `PendingPrivateReply` owned by the
  attempt that made it. Only that attempt notes its commitment or releases
  it, so an attempt whose request was settled can no longer release the
  reservation of the next attempt to the same recipient.
- Cancellation: the send holds the reservation in a guard that releases it
  when the send is dropped before it pays (synchronously when the wallet
  lock is free, otherwise on a spawned task). It is kept once payment may
  have started.
- Settle only what was reconciled: the private sync settles a pending
  request only for a sent entry whose record it built and that the tracked
  request to the recipient now matches (by account reference, or by being
  newer), after both ingests succeeded. An entry whose recipient could not
  be read no longer settles it with nothing recorded.
- Restart: reservations are in memory, but an unconfirmed payment survives
  a restart as the shielded store's redrive record.
  `PlatformWallet::restore_pending_private_contact_requests` reads the
  type-29 transitions to DashPay's store from those records and reserves
  each recipient again under its entry's commitment, so a sync settles it.
  The private payment runs the same check right before it spends, and
  refuses with `PrivateContactRequestPending` when an unresolved request
  between the same pair is found, leaving the recipient reserved for that
  one. A rotation still in flight is not restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rsion is used

The account reference's rotation version saturates at 15. Past it, with the
same receiving xpub and encryption key, the next request would carry the
tracked request's reference again. A `contactRequest` document then failed
only at the contract's unique index, after signing; a private request was
accepted by the store, paid for, and then ignored as already tracked, so the
fee bought nothing.

`prepare_contact_request` now refuses such a request with `InvalidParameter`
before anything is signed, sealed or paid, for both the public and the
private send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the wallet's removal

`PlatformWallet::pay_private_contact_request` entered the shielded spend
without the wallet generation's lifecycle gate. A caller could remove the
wallet while the proof was being built; the retained spend then recorded
pending activity, recreating the removed subwallet's shielded state, and
broadcast.

The payment now takes the generation's payment guard first, as the
transparent contact payment does, and holds it across the liveness check,
the restore of unconfirmed requests and the spend. A wallet removed or
re-created since the handle was taken is refused with
`WalletRemovedBeforeSend`, with nothing restored, recorded or paid, and a
removal waits for a payment already past the check. The guard is taken
before the wallet manager lock, as the teardown takes them.

Reservation attempt ids now come from one process-wide counter, so a handle
that outlived its wallet's removal and re-creation under the same id cannot
release a reservation the new wallet made with the same id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st private send since load

A private request or reply can be paid for and confirmed but not read back
by the post-pay sync: the send returns `PrivateContactRequestPending` with
nothing recorded and no unconfirmed spend kept, as the payment succeeded.
After a restart the in-memory reservation is gone and the store cursor
starts again at 0, so a new private send or accept sealed and paid a second
entry before any sync had read the first.

Each identity now carries an in-memory flag, set when a private sync reads
DashPay's store to its end for it with nothing held back. Until it is set, a
private send, a private accept, and an in-kind accept of a request that
came privately (or would get a private reply) first run bounded private
syncs. They stop when the flag is set, and refuse with
`PrivateContactRequestPending`, nothing sealed or paid, when the syncs stop
advancing or run out; a sync error comes back as it is. The in-kind accept
reads before it chooses the reply, so a reply read back is adopted with no
payment. A public request with no private one tracked reads nothing.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

Verified head 6b54d0d: the store-binding merge is correct, and eleven prior findings are fixed, but restart recovery still allows duplicate payments for an unresolved private rotation. This was a static review; no builds or tests were run. Current CI shows successful Kotlin build/tests and policy checks, with the automated review pending and no Rust test or Clippy result listed.

🔴 1 blocking

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

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate diff directly changes shielded funds spending and note selection in shielded/operations.rs::private_document_create_from_pool_with_metadata and shielded/note_selection.rs, alongside private-request signing and encryption-key handling.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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/private_contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:1306-1308: Retain and serialize pending private replies before paying
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4506856812)
  This guard treats any outgoing request as completion of the saved operation, so the explicitly excluded pending-rotation case still allows duplicate payment. Persist an outgoing v0 request, submit a v1 rotation that returns ShieldedSpendUnconfirmed, and restart before v1 reaches the private store. Both startup restoration and the payment-time hold call this helper and skip the saved v1 PendingRedrive because v0 exists. The initial store scan can recover only v0; retrying then derives v1 again, seals a different entry, and can pay from other available notes while the original signed transition remains executable. Note reservations protect the original inputs, not the contact pair, and neither type-29 validation nor the opaque append-only store enforces account-reference uniqueness across these transitions. Both payments can therefore execute and charge fees. Restore unresolved rotations as well, and discharge a saved operation only after reconciling its particular request, or a superseding request, against recorded state—not merely because an outgoing request exists. Add a restart regression with recorded v0, an unconfirmed v1, and other spendable notes, verifying that retry cannot fund another transition.

…t it rotates

The restart restore, at startup and in the hold a private payment runs,
skipped a saved unconfirmed private contact request whenever its sender
tracked any request to the recipient. With v0 recorded and a v1 rotation
whose payment was unconfirmed at a restart, the store read recovered only
v0, so a retried rotation derived v1 again, sealed another entry and could
pay from other notes while the first v1 could still execute.

Each saved entry's request is now decoded (dpp's private document payload
decode, then `PrivateContactRequest::from_properties`), and the entry is
discharged only once the request tracked to its recipient has the entry's
account reference or a later version: its own request or a superseding one
is recorded. A recorded v0 no longer hides an unconfirmed v1; a recorded v1
or newer, or an entry read back while its spend still waits, is skipped as
before. An entry whose request does not decode is skipped, as no sync could
record it.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

Static verification at afc6b7e confirms 13 prior findings are fixed, while three retain cancellation or ownership gaps. Three additional blockers affect reserved shielded notes, production-provider compatibility, and protocol-refusal handling; one non-blocking association-cleanup issue also remains. No builds or tests were run: current CI reports Kotlin build/tests passing, no failed code checks, and the review check pending.

🔴 6 blocking | 🟡 1 suggestion(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

🟡 Suggestion: Evict private-contact payment associations when their redrives are dropped
packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:789-800

private_contact_payments gains an entry whenever a private-contact redrive is armed or rehydrated, but clear_redrive, mark_spent, and clear_pending remove redrives without removing their associations. The purge paths also leave this map intact. Listing joins live redrives before consulting the map, so stale associations currently do not restore false reservations, but every completed private payment retains an unreachable allocation until the store is reopened. Remove associations alongside their owning redrives, preserving the existing persistence-before-memory ordering where required. clear_pending will need the dropped activity IDs or an equivalent targeted cleanup mechanism.

source: muse-spark-1.3-contributor (phase1-reviewer: general, rust-quality)

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

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate diff changes shielded funds movement and note selection in wallet/shielded/operations.rs and wallet/shielded/note_selection.rs, plus signing and encryption-key handling in wallet/identity/network/private_contact_requests.rs.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1665-1683: Keep cancellation cleanup armed during lazy payment preparation
  PaymentStarted correctly remains unmarked during the host resolver and proof preparation, but mark() still precedes the awaited durable handoff. arm_redrive_before_broadcast awaits store.write() at line 3234 before saving the transition. Cancellation while that lock is contended leaves no saved redrive and no broadcast, yet PrivateReplyReservation::drop retains the contact reservation because the token is marked. Private sync cannot settle the unpublished commitment; even anchor-pruning cannot report its rejected entry without the missing redrive. Complete durable arming before marking the token, with no intervening suspension, and add a regression that cancels while waiting for the pre-broadcast store lock.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1605-1621: Guard reserved shielded notes against pre-broadcast cancellation
  reserve_notes_covering marks the selected notes pending, but this new payment path installs no cancellation-safe rollback owner. extract_spends_and_anchor then awaits ShieldedAnchors::fetch_current before arm_pending_release or a durable redrive exists. Dropping the future during that fetch bypasses the result-based cancel_pending branch. The outer contact guard releases the pair, but the notes remain excluded from spendable balance and selection. Their reservations have anchor: None, which stale_pending_spends deliberately skips, so neither redrive nor anchor-pruning cleanup releases them during the session. Give reserved notes an RAII rollback owner until recoverable operation ownership is established, preferably in the shared reservation layer, and test cancellation during the actual anchor fetch with assertions on note availability.

In `packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:775-782: Release private reservations after terminal shielded failures
  The coordinator now reports definitive failures, but reconciliation destructively takes those notifications before awaiting the wallet-manager lock. WalletPersister::take_rejected_private_entries uses mem::take, so cancelling a send or accept while this lock is contended discards the only rejected-commitment set without releasing pending_private_replies. The terminally rejected redrive has already been removed, and no published entry exists for sync to recover; later calls therefore leave the pair blocked until restart. Acquire and validate the managed-wallet state before consuming notifications, then apply releases without another cancellation point, or retain unapplied notifications in a cancellation-safe owner. Extend the rejection regression with cancellation during lock contention and a retry without restarting.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:205-207: Require network evidence before skipping the private-store scan
  A non-transient ProtocolVersionNotActive refusal does not establish that private documents are inactive on the network. An SDK pinned to protocol 14 returns this error with pinned: true and cause: None even when the verified network version is 15. QuerySettings::network_protocol_basis is the minimum of the client and verified versions, so the error's network field also does not establish actual network non-activation. This predicate accepts that refusal, and read_private_store_before_deciding converts it to success. A restored wallet can consequently publish a public request or reply without discovering an existing private relationship. Permit the fallback only with verified evidence that the network is below private-document activation; fail closed when the client cannot perform the required scan. Add coverage for a client pinned below 15 against a network verified at 15.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:755-756: Supply production scanning support before requiring it for public requests
  Existing public sends now require private-store scanning before submission, and public accepts have the same preflight. However, ResolverContactCryptoProvider in packages/rs-platform-wallet-ffi/src/dashpay.rs implements neither scanning method and inherits the unsupported-error default. On a protocol-15 network, a normal HD identity with DashPay keys therefore cannot send or accept public requests through the existing production exports. An empty store also yields a page that is passed to scan_private_document_pages, so it does not avoid the error. Default trait methods preserve compilation, not behavior compatibility; the planned FFI follow-up does not remove this regression from the current revision. Land signer-backed scanning support with this mandatory preflight, delegating scanning to the Rust library implementation and keeping the binding thin. Do not treat unsupported scanning as an empty store.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:620-624: Serialize public sends with private channel eligibility
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4507940815)
  The new claim serializes public and private sends within one wallet generation, but cleanup identifies its owner only by wallet ID and identity pair. Public sends hold no generation lifecycle guard. Send A can claim the pair and suspend during preparation, the wallet can be removed and recreated under the same ID, and send B can acquire a claim in the replacement wallet. Dropping A then decrements B's claim through this lookup. A private send can reserve and pay while B is still preparing, after which B publishes the public document. Bind release, including detached Drop cleanup, to the acquiring attempt or wallet generation, as private reservations already do with process-wide attempt IDs. Cover removal/recreation while an old claim and a replacement claim are both alive.

In `packages/rs-platform-wallet/src/wallet/shielded/file_store.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:789-800: Evict private-contact payment associations when their redrives are dropped
  private_contact_payments gains an entry whenever a private-contact redrive is armed or rehydrated, but clear_redrive, mark_spent, and clear_pending remove redrives without removing their associations. The purge paths also leave this map intact. Listing joins live redrives before consulting the map, so stale associations currently do not restore false reservations, but every completed private payment retains an unreachable allocation until the store is reopened. Remove associations alongside their owning redrives, preserving the existing persistence-before-memory ordering where required. clear_pending will need the dropped activity IDs or an equivalent targeted cleanup mechanism.

QuantumExplorer and others added 11 commits November 9, 2026 06:57
…cord is saved

The type 29 payment marked its `PaymentStarted` before awaiting the store
lock that saves its record. A send dropped while that lock was contended
then left no record and no broadcast, yet the marked token kept its contact
request reserved, with nothing a sync or a redrive could settle.
`arm_redrive_before_broadcast` now takes the token and marks it right after
the record is saved, under the same lock, with nothing awaited in between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… owns them

Every shielded spend marked its notes pending, then awaited the anchor
fetch before anything recorded who frees them: a spend future dropped there
skipped the outcome's `cancel_pending`, and the notes stayed excluded from
the balance and from selection for the rest of the session, their
reservations carrying no anchor for the pruned-anchor release to find.

The shared reservation layer (`reserve_notes_covering`,
`reserve_unspent_notes`, `reserve_unspent_notes_for_denomination`) now
returns a `NoteReservation` guard that releases the notes on drop, at once
when the store is free and on a spawned task otherwise. Each spend hands it
over once its reservations carry their anchor (`arm_pending_release` now
says whether they all do), or once the outcome handling owns them.
`ShieldedStore` is `'static` so that release can be spawned; both stores
already are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er the wallet lock

`release_rejected_private_requests` took the rejected commitments the
coordinator noted, then awaited the wallet-manager lock to apply them. A
send or accept dropped while that lock was contended discarded the only set,
and the pairs it held stayed reserved until restart. The notices are now
taken once the wallet is locked and found, and applied with nothing awaited
in between; a release dropped while it waits leaves them for the next call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erified below 15

Before a public send or accept, an SDK refusal of the private store read
(`ProtocolVersionNotActive`) was taken as "no store to read" whenever it
was not transient. An SDK pinned to protocol 14 gives that refusal even
when a verified answer shows the network at 15, so a restored wallet could
publish a request without finding the private one it had sent.

The fallback now asks `Sdk::network_proven_below(required)`: only a
verified answer showing the network below the required version reads as
nothing to read. Otherwise, or when that cannot be learnt, the refusal
comes back as it is and nothing is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ough the resolver signer

Public sends and accepts read DashPay's private store first, but the
production FFI provider, `ResolverContactCryptoProvider`, implemented no
scanning and inherited the unsupported default, so on protocol 15 they
failed for every HD identity with DashPay keys.

- `MnemonicResolverCoreSigner` gains the private document operations the
  FFI branch added (6735575, 29adfca, 5358ce1), taken verbatim so
  that branch merges cleanly: `scan_private_document_page` and
  `seal_private_contact_request`, over a `resolve_master` that resolves the
  mnemonic once for every key an operation derives.
- New `scan_private_document_pages`: a batch of pages with one mnemonic
  resolve and one scan-key derivation for the batch.
- `ResolverContactCryptoProvider` implements `scan_private_document_page`,
  the batch `scan_private_document_pages`, and `seal_private_contact_request`
  over them, with the FFI branch's error mapping: a missing mnemonic is the
  key-unavailable error, never an empty store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tempt that took it

A public send's claim on its pair was a count, and its release found the
claim by wallet id and pair. After a wallet was removed and re-created
under the same id, an old send's release, its drop included, freed the new
send's claim, letting a private request to the pair be reserved while the
new public one was still in flight.

Claims now carry process-wide attempt ids, from the counter private
reservations use (`NEXT_CONTACT_REQUEST_ATTEMPT`): `claim_public_contact_request`
returns its attempt, `PublicContactClaim` keeps it, and
`release_public_contact_request` releases that attempt only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its record

The file store's `private_contact_payments` gained an entry whenever a
private contact request's record was armed or rehydrated, but kept it when
the record went: `mark_spent`, `clear_pending`, `clear_redrive` and the
purges removed records and left their associations in memory until the
store was reopened. Each now removes the association with its record: by
activity id where the dropped record is known, by sweeping the subwallet's
associations against its live records otherwise.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

Verified the findings against f98c1bd: six prior findings remain valid and sixteen are fixed. An additional readiness gap after unignoring a sender also defeats the public-send privacy prerequisite, leaving six blocking findings and one cleanup suggestion overall. This was static verification only; current CI shows Kotlin build/tests passing and the review check pending, but provides no wallet-specific Rust validation result.

🔴 6 blocking | 🟡 1 suggestion(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

🟡 Suggestion: Evict private-contact payment associations when their redrives are dropped
packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:789-800

Each private payment adds an association to private_contact_payments, but clear_redrive deletes only the SQLite row and SubwalletState record. The mark_spent, clear_pending, and wallet/subwallet purge paths also leave the association map untouched. Joining against live redrives correctly prevents stale restoration results, but does not reclaim the entries: a long-lived store retains historical sender/recipient metadata and allocations after completed payments and wallet removal. Evict associations whenever their owning redrive is removed, and extend cleanup coverage to inspect the backing map rather than only the filtered listing.

source: muse-spark-1.3-contributor (phase1-reviewer: rust-quality); gpt-6.1-sol (phase2-reviewer: general, architecture-layering)

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

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 5: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — This large, intricate change directly modifies shielded funds spending and fee-based note selection in packages/rs-platform-wallet/src/wallet/shielded/operations.rs and note_selection.rs, alongside private contact request signing and key handling.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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:389-390: Invalidate private-store readiness when unignoring a sender
  Unignoring rewinds private_contact_requests_position but leaves private_contact_requests_read set. A completed private scan can have skipped this sender's request through ingest_received_requests' ignored-sender branch, leaving no tracked private contact. After unignore, read_private_store_before_sending returns immediately on the stale readiness flag, so a public send can publish the pair before the rewound entries are ingested. There is also a concurrent variant: ScannedIdentity correctly preserves the rewind, but the end-of-store branch at private_contact_requests.rs:1538-1540 marks the identity ready without checking identity.rewound. Clear readiness when unignoring and prevent a rewound pass from re-establishing it; cover both unignore-after-completion and unignore-during-scan before a public send.

In `packages/rs-platform-wallet/src/wallet/shielded/file_store.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:789-800: Evict private-contact payment associations when their redrives are dropped
  Each private payment adds an association to private_contact_payments, but clear_redrive deletes only the SQLite row and SubwalletState record. The mark_spent, clear_pending, and wallet/subwallet purge paths also leave the association map untouched. Joining against live redrives correctly prevents stale restoration results, but does not reclaim the entries: a long-lived store retains historical sender/recipient metadata and allocations after completed payments and wallet removal. Evict associations whenever their owning redrive is removed, and extend cleanup coverage to inspect the backing map rather than only the filtered listing.

In `packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:205-207: Require network evidence before skipping the private-store scan
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4508317364)
  This predicate treats every non-transient ProtocolVersionNotActive refusal as evidence that no private store exists. The SDK also returns that refusal when its own version is pinned below activation, or its configuration prevents proof verification; neither establishes the network's version. For example, an SDK pinned to 14 against a protocol-15 network skips private recovery through read_private_store_before_deciding while ordinary public-document operations remain available. A restored device can consequently publish a public contactRequest naming a relationship already exchanged privately. The reviewed SDK exposes network_proven_below specifically to distinguish verified network evidence from client limitations. Require affirmative evidence below the private-document activation version before bypassing the scan; otherwise preserve the refusal.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:775-782: Release private reservations after terminal shielded failures
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4508317355)
  take_rejected_private_entries uses mem::take to destructively drain the terminal-rejection notifications before this function awaits wallet_manager.write(). If a send or accept is cancelled while waiting for that lock, the local commitments are dropped without releasing pending_private_replies. Once the coordinator has removed the failed redrive, it has no record from which to emit the notification again, and private sync cannot find an entry that never executed. Subsequent public and private operations for the pair therefore remain blocked until restart. Acquire and validate the managed wallet before draining the notifications, then consume and apply them without another suspension point, or retain ownership until application is acknowledged.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:755-756: Supply production scanning support before requiring it for public requests
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4508317358)
  The existing public send path now requires a private-store scan before submission. On a network serving private documents, an ordinary managed HD identity with DashPay keys reaches scan_private_document_pages even for an empty store. However, ResolverContactCryptoProvider in packages/rs-platform-wallet-ffi/src/dashpay.rs implements neither scanning method, so it inherits the new unsupported error and public sends, accepts that require a reply, and QR sends fail before submission. The SeedCryptoProvider implementation used by the tests does not cover this production adapter. Default trait methods preserve compilation, not runtime compatibility. Supply production signer-backed scanning with this prerequisite, keeping key operations in their owning signer layer; silently treating unsupported scanning as an empty store would defeat the privacy check.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1605-1621: Guard reserved shielded notes against pre-broadcast cancellation
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4508317357)
  reserve_notes_covering marks the selected nullifiers pending, but this operation holds the reservation only through an ordinary Vec and result-based cleanup. extract_spends_and_anchor then awaits a ShieldedAnchors network fetch before an anchor, activity link, or durable redrive is attached. Dropping or timing out the send at that await skips the final match and cancel_pending. The notes remain excluded from selection and spendable balance, and stale_pending_spends deliberately excludes these unanchored reservations, so normal sync cannot release them. The contact-pair Drop guard does not own the notes. Introduce cancellation-safe note ownership immediately upon reservation, releasing pre-handoff notes on drop and transferring responsibility only once recoverable operation state is installed.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1665-1683: Keep cancellation cleanup armed during lazy payment preparation
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4508317351)
  PaymentStarted is marked before arm_redrive_before_broadcast acquires store.write().await. If another store user holds the lock and the host cancels this future while it waits, no transition has been saved or broadcast, but PrivateReplyReservation::drop sees the marked token and retains the contact reservation. No private entry can arrive to settle it. The later anchor-prune pass cannot release the contact pair either: it obtains the entry commitments from the redrive that was never installed. The lazy host resolver is protected now, but this later pre-submission await still strands both channels until restart. Persist the durable redrive successfully before marking the token, with no intervening await between successful installation and the mark.

QuantumExplorer and others added 2 commits November 9, 2026 08:46
… a sender is unignored

`unignore_sender` rewound the private read position but left
`private_contact_requests_read` set. A pass that read the store with the
sender ignored had passed over its private request, so after the unignore
a public send skipped the read on the stale flag and could publish the pair
before the rewound entries were ingested. A pass the sender was unignored
under also took the store as read at its end, as the end-of-store branch
did not check the rewind.

The unignore now clears the readiness with the rewind (the rewound position
is stored as before; readiness stays in memory), and a pass whose cursor was
rewound under it does not set it again.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

Static verification at a35fe84 confirms that 20 prior findings are fixed, but three blocking lifecycle gaps remain: cancellation during note rollback, public submission before pending-operation recovery, and restoration resurrecting a terminally rejected request. Three additional suggestions address typed FFI outcomes, cancellation-safe secret cleanup, and Rust API migration documentation. No builds or tests were run; current CI shows Kotlin build/tests passing, no failed code checks, and the review check pending, but provides no Rust validation results.

🔴 3 blocking | 🟡 3 suggestion(s)

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

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 7: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 8: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — This large, intricate diff directly changes shielded funds spending and fee-only note selection in wallet/shielded/operations.rs and note_selection.rs, and signature and encryption-key handling in private_contact_requests.rs and mnemonic_resolver_core_signer.rs.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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/platform_wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:1039-1047: Include unresolved private sends in the public-send privacy gate
  The in-process pending check is present, but automatic restart recovery runs only during shielded binding. PlatformWalletManager::load_from_persistor explicitly leaves shielded restoration for later, while public sends and the production platform_wallet_send_contact_request_with_signer export require neither binding nor recovery readiness. After restarting with a saved, unconfirmed private payment, a public send before binding can therefore scan an empty private store, find no in-memory reservation, and publish the pair even though the saved type-29 transition remains executable. The restart regression confirms that the pair is not pending before bind_shielded and checks refusal only afterward. Recover durable pending-channel metadata before public submissions become eligible, or enforce a Rust-owned recovery barrier in public send and accept paths. Extend the regression to attempt a public submission before binding and require it to fail closed.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:812-820: Preserve contact-request outcomes across the C error boundary
  These new outcomes reach the existing production send and accept exports, but PlatformWalletFFIResult::from handles neither PrivateContactRequestPending nor PrivateContactRequestNeedsPrivateReply. Both fall through to ErrorUnknown (99), which the Swift result mapping exposes as .unknown(detail). Hosts consequently cannot distinguish a request awaiting reconciliation, a public-channel privacy refusal, and an internal failure without parsing human-readable text. This also affects public sends whose mandatory initial private-store scan exhausts its budget. Add stable FFI discriminants and corresponding host mappings, keeping channel selection and reconciliation decisions in Rust.

In `packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:923-930: Make the sealing seed zeroize on future cancellation
  esk_seed is a plain array retained across the awaited seal_private_contact_request call. The explicit zeroize afterward covers normal completion and returned errors, but cancellation while the provider is suspended skips it and drops the future without wiping the seed. This is sensitive ephemeral encryption material supplied to the envelope sealer. Wrap it in Zeroizing before filling it so cancellation also runs cleanup.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:1653-1660: Release private reservations after terminal shielded failures
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4508317355)
  The rejection notification handles sequential rejection and retry, but a stale restoration can recreate the rejected reservation after that notification is consumed. restore_private_contact_payments snapshots redrives before acquiring the wallet lock, and the crypto-based restoration additionally awaits scanning after taking its snapshot. A crypto restore can pause there, let the coordinator definitively reject and remove commitment C, let another send reconcile and consume C's rejection notice, then resume and call restore_private_reply for C. No outgoing request exists for the rejected operation, so the recorded-request check does not stop reinsertion. With neither a redrive nor a retained rejection notice, later sends and accepts remain pending until restart. Make terminal outcomes dominate both restoration paths, using retained terminal metadata or coordinated validation and insertion; do not infer failure merely from a missing redrive. Cover a restore paused during scanning and resumed after rejection reconciliation.

In `packages/rs-platform-wallet/src/wallet/shielded/store.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/store.rs:262-264: Document the new ShieldedStore lifetime requirement in migration guidance
  Adding 'static to this public trait rejects custom backends that borrow storage or configuration from their embedding application, although those implementations satisfied the previous contract. The trait comment explains why detached cleanup needs the bound, but the PR's compatibility discussion and the wallet README's existing API migration section do not describe this additional source incompatibility. Document migration to owned or Arc-owned resources and account for the constraint in release compatibility decisions. This is a Rust API change, not a consensus change, and needs no PlatformVersion slot.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1708-1710: Guard reserved shielded notes against pre-broadcast cancellation
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4508317357)
  The new guard protects cancellation during the anchor fetch, but this unconditional handoff disarms it before definite-failure cleanup finishes. If extract_spends_and_anchor returns an error while another task holds the store write lock, pending_entry is still None and execution reaches cancel_pending, which awaits that lock. Cancelling the send there drops the rollback after the guard has been disarmed. Nothing was broadcast, yet the notes remain reserved with anchor: None; stale_pending_spends excludes them, so anchor-pruning cleanup cannot release them. The same unconditional handoff exists in the other shielded spend paths. Keep rollback ownership until definite-failure cleanup actually clears the notes, while retaining recoverable ownership for potentially executed spends. Add a regression that cancels during lock-contended error rollback and verifies that the notes become spendable.

QuantumExplorer and others added 9 commits November 9, 2026 10:35
…their FFI codes

Public sends and accepts now return `PrivateContactRequestPending` and
`PrivateContactRequestNeedsPrivateReply`, but `PlatformWalletFFIResult::from`
mapped neither, so both reached the send and accept exports as
`ErrorUnknown` (99). The codes the FFI branch defines are taken verbatim, so
that branch merges cleanly: 68 `ErrorPrivateContactRequestNeedsPrivateReply`,
71 `ErrorPrivateContactRequestPending`, with 69 `ErrorShieldedBuildRequired`
and 70 `ErrorProtocolVersionNotActive` (a non-transient SDK refusal) from
the same block, their registry rows, and `map_spend_result` keeping 71 and a
`WalletRemovedBeforeSend` at 98.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…send is dropped

The ephemeral seed a private contact request is sealed with was a plain
array, wiped after the awaited seal returned: a send dropped while the
provider sealed skipped that and left the seed unwiped. It is now held in
`Zeroizing` from before it is filled, so every exit wipes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…I migration

Custom shielded stores that borrow from their application must own the
resource, by value or behind an `Arc`, now that the trait is `'static`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the rollback releases them

Every shielded spend handed its note-reservation guard over before its
outcome was handled, so a definite failure released the notes through a
separate rollback that waited for the store lock with the guard already
disarmed. A spend dropped while that rollback waited (the lock held by a
sync or another spend) left its notes reserved for the rest of the session.

The guard is now handed over only by the outcomes that own the notes: a
success, which marks them spent, and an unconfirmed spend, which sync
releases. A definite failure reclaims the guard and releases the notes
through it, so a drop at any point on the way still releases them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion so no restoration revives it

A restoration of the private contact requests whose payment is
unconfirmed reads the payments' records, or opens their entries, before
it takes the wallet lock to reserve their pairs. A payment the shielded
coordinator settled as never executing in between had its rejection
taken by a send's reconciliation, which found nothing to release yet;
the restoration then reserved the pair for an entry that can never be
appended, and the pair stayed blocked until a restart.

The wallet now keeps every rejected commitment for the session, beside
the notices a reconciliation takes, and both restorations check it under
the wallet lock before they reserve a pair. A rejection is noted before
it can be taken, and the take and the check both run under the wallet
lock, so a restoration that resumes after a reconciliation sees it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s until its private payments are restored

A private contact request whose payment is unconfirmed when the process
ends is restored, from the payment's record in the shielded store, only
when the wallet binds its shielded account. A public send or accept
after a restart and before that bind read DashPay's store, found
nothing there (the entry is appended only once the payment executes),
and published the pair the private request kept off chain.

A load now holds the public contact requests, sent or accepted, of each
wallet that bound a shielded account before, which its persisted viewing
key shows: a bind persists the key before installing anything and
refuses when it cannot, so a wallet without one never bound a shielded
account and is never held. While held, they are refused with
`PrivateContactPaymentsNotRestored`, with nothing read, signed or
broadcast. A successful restoration lifts the hold. Besides the bind,
`configure_shielded` now restores every held wallet's payments from the
store with no keys, and a load after it restores those it loads.

The FFI reports the refusal as code 71 (pending), so hosts retry as
they do for a pending request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vider holds another seed

A private contact request sync scanned DashPay's store with whatever
keys its crypto provider derived. A provider resolving another valid
mnemonic derives valid but unrelated keys: they open nothing, yet the
pass recorded its pages as read and a backend kept that position. Its
key fingerprint hashes the identity's public keys, not what the provider
derived, so fixing the provider did not rewind it, and the requests
those pages held stayed unread across launches.

Each pass now checks, before any page is read or any position moves,
that the provider resolves this wallet's seed (`verify_seed_binds`, one
account-0 xpub derivation a pass), and is refused with `SeedMismatch`
otherwise. A pass with nothing to scan derives nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and-scan' into feat/platform-wallet-private-contact-requests

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

At head 2b5e86f, the SDK merge delta changes only documentation and tests, but cumulative verification confirms one remaining restart-privacy blocker: reopening persisted private-payment state without the shielded feature bypasses the recovery hold. Of the other prior findings, 25 are fixed and the remaining mobile error mappings are reasonably deferred to tracked PRs #5812 and #5813. Validation was static only; current CI reports Kotlin build/tests passing, the review check pending, and no Rust validation result.

🔴 1 blocking

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 7: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 8: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — This large, intricate change directly extends shielded funds spending and fee-based note selection in packages/rs-platform-wallet/src/wallet/shielded/operations.rs and note_selection.rs, alongside private-request signing and key handling.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity quota lookup failed), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 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/manager/load.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/load.rs:571-575: Include unresolved private sends in the public-send privacy gate
  The persisted-viewing-key barrier fixes the pre-bind window only in shielded-enabled builds. Both crates omit `shielded` from their default features; without it, `ClientStartState.shielded`, the FFI shielded loaders, and this hold installation are compiled out, while `private_payments_unrestored` initializes to false and public sends remain available. A wallet that previously broadcast and saved an unconfirmed private payment can therefore reopen through a non-shielded build, scan a private store that does not yet contain that entry, and publish the identity pair publicly while the signed private transition remains executable. Persisting the viewing key during binding does not protect this case because the reopening build cannot expose that marker to the gate. Preserve a feature-independent recovery marker or equivalent persistence contract and refuse public sends and accepts when pending-channel recovery cannot be performed. Add coverage for reopening shielded-created pending state without shielded support.

Comment on lines +571 to +575
#[cfg(feature = "shielded")]
if bound_shielded.contains(&wallet_id) {
platform_wallet
.persister()
.hold_until_private_payments_restored();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Include unresolved private sends in the public-send privacy gate

The persisted-viewing-key barrier fixes the pre-bind window only in shielded-enabled builds. Both crates omit shielded from their default features; without it, ClientStartState.shielded, the FFI shielded loaders, and this hold installation are compiled out, while private_payments_unrestored initializes to false and public sends remain available. A wallet that previously broadcast and saved an unconfirmed private payment can therefore reopen through a non-shielded build, scan a private store that does not yet contain that entry, and publish the identity pair publicly while the signed private transition remains executable. Persisting the viewing key during binding does not protect this case because the reopening build cannot expose that marker to the gate. Preserve a feature-independent recovery marker or equivalent persistence contract and refuse public sends and accepts when pending-channel recovery cannot be performed. Add coverage for reopening shielded-created pending state without shielded support.

source: gpt-6.1-sol (phase2-reviewer: general)

QuantumExplorer and others added 4 commits November 9, 2026 12:36
…through the private sync's seed check

The private contact request sync now checks its provider's seed before it
reads a page, deriving the account-0 xpub through the resolver-backed
provider. That derivation turned a resolver's NOT_FOUND into
`InvalidIdentityData`, so the sync failed with `ErrorUnknown` (99)
instead of `ErrorSigningKeyUnavailable` (31) and a host could not route
it to key repair. It now maps signer failures as the page scan does,
keeping a missing mnemonic as the key-unavailable outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ivate payment in every build

The hold on a reloaded wallet's public contact requests came only from
its persisted shielded viewing key, which only a build with the
`shielded` feature loads. A wallet whose unconfirmed private payment a
shielded build saved could be reopened by a build without the feature,
scan a store that does not hold the entry yet, and publish the pair
while the signed private transition could still execute.

Each identity's private scan state record
(`PrivateContactRequestsPosition`, persisted for a backend attesting
`PRIVATE_CONTACT_REQUEST_SCAN_STATE`) now carries
`private_payments_unresolved`. The wallet stores it set before a private
payment may reach the network, refusing the send when that store fails,
and stores it cleared once the identity holds no private request it is
paying for or has not read back: read back, rejected for good, or
released before it was paid. Loaded set, in any build, it holds the
identity: its public sends and accepts, the in-kind accept included, are
refused with `PrivateContactPaymentsNotRestored` until a restoration
from the shielded store clears it. A private sync does not, and a build
without the `shielded` feature never does, which is the fail-closed
outcome: its private payment may still execute.

`DashPayView::private_contact_readiness` reads, from the wallet's own
state, whether its private payments are restored for an identity,
whether its private store was read to its end since load, and whether
the marker is set, so hosts need not infer readiness from refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r own code, with the wallet's readiness

`PrivateContactPaymentsNotRestored`, a wallet-wide hold, reached hosts
as 71, the code of one contact's pending request. Hosts cleared a 71
after a private sync and enabled sending again, although no private
sync lifts the hold, so the next submission was refused once more.

It now has its own code, 72 (`ErrorPrivateContactPaymentsNotRestored`),
next to 71 in the registry. `platform_wallet_get_private_contact_readiness`
reads `DashPayView::private_contact_readiness` for one of a wallet's
identities into `PrivateContactReadinessFFI`: whether its private
payments are restored, whether its private store was read to its end
since load, and whether its unresolved-payment marker is set, so hosts
show the wallet's state instead of reconstructing it.

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

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants