Repository navigation
feat(platform-wallet): send, receive and accept DashPay private contact requests - #5810
QuantumExplorer wants to merge 63 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
|
|
⛔ Final review complete — 2 blocking finding(s) (commit 8260a1c) |
…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>
…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.
…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>
…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 c7502c4 against the source and review evidence. Most prior fixes are present, but four blocking privacy/recovery issues remain: optional or non-durable recovery marking, ambiguous broadcast failures releasing protection, malformed recovery associations becoming absence, and compressed-byte matching losing valid recipient keys. This was a static review only; current CI reports passing Kotlin build/tests and policy checks, with the review pending and no separate Rust wallet test result shown.
🔴 4 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: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); 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) — The large, intricate diff directly changes shielded funds spending and fee-aware 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/wallet/identity/network/private_contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:858-870: Include unresolved private sends in the public-send privacy gate
Private payment proceeds without feature-independent recovery protection when PRIVATE_CONTACT_REQUEST_SCAN_STATE is absent: this helper returns success without writing a marker, although shielded binding requires only SHIELDED_FVK_RESTART. Reopening that wallet without `shielded` installs no viewing-key barrier, and load_from_persistor additionally discards a restored identity hold through forget_private_contact_scan_state. An empty private-store scan can then permit a public request while the saved private transition remains executable. The example-app assertion does not close this on the reviewed head: neither host declaration includes bit 14, and FFIPersister::callback_capabilities cannot expose it through its capability intersection.
Even an attesting backend is not protected if it buffers writes. PlatformWalletPersistence explicitly permits store to defer durability until flush, but this helper neither requires store_commits_inline nor flushes before payment. A process exit can therefore retain the shielded transition while losing the true marker. Require durable, feature-independent recovery marking before payment, preserve known unresolved holds independently of optional scan checkpoints, and refuse payment when that contract cannot be satisfied. Cover both a non-attesting backend reopening without shielded support and a buffered backend losing unflushed changes.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:694-703: Retain private-channel protection after potentially delivered broadcast failures
This releases the pair and updates its unresolved-payment marker for every payment error except Pending and ShieldedSpendUnconfirmed. However, broadcast_shielded_spend maps errors accepted by broadcast_definitely_failed into ShieldedBroadcastFailed, and that shared policy explicitly acknowledges that Unavailable can occur after delivery or hide an earlier delivered retry. Applying its fund-safe note-release tradeoff to channel privacy is unsafe: the private transition can still land after this reservation is released. Because pre-send sync has already marked the store read, a subsequent public send can publish the identity pair without another scan. Keep the privacy reservation and durable marker for outcomes that permit prior delivery, independently of whether notes are released under the existing spend policy. Preserve enough outcome information to distinguish non-delivery from ambiguity, and cover an admitted submission whose response is Unavailable.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:1602-1611: Match recovered recipient keys as curve points rather than encoded bytes
sent.recipient_public_key.serialize() always produces a 33-byte compressed encoding, but valid ECDSA_SECP256K1 identity keys can contain the 65-byte uncompressed encoding of the same point. DPP permits that representation, and the SDK sealer parses it successfully; the SDK also has an explicit uncompressed-key alias regression. This byte comparison nevertheless fails, so outgoing recovery skips the valid request and subsequently advances the cursor and readiness flag. A fresh send pays but cannot complete read-back, while a seed-restored sender can lose the private-channel evidence and later publish a public request to that recipient. Parse ECDSA identity keys and compare normalized curve points, reusing the SDK's key eligibility rules where applicable. Cover recovery with an uncompressed recipient key and verify that a subsequent public send remains refused.
In `packages/rs-platform-wallet/src/wallet/shielded/file_store.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/file_store.rs:329-334: Fail closed when a saved private payment association is malformed
Both a SQLite decoding error and a non-null association blob with an invalid length become None here. The valid signed transition and its redrive are still restored, but private_contact_redrives_for_wallet omits that payment because the association is missing. restore_private_contact_payments then succeeds and clears the wallet and identity recovery holds without reserving the affected pair. If the transition has not executed, a public send can scan an empty private store and publish the relationship while the saved private payment remains executable. The association is now safety-critical recovery state, not optional display metadata. Preserve SQL NULL as the explicit legacy case, but propagate malformed non-null data as a typed recovery error so restoration cannot release the holds. Add a reopen regression containing a valid saved transition and a malformed association.
…d association that does not read Reopening the file shielded store read a saved payment's association with the private contact request it pays for as absent both when the column held SQL NULL and when it held a value that did not decode, or did not read. The record's signed transition was still restored, but the payment dropped out of `private_contact_redrives_for_wallet`, so the restoration succeeded, cleared the wallet's and identities' holds, and reserved nothing for the pair while the payment could still execute. SQL NULL stays the legacy record with no association. Any other value that does not read back is now kept as unreadable with its record, and listing the wallet's payments fails with `RecoveryCorrupted`, so the restoration fails and the holds stay; a damaged record retained without being loaded that carries an association fails it too. The record keeps its retained-and-skipped handling, and an unreadable association goes with its record when the payment resolves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ey as a curve point Recovering a private contact request the wallet's identity sent compared the 33-byte compressed key the entry was sealed to with each recipient key's stored bytes. An ECDSA_SECP256K1 identity key may hold the same point uncompressed, which DPP accepts and the sealer reads, so such a request was passed over and the read position moved past it: a sender restored from seed lost the evidence that the requests went privately, and could publish a public request to that recipient. Each ECDSA_SECP256K1 key of a purpose an entry can be sealed to is now parsed and compared as a point; other keys are not matched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and through an ambiguous broadcast Three gaps let a reopened or still-running wallet publish a pair whose private contact request payment could still execute. A backend that did not keep the private contact request scan state took a private payment with no recovery marker, and a buffered one could lose the marker it stored. A private send or accept is now refused with `PrivateContactRecoveryUnsupported` (FFI code 73) before anything is sealed or paid unless the backend keeps that state, and the marker is flushed before paying when the backend does not commit a store inline; a failed store or flush refuses the payment. A clear is not flushed: losing one only holds the identity after a restart. A load that drops the scan state of a backend no longer attesting it keeps a restored hold. Keeping the scan state now also takes atomic changesets (`PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART`): an un-ignore stores the sender's removal and the read-position rewind in one changeset, and a backend committing one without the other would read on past the requests passed over while the sender was ignored. A backend declaring the scan state alone keeps none of it. A spend whose broadcast failed with `Unavailable`, which can follow delivery, released the private request's pair with its notes. It now fails with `ShieldedBroadcastMaybeDelivered` (FFI code 16, as a failed broadcast); its notes are still released, as the nullifier set keeps funds safe, but the pair stays held and its marker set, as the entry may still be appended. 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 of head 8260a1c confirms two blocking recovery gaps: ambiguous-broadcast cleanup deletes durable private-payment evidence, and restoration treats damaged associated transition bytes as absence. Of the 32 prior findings, 30 are fixed, one remains valid, and one remains intentionally deferred to tracked host-mapping follow-ups. Current CI shows Kotlin build/tests and policy checks passing, with this review pending; no local builds or tests were run.
🔴 2 blocking
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: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 8: 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 movement and note reservation 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/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1762-1766: Retain private-channel protection after potentially delivered broadcast failures
ShieldedBroadcastMaybeDelivered preserves the live contact reservation, but the shielded operation still routes it through this generic failure arm. reservation.cancel() calls clear_pending, which deletes the saved redrive row and evicts its PrivateContactPayment association in FileBackedShieldedStore. After an admitted submission returns Unavailable and the application restarts, the durable marker initially holds the identity, but restore_private_contact_payments finds no associated payment and clears both recovery holds. A private-store scan before the admitted transition executes can legitimately return empty, allowing a public request to disclose the identity pair or another private request to pay again. The new marker and fresh scan therefore do not establish the restart guarantee described in your reply: the cleanup has removed the evidence that the earlier transition remains executable. Preserve durable pair and commitment recovery evidence independently of the deliberate note-release policy, and discharge it only after publication is reconciled or non-execution is established. Extend the regression through the actual shielded rollback, file-store reopen, restoration, and a public-send attempt before entry inclusion; the current callback-injected test exercises only the live-session hold.
In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:1837-1841: Fail restoration closed when an associated private payment's transition is unreadable
Every record in saved already has an explicit PrivateContactPayment association, but private_contact_request_redrive_entries silently skips transitions that cannot deserialize or do not match the expected contract/type, and this filter_map also drops entries shorter than their commitment. FileBackedShieldedStore::rehydrate_pending_spends accepts a row with valid metadata and a readable association even when its st_bytes blob cannot deserialize: the otherwise-valid metadata path does not require a successful transition decode. Damaging only those bytes after an unconfirmed broadcast therefore lets the store reopen and list the associated record, produces no payments here, and reaches note_private_payments_restored/private_payments_restored, releasing the known unresolved holds. The original network copy can still execute, so an empty private-store scan can subsequently permit public disclosure. Make explicitly associated payment recovery all-or-error, validating the transition family, target and entries before releasing any hold; preserve associated recovery evidence when redrive decoding fails as well. Add a reopen regression with an intact association and corrupted signed-transition bytes, asserting that restoration fails and public submissions remain refused.
| ShieldedActivityStatus::Failed, | ||
| None, | ||
| ) | ||
| .await; | ||
| reservation.cancel().await; |
There was a problem hiding this comment.
🔴 Blocking: Retain private-channel protection after potentially delivered broadcast failures
ShieldedBroadcastMaybeDelivered preserves the live contact reservation, but the shielded operation still routes it through this generic failure arm. reservation.cancel() calls clear_pending, which deletes the saved redrive row and evicts its PrivateContactPayment association in FileBackedShieldedStore. After an admitted submission returns Unavailable and the application restarts, the durable marker initially holds the identity, but restore_private_contact_payments finds no associated payment and clears both recovery holds. A private-store scan before the admitted transition executes can legitimately return empty, allowing a public request to disclose the identity pair or another private request to pay again. The new marker and fresh scan therefore do not establish the restart guarantee described in your reply: the cleanup has removed the evidence that the earlier transition remains executable. Preserve durable pair and commitment recovery evidence independently of the deliberate note-release policy, and discharge it only after publication is reconciled or non-execution is established. Extend the regression through the actual shielded rollback, file-store reopen, restoration, and a public-send attempt before entry inclusion; the current callback-injected test exercises only the live-session hold.
source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, platform-versioning, security-auditor)
| .flat_map(|(redrive, payment)| { | ||
| private_contact_request_redrive_entries([redrive], dashpay_id) | ||
| .into_iter() | ||
| .filter_map(move |entry| { | ||
| let commitment: [u8; 32] = entry.as_slice().get(..32)?.try_into().ok()?; |
There was a problem hiding this comment.
🔴 Blocking: Fail restoration closed when an associated private payment's transition is unreadable
Every record in saved already has an explicit PrivateContactPayment association, but private_contact_request_redrive_entries silently skips transitions that cannot deserialize or do not match the expected contract/type, and this filter_map also drops entries shorter than their commitment. FileBackedShieldedStore::rehydrate_pending_spends accepts a row with valid metadata and a readable association even when its st_bytes blob cannot deserialize: the otherwise-valid metadata path does not require a successful transition decode. Damaging only those bytes after an unconfirmed broadcast therefore lets the store reopen and list the associated record, produces no payments here, and reaches note_private_payments_restored/private_payments_restored, releasing the known unresolved holds. The original network copy can still execute, so an empty private-store scan can subsequently permit public disclosure. Make explicitly associated payment recovery all-or-error, validating the transition family, target and entries before releasing any hold; preserve associated recovery evidence when redrive decoding fails as well. Add a reopen regression with an intact association and corrupted signed-transition bytes, asserting that restoration fails and public submissions remain refused.
source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor)
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