Repository navigation
feat(platform-wallet): send, receive and accept DashPay private contact requests - #5810
QuantumExplorer wants to merge 59 commits into
Conversation
…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>
…tform-wallet-private-contact-requests
…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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
|
🔍 Review in progress — actively reviewing now (commit 03cea26) · triage: critical |
…tform-wallet-private-contact-requests
…tform-wallet-private-contact-requests
…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>
…tform-wallet-private-contact-requests
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
left a comment
There was a problem hiding this comment.
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:
criticalbygpt-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); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-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; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-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.
…tform-wallet-private-contact-requests
…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
left a comment
There was a problem hiding this comment.
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:
criticalbygpt-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); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-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; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-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.
…tform-wallet-private-contact-requests
…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>
…tform-wallet-private-contact-requests
… 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>
…tform-wallet-private-contact-requests
…tform-wallet-private-contact-requests
…tform-wallet-private-contact-requests
thepastaclaw
left a comment
There was a problem hiding this comment.
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:
criticalbygpt-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); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-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; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-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
left a comment
There was a problem hiding this comment.
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:
criticalbygpt-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); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-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; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— platform-versioning (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-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.
…tform-wallet-private-contact-requests
…tform-wallet-private-contact-requests
…tform-wallet-private-contact-requests
…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>
…tform-wallet-private-contact-requests
thepastaclaw
left a comment
There was a problem hiding this comment.
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:
criticalbygpt-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); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-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; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— platform-versioning (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-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.
… 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>
…tform-wallet-private-contact-requests
thepastaclaw
left a comment
There was a problem hiding this comment.
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:
criticalbygpt-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); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-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; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— platform-versioning (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-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.
…tform-wallet-private-contact-requests
…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
left a comment
There was a problem hiding this comment.
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:
criticalbygpt-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); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-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; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— platform-versioning (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-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.
| #[cfg(feature = "shielded")] | ||
| if bound_shielded.contains(&wallet_id) { | ||
| platform_wallet | ||
| .persister() | ||
| .hold_until_private_payments_restored(); |
There was a problem hiding this comment.
🔴 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)
…tform-wallet-private-contact-requests
…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>
Basic explanation
What this does: the wallet can now send, receive and accept DashPay contact requests that nobody else can see.
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.
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:
What was done?
Sending:
PlatformWallet::send_private_contact_request(coordinator, seed, account, sender, recipient, label, auto_accept_proof, shielded_address, signer, crypto, prover)DashPayView::prepare_contact_request, shared by both sends. A later request to the same recipient, either way, rotates past this one.signersigns the request with the first of the sender's keys it holds amongprivate_contact_request_signing_keys: enabledAUTHENTICATIONkeys at a level DashPay's documents are signed at (HIGHorCRITICAL), bound to no other contract or document type.private_document_create_from_pool_with_metadataappends the entry with a type 29 transition paid from the account's notes:ShieldedFeeKind::PrivateDocumentCreate { entry_count, entry_size };Receiving:
DashPayView::sync_private_contact_requests(crypto, max_pages)private_contact_requests_from_scan). That needs no secret key.How a private request is recorded: as a
ContactRequest.contactRequestdocument carries them, with IVs taken from the entry's commitment. So:contactRequestdocument to carry its Platform-assigned$createdAtCoreBlockHeight, and no Core chain puts that at 0. SoContactRequest::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)derivesreceived_privatelyandsent_privatelyfrom 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.Accepting:
PlatformWallet::accept_private_contact_request(coordinator, seed, account, request, shielded_address, signer, crypto, prover)accept_contact_request_replying_with, shared by both accepts.Older requests are no rotation.
ingest_received_requestsandingest_sent_requestspass 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.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.
ContactCryptoProvidergains two methods:scan_private_document_pageseal_private_contact_request, which takes the signature from the identity signerThey 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 testSeedCryptoProviderimplements 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
accept_contact_request_replying_in_kind(onDashPayView, and onPlatformWalletwithshielded) 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.PrivateContactRequestPending.PrivateContactRequestPendinginstead 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.PlatformWallet::restore_pending_private_contact_requestsand right before every private payment, so no second request to that pair is paid for.PrivateContactRequestPending, since the private one may already be on chain.PrivateContactSyncReport::requestsholds what the ingests recorded. Not included: a request already tracked, an ignored sender's, a replay, or one whose store failed.How Has This Been Tested?
cargo test -p platform-wallet --features shielded --lib -- private_contact private_document_create contact dashpay older_request: 233 passed.should_send_read_and_accept_a_private_contact_request. Two wallets on mock SDKs: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_timean_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.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:
structure.rs, regeneratedgrovedb-structure.json, and checked the structure viewer link posted on this pull requestFor repository code-owners and collaborators only
🤖 Generated with Claude Code