Repository navigation
feat(platform-wallet): send, receive and accept DashPay private contact requests - #5810
Conversation
The prepared-keys test also opens an entry with the first key of the set, so a scan skipping earlier keys would fail it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK can now append entries to a private document type's store, paid from the shielded pool (type 29), and find a wallet's entries in it: - `PrivateDocumentCreateFromShieldedPool` builds, structure-checks and broadcasts the transition from a proven bundle, and waits for its nullifiers to be proven spent. - `PrivateDocumentScanKeys` and `Sdk::scan_private_document_page` sort a proved page of a store into what the wallet's identities received (their keys open it), what they sent (their outgoing viewing keys recover it) and what opened but is refused, with the cursor to scan from next. - DashPay: `PrivateContactRequest` with the schema's bounds, `seal_private_contact_request` (the sender's encryption key, a deniable MAC, and the outgoing viewing key derived from that key), and `Sdk::scan_private_contact_requests`, which decodes each request, checks its sender against the identity key it names and keeps the first request per sender, recipient and account reference. platform-encryption: prepared recipient keys check a sender with the key that opened the entry, so a scan never copies secret keys out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wallet restoring its sent requests needs the encryption key each was sealed with to rebuild its local record of the request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
A private contact request carried a deniable MAC keyed by the static ECDH of the sender's encryption key and the recipient's key. Anyone holding the recipient's key could compute it, so a stolen recipient key forged requests from any sender, including rotations that redirect the recipient's payments. The request is now signed by one of the sender's signing keys, as its public request's transition is: an enabled AUTHENTICATION key at a level DashPay's documents are signed at (CRITICAL or HIGH), ECDSA_SECP256K1 or ECDSA_HASH160. The entry still names the encryption key, which the pair's shared key and the outgoing viewing key come from; the reader recovers the signing key from the signature. Entries with a MAC are refused. Envelope (signature mode, nothing deployed): the signature now signs the double SHA-256 of the signing data, as identity signers sign data, so a wallet signs with the signer it signs transitions with. The caller passes the signature; `private_document_signing_data`, `sign_private_document_signing_data`, `recover_private_document_signer` and `OpenedPrivateDocumentEntry::recover_signer` are new. Prepared recipient keys are wiped on drop. Reading also checks, at the entry's block time, the recipient key it is encrypted to, and that a sent entry names the identity and key its outgoing viewing key comes from and is signed by that identity. The sender is checked before the payload is decoded. `PrivateDocumentScanKeys::verify_sender` tries every key set of the identity. 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>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
Walkthrough
Pre-merge checks:
|
|
⏹️ Not reviewed — PR is closed (merged) (commit 54ae054) |
Review fixes: - The envelope's authenticated data names the recipient key `ser(R)`, so a body re-encrypted to another key of the same identity (an old key's holder re-targeting a signed request) authenticates no sender. `private_document_signing_data` takes the recipient. The MAC and signature vectors are re-pinned; nothing is deployed. - A signing key bound to a contract signs only inside its bounds: DashPay, or its `privateContactRequest` type. A contract group bound signs none, as a client cannot read the group's members as they were. The seal and the reader apply the same rule; `private_contact_request_signing_keys` lists the keys that qualify. - `PrivateContactRequest::from_properties` drops a null only under an optional property's name, so an unknown property set to null is refused. - Recovered sent requests are deduplicated per sender, recipient and account reference, apart from the received ones. - Senders added with the same outgoing viewing key: an entry recovers as the one it names. - Docs: the transition's proof shows the nullifiers spent, not that its entries were appended; fetching a sender right after its page tells the serving node which identity the wallet looked up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tform-wallet-private-contact-requests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…y heights Review fixes: - Requests are checked against their type's schema with dpp's document validation (`DataContract::validate_document_properties`), before encoding when sealing and after decoding when reading, as the payload codec asks. The SDK's own copies of the label and proof bounds are gone; `to_properties` is a typed conversion. The SDK enables dpp's `validation` feature. `seal_private_contact_request` and `private_contact_request_signing_data` take `platform_version` last. - The reader takes each identity with the height its proof is of (`ProvedIdentity`). `private_contact_request_identities` lists, per identity, the latest block whose entry it checks; a snapshot proved before it, which a node may serve within the SDK's height tolerance, or one not given, fails the read instead of judging the entry against keys as they were before it. Only signed entries need identities. - `Sdk::fetch_private_contact_request_identities` fetches them four at a time with their metadata and fetches a stale one once more. - Both SDK helpers establish protocol version 15 before resolving DashPay, so an unpinned SDK starting below it does not read a DashPay without the private type. - `PrivateDocumentScanKeys::derive` builds scan keys from key paths with a derivation callback, wiping what it derives, for signers that keep keys. 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.
…he private type Review fixes: - A signing key past its `expires_at` signs nothing: the reader checks it at the entry's block time with dpp's `is_expired_at` (the expiry instant is expired), and the key list and the seal check it at this device's clock, so an expired key is not offered. - `private_document_create_from_shielded_pool` establishes protocol version 15 before building the transition, which an older SDK's limits would refuse for any entries. - A DashPay the context provider holds from before the private type is refetched from the network and registered with the provider, which the page's proof is verified against. - The reader refuses an identity given under another identity's id, and a recipient key id that is not the key the entry is encrypted to. - The identity fetch iterates owned pairs, so its future is `Send` (rust-lang/rust#100013), and its retry logic is a function tests drive with scripted proofs. - An OS RNG failure is an error, not a panic. - The wasm-sdk contract fixture gives its indexed `rarity` string the `maxLength` dpp's now enabled validation requires of an indexed string. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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.
… the refreshed DashPay A private contact request's encryption keys, the sender's and the recipient's, are now used only inside their contract bounds: unbound, bound to DashPay, or bound to DashPay's contactRequest or privateContactRequest. contactRequest is allowed because DashPay requires bound keys for it and apps register their DashPay keys bound to it. Keys bound to another contract, another DashPay type or a contract group are refused by the seal and the reader. dpp decides the document-type scope once, in ContractBounds::check_document_type (Allowed, Denied, or needing the group's members, which a client refuses). The signing key check and encrypted_for's key check use it too. After registering a refreshed DashPay, the helper reads it back from the context provider and fails if the provider still lacks the private type, as every page's proof would. The reader's docs no longer claim a key was the identity's when the entry was appended. Tests: encryption key bounds for both roles in the seal and the reader; the DashPay refresh on an unpinned SDK, the already-current shortcut, and a refresh the provider does not take; a send on an unpinned SDK learns the network's version before it builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
|
Waiting for bot review — thepastaclaw not yet. Wait for the missing reviews, or a writer can post |
…uests Brings in #5556 (shared broadcast keeps uncertain submission outcomes), #5823 (proven state transition outcome, getRecentStateTransitionStatus), #5815, #5834, #5825, #5835, #5838 and #5839. One textual conflict, in packages/rs-sdk/src/platform/transition/broadcast.rs at the end of broadcast_with_retries. This branch (#5837) had the shared broadcast fail over on a nonce-ahead answer and on a missing DPNS owner (retry_with_additional_error, from #5562), and made is_address_nonce_ahead public for the wallet's single-dispatch broadcast. #5556 replaced both failovers with the plain shared retry, so that a concrete refusal ends sending, with tests saying so (should_stop_on_nonce_rejection_without_ rebuilding_or_resending, should_stop_on_concrete_rejection_regardless_of_ node_failover_settings), and removed is_address_nonce_ahead and is_missing_transition_owner. The file is taken as v5.1-dev has it. The wallet's single-dispatch broadcast (broadcast_outcome::track_delivery) still goes on to the next dispatch after a nonce-ahead answer, as #5837 meant: the classifier moves into broadcast_outcome.rs, unchanged, and its doc no longer says the SDK's own broadcast fails over too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the single-dispatch broadcast and the spend contract Since #5556 the SDK puts a broadcast that may have been delivered under Error::StateTransitionResultUnconfirmed, a duplicate answer included (a gRPC AlreadyExists is classified as possibly accepted). The wallet's single-dispatch broadcast read only a bare AlreadyExists as admitted, so a node already holding the transition now read as maybe delivered and the same bytes went on to the rest of the budget. A node holding the transition, bare or under the unconfirmed or retry envelope, is admitted again. The envelope is maybe delivered whatever it wraps, never a refusal before anything was sent, and the identity shield never reads it as a refusal. The FFI's shielded spend contract (map_spend_result) flattened an unclassified SDK unconfirmed outcome to ErrorWalletOperation (6), which reads as a failure; it now keeps the blanket conversion's ErrorTransactionBroadcastUnconfirmed (20). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns in the registry #5556 routes the SDK's unconfirmed state transition outcome to ErrorTransactionBroadcastUnconfirmed (20), and the shielded spend contract keeps it there; the registry row now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Waiting for bot review — thepastaclaw not yet. Wait for the missing reviews, or a writer can post |
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
The exact review head still has two blocking wallet-state defects. Private reservation and cleanup mutations are not consistently fenced to the originating wallet generation, and restoration can discharge a journal after a transient persistence flush failure merely because the request is present in memory. The module-size concern remains a valid maintainability suggestion; Rust workspace checks passed, while Swift and JS Evo checks were still in progress in the supplied CI snapshot.
🔴 2 blocking | 🟡 1 suggestion(s)
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: ffi-engineer); 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: gpt-6-astra (agent: phase2-reviewer, role: architecture-layering); reviewer 9: gpt-6-astra (agent: phase2-reviewer, role: ffi-engineer); reviewer 10: gpt-6-astra (agent: phase2-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 changes critical funds-movement and coin-selection paths in shielded operations, cryptographic key handling and signing for private contact requests, durable wallet persistence, and FFI error/result boundaries, including functions such as PlatformWallet::send_private_contact_request and shielded broadcast 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— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— rust-quality (completed, effort xhigh); agentphase2-reviewer - Model comparison: every Phase-2 reviewer also ran on
gpt-6-astra; the verifier weighed both sets without knowing which model wrote which
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review (PastaClaw). How to work through them:
- Verify each finding against the code; fix only what is real, as narrowly as the finding.
- If a finding is wrong, decline it on its thread in one or two sentences; the bot answers once and stops.
- To defer one, link a tracking issue on its thread (or reply asking the bot to file one).
- Suggestions are optional: fix them forward in a later PR rather than pushing another round just for them.
- Address every current finding in one push: self-review the full diff first, and don't push piecemeal.
- Keep the PR's scope; don't grow it to satisfy hardening suggestions.
- Carried items were raised in an earlier round: read the thread first, and don't re-fix what you already fixed, declined or deferred there. Disputed and deferred items are already handled and are not listed.
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:1007-1020: Fence private contact reservations to the wallet generation
`send_private_request` reserves the sender/recipient pair through `with_managed`, but `with_managed` looks up `PlatformWalletInfo` by `wallet_id` only and does not compare the handle's `WalletGeneration`. A stale private-send handle can finish its preflight read, then have its wallet removed and recreated under the same ID before this reservation runs; the reservation is consequently installed on the replacement generation. The later payment-generation guard does not prevent this because it occurs after reservation and preparation, and both `PrivateReplyReservation::release` and its detached `Drop` cleanup call `release_reservation` and `store_private_payment_marker` with the wallet ID alone, so stale cleanup can also mutate the replacement. Use the generation-aware `current_wallet_info_mut`/`WalletGeneration` checks for reservation, marker, and cleanup mutations, making stale handles no-ops against replacements while retaining attempt ownership.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:1-60: Split the 12k-line private contact requests module
`private_contact_requests.rs` is approximately 12,609 lines at this head, alongside other similarly large wallet orchestration modules. Keeping scan progress, journal restoration, send/accept orchestration, and extensive test helpers in one module makes the ownership and error paths difficult to navigate and review and increases incremental compilation cost. Extracting these responsibilities into focused submodules under the private-contact network area would make the lifecycle boundaries clearer without changing behavior.
In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:3171-3212: Flush settled contact records before restoration discharges their journal
`restore_journaled_private_payments` treats a journal record as settled when the current in-memory outgoing request records or supersedes its account reference. `restore` then discharges that journal whenever `buffered_writes_lost()` is false, but that flag only identifies a later permanent flush failure; it does not prove that the current contact record and cursor changes are durable. A private sync can record the request in memory, encounter a transient flush failure, retain the read-back notice, and then run restoration before the retry succeeds. The restoration can discharge the journal and publish the discharge while the contact row is still only buffered; if a later flush permanently drops that buffer, restart can find neither the request nor its journal protection. Restoration must require successful durability of the settled contact changeset, or defer journal discharge until the pending read-back notification has been durably acknowledged.
|
Your move: thepastaclaw requested changes on this head; dismiss the review or push a fix; thepastaclaw left review threads unresolved; resolve them. |
…eration A handle taken before its wallet was removed and created again under the same id could still act on the replacement: its reservation's release and drop stored a private payment marker for the replacement's identity, its managed-identity steps reserved on the replacement, and a public claim could be taken there. Each such step now acts only while the wallet is still the handle's generation: a send is refused with WalletRemovedBeforeSend before anything is paid, and cleanup is a no-op. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… once durable A restoration discharged a journal record whose request the wallet tracks even when that request was stored but not yet flushed. A backend that does not commit inline is now flushed first, and a failed flush leaves the record for a later restoration. A record whose read-back still waits for its flush is left to that path, which a restoration now retries first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README said shields from Platform addresses and from an identity both keep their own handling. A shield from Platform addresses now broadcasts in single dispatches and reports ShieldedSpendUnconfirmed (FFI code 18) once a dispatch may have reached a node; only the identity-funded shield keeps its own handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TransitionOutcome::ExecutedOrSuperseded now carries consumed_by_height, the lowest height of a read that showed one of the transition's inputs consumed: the transition cannot execute after it. A caller that knows what the transition would have written can then read it at or above that height and tell a transition that never wrote it from one that did. A single-nonce transition, such as a document create, is never CannotExecute (that needs one input taken while another is free), so this is the only proof that one will not write its document. The outcome API is unreleased (#5823), so the variant changes in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sdk::sign_contact_request returns the signed contactRequest create transition (SignedContactRequest), so a caller can keep the bytes and broadcast the same transition again on a retry instead of signing a second request under a new identity contract nonce. Sdk::send_contact_request is now sign, broadcast and wait for the same transition, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unconfirmed A public contact request (a send, an accept, the in-kind accept's public reply, a QR send, an auto-accept, a rotation) released its pair however its send ended. After a broadcast that may have been delivered, the SDK's unconfirmed outcome left nothing holding the pair, so a second accept signed and submitted a second transition while the first could still execute. The send now signs, keeps the signed transition, and broadcasts it by the rule shielded spends use. Once a dispatch may have reached an endpoint and the document is not confirmed, the pair is held by the wallet as an unconfirmed public request, bound to the wallet generation, and the host still gets the SDK's unconfirmed outcome (FFI 20). While it stands, a public or private request or accept to the pair is refused with PublicContactRequestUnconfirmed, with nothing signed or paid, and the sender identity cannot be removed. DashPayView::public_contact_request_unconfirmed reads it per pair. The public contact request sync, and each send or accept to the pair, settles it from proved reads: the request found by DashPay's unique index is recorded as sent; its identity contract nonce proved used, with the request proved absent at or above that height, frees the pair; otherwise the same signed bytes are broadcast again. Before signing, a send adopts a request already on Platform that it would repeat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…request PlatformWalletError::PublicContactRequestUnconfirmed maps to ErrorPublicContactRequestUnconfirmed (76) in the blanket conversion and in map_spend_result, which the private send and accepts use, so a host can tell a public request awaiting its outcome from a private request pending (71). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Your move: thepastaclaw left review threads unresolved; resolve them. |
|
Waiting for bot review — thepastaclaw not yet. Wait for the missing reviews, or a writer can post |
Why this PR is large, what in it is interim, and how it gets finishedIn short: most of this PR is the wallet making up, on the client, for something Platform cannot give it yet: a proof that a state transition executed, or that it can no longer execute. That gap is tracked in #4270. The outcome handling here is interim and is meant to be replaced once that proof exists. The gap underneath
Because of that, after a broadcast that may have reached the network the wallet keeps the payment's notes reserved and the contact pair held, and releases them only on evidence it can verify itself: the entry read back from the store, the request proved on Platform, or the spend's anchor proved pruned. What the wallet is compensating for
SizeAt
The feature itself was about 1,900 non-test lines when it was first reviewed. The rest was written in response to review. How it grew under reviewThe PR was 2,800 lines at its first review. Up to
So 40 of the 57 newly posted findings were in code written as fixes, and every review listed between 1 and 6. The count has not trended down. Each posting is classified by the age of the lines it points at, which is an approximation: some findings were posted again at new lines after a fix. What a proof of execution or non-execution would replace
In their place: one saved record per transition, written before broadcast and resolved by that proof, for every kind of transition. For that to work the proof has to make non-execution final (an expiry height, or "can no longer be admitted"), not only "not executed as of height H", and for the private payment it has to cover the appended entry, not only the spent notes. It would not replace the read of the private store before sending (that discovers other parties' and other devices' requests), protection against paying twice from two devices, or saving the record before broadcasting. Limits accepted hereThey are listed under "Costs" in the description and in the wallet README. The main ones: a payment that never lands holds its pair until its anchor is pruned, longer on an idle pool; the hold on an unconfirmed public request is in memory, so after a restart a private request to that pair is not held back while the public one may still land (a second public request is still prevented by DashPay's unique index and a proved lookup before signing); a build without Proposed way to finish
🤖 Written with Claude Code |
#5810 was squash-merged into v5.1-dev, so its changes arrive a second time. Eight hunks in six files conflicted, each where the squash carries the wallet PR's wording for something this branch finishes (registry rows 68 to 76 naming the exports and Swift cases, doc comments that said an export "is added by the FFI PR", `catch_spend_panic` kept `pub(crate)` for the private contact request exports, a comment in SendViewModel). This branch's version is kept in each; the wallet and SDK crates are now identical to v5.1-dev. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…in-private-contact-requests Brings in v5.1-dev through the FFI branch, now that #5810 is merged there, and the registry's corrected next allocatable result code (77). No conflicts; nothing in the Kotlin SDK, the JNI or the app changes. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Basic explanation
What this does: the wallet can now send, receive and accept DashPay contact requests that nobody else can see.
Value: private requests make DashPay contacts without publishing who knows whom. Once received, a private request establishes a contact and gets its payment accounts exactly as a public one does, so payments, rotation and restore keep working.
Risks: medium.
v5.1-dev, an endpoint's verdict and some transport failures after a dispatch reported failed (16) and released them, and most note spends saved their record only after an unconfirmed result. Hosts must not offer a retry with other notes on 18.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:
Known limit, tracked in #4270. Platform cannot yet prove to a client whether a state transition executed, or that it can no longer execute. Until it can, the wallet infers the outcome of a transition whose broadcast was not confirmed: it keeps the payment's notes reserved and the contact pair held, and releases them only on evidence it can verify itself (the entry read back from the store, the request proved on Platform, or the spend's anchor proved pruned). That is why a payment that never lands holds its pair for up to about 1100 blocks, and why this PR carries a payment journal, expiry evidence and several holds.
This handling is interim. It is meant to be replaced by a proof of execution or non-execution for each transition: #4270 describes the gap in the proofs, #5822 states what the wallet needs from it, #5823 is a first step, and #5269 covers a node's unproved view of a transition.
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 private request to the same recipient rotates past this one; a later public one is refused (68).signersigns the request with the first of the sender's keys it holds amongprivate_contact_request_signing_keys: enabled, unexpiredAUTHENTICATIONkeys of a signing key type, 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 };PrivateContactRequestPending(71) and the request stays pending.One private request in flight per recipient. It is reserved before sealing. A retried or concurrent send or accept gets
PrivateContactRequestPending(71) instead of paying again, and so does a public request to that recipient.WalletRemovedBeforeSend, 98) before anything is paid, and its release and drop cleanup act on nothing of the new wallet.Receiving:
DashPayView::sync_private_contact_requests(crypto, max_pages)private_contact_requests_from_scan). That needs no secret key.PrivateContactSyncReport::requestsholds what the ingests recorded. Not included: a request already tracked, an ignored sender's, a replay, or one whose store failed.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. The contact's shielded address is taken from the request tracked from that contact. It is persisted for a backend that keeps the private scan state; otherwise the scan a cold start runs rebuilds it.Accepting:
PlatformWallet::accept_private_contact_request(coordinator, seed, account, request, shielded_address, signer, crypto, prover)accept_contact_request_replying_with, shared by both accepts.accept_contact_request_replying_in_kind, onDashPayViewand onPlatformWalletwithshielded, replies the way a request came.PlatformWallet's version takes what a private reply is paid with as a closure (PrivateReplyPayment), called only for a private reply. A public or adopted reply therefore needs neither shielded support nor the seed.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. Collapsing a sweep orders the same way. A read from the store's start (a cold start without a persisted read position, or a read rewound after a key change) 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 three methods:scan_private_document_page;scan_private_document_pages, which sorts several pages with one key derivation and defaults to onescan_private_document_pagecall per page;seal_private_contact_request, which takes the signature from the identity signer.They take Rust-built key paths (
PrivateDocumentKeyPaths), so the scalars never leave the signer.scan_private_document_pageandseal_private_contact_requestdefault to an "unsupported" error, so existing providers compile unchanged. The in-crateSeedCryptoProviderimplements them. So does the FFI'sResolverContactCryptoProvider, throughMnemonicResolverCoreSignerinrs-sdk-ffi, which derives the keys with the host's mnemonic resolver. The exports that send and accept private requests come with the FFI PR.SDK.
Sdk::sign_contact_requestreturns a signedcontactRequestcreate (SignedContactRequest) without broadcasting it, so the wallet keeps the bytes and a retry broadcasts the same transition;Sdk::send_contact_requestis now sign, broadcast and wait, as before.TransitionOutcome::ExecutedOrSupersededcarriesconsumed_by_height, the lowest height a read showed one of the transition's inputs consumed, after which it cannot execute (the outcome API, #5823, is unreleased).Sdk::private_document_pages_with_heightsyields each page with the lowest and highest heights its reads were proved at (PrivateDocumentPageHeights). The mock SDK can answer each query at a set height, which the tests use for mixed-height pages.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.Private payments, holds and the store read
The private payment journal. Before a private request's payment can reach the network, the shielded store saves a journal record of it. The record and the spend's redrive record go in one durable write (SQLite
synchronous=FULLwithfullfsync). A record that cannot be saved refuses the payment.The record keeps:
No note release, redrive removal, account rebind or purge removes a record. A record is released only on proof:
After a flush of the wallet's records failed for good, nothing is released on the strength of a tracked request for the rest of the process: no read-back is noted, a read of the entry again included, and a restoration leaves the records it finds settled. The tracked request may then be in memory only. A restoration that finds a record whose request its sender does not track rewinds the sender's read to the position the record kept, so the next process reads the entry again, records the request durably and releases the record.
An endpoint's answer, a consensus verdict included, proves nothing and releases nothing: no proof or quorum signature backs it.
Broadcast outcomes. Every shielded spend that consumes notes (transfers, unshields, withdrawals, identity top-ups and creations from the pool, and private document creations), every shield from Platform addresses (to the wallet's own notes or to a recipient) and every masternode credit withdrawal broadcasts in single dispatches and keeps what each one showed.
StateTransitionResultUnconfirmed), whatever it wraps. A node's answer that it already holds the transition (AlreadyExists, bare or inside that outcome) counts as admitted. The transition then waits for its result.ShieldedBroadcastFailed(16) for a note spend, its notes released, or for a shield; an ordinary, retryable error for a withdrawal.ShieldedSpendUnconfirmed(18), its notes and record kept until it lands or its anchor is pruned; an identity creation reportsShieldedBroadcastUnconfirmed(17) instead.MasternodeWithdrawalUnconfirmed(42) for every outcome short of the proved remaining balance.Public requests whose outcome is unconfirmed. Every public
contactRequest(a send, a public accept, the in-kind accept's public reply, a QR send, an auto-accept, a rotation) goes through one send, which signs, keeps the signed transition and broadcasts it by the rule above.ownerIdUserIdAndAccountRefunique index (sender, recipient, account reference), proved. One found, sent by a process that ended before recording it or by another device, is adopted as sent with nothing signed. A read that fails sends on.PublicContactRequestUnconfirmed(76), nothing signed or paid, and the sender cannot be removed.Sdk::state_transition_outcomereads the transition's identity contract nonce, then the unique index is read. A request found is recorded as sent and frees the pair. With none found, the nonce proved used at a height at or below the read that missed the request frees the pair. Otherwise the same signed bytes are broadcast again. No retry signs a second transition. A single-nonce transition is neverCannotExecute, so the nonce-used-and-absent case is what frees a request that will not land.What holds contact requests after a restart, and what lifts it.
configure_shielded, a bind, or the one each contact request runs first without waiting on the storeshieldedconfigure_shieldedrestores every held wallet, and every wallet whose payments the journal may keep.PlatformWallet::restore_pending_private_contact_requestsremains for payments saved before the journal kept them, and a restoration adds those to the journal.The read before every contact request. Every contact request and acceptance, public or private, first reads the private store for the identity from its cursor to the end. This happens before anything is reserved or sent.
PrivateContactStoreNotRead(74), nothing reserved, sealed, paid or broadcast. Calling again continues the read.Persister requirement. A private request is paid for only over a persister that declares
PRIVATE_CONTACT_REQUEST_SCAN_STATE(bit 15) together withATOMIC_CHANGESETS(bit 0).PrivateContactRecoveryUnsupported(73) before anything is sealed or paid.A build without
shieldedkeeps no shielded store, journal or restoration. It still holds an identity whose record is marked, loaded or kept through its wallet's removal (72), and still reads the private store before every contact request. It pays for no private request itself (the private send and accept take the payment as a caller's closure), never lifts a marker's hold, and holds a pair whose payment has not landed only through that marker.Identity removal.
IdentityManager::remove_identity(FFIidentity_manager_remove_identity) refuses and removes nothing:Removed and added again, the identity would start with none of those holds.
Reads for hosts.
DashPayView::private_contact_readiness(FFIplatform_wallet_get_private_contact_readiness) reads:DashPayView::private_pair_held(sender, recipient)reads one pair's hold as a send decides it.DashPayView::public_contact_request_unconfirmed(sender, recipient)reads whether a public request to the pair is unconfirmed, so a host tells it from a private request pending. Its FFI export comes with feat(swift-sdk): send, receive and accept DashPay private contact requests #5812.Costs.
shielded, an identity whose record is marked (opening the wallet in a shielded build with its store clears it).How Has This Been Tested?
cargo test -p platform-wallet --lib --features shielded,test-utils -- contact_request private lifecycle broadcast_outcome redrive masternode persister remove_identity: 443 passed.--features test-utils, withoutshielded: 352 passed.cargo test -p platform-wallet-ffi --lib --features shielded -- private error persistence shielded_send contact: 219 passed.cargo test -p dash-sdk --lib -- outcome dashpay broadcast put_document: 241 passed.cargo clippy --all-targets -- -D warnings:platform-walletwithshielded,test-utils: clean;platform-wallet-ffiwithshielded: clean;dash-sdk: clean;platform-walletwithtest-utilsonly: two dead-code errors inwallet/asset_lock, whichv5.1-devhas too.should_send_read_and_accept_a_private_contact_requestshould_recover_a_sent_private_contact_request_from_the_storeshould_record_a_private_request_the_same_way_every_timean_older_request_than_the_tracked_one_is_no_rotationtest_select_for_a_private_document_create_covers_its_fee_alone: 141,155,400 credits for one DashPay entryshould_restore_a_pending_request_paid_for_before_a_restartshould_free_a_pair_once_its_private_payment_provably_expiredshould_not_take_an_entry_starting_with_a_commitment_as_its_paymentshould_keep_a_private_payment_s_journal_record_unless_it_never_leftshould_discharge_a_journal_record_a_restoration_finds_settledshould_drop_a_read_back_notice_whose_records_the_backend_droppedshould_hold_a_record_whose_request_a_lost_flush_dropped_until_a_restart_reads_it_againshould_keep_a_journal_record_s_entry_hash_and_position_across_its_bytesshould_open_the_recovery_connection_with_full_syncsshould_keep_a_broadcast_an_endpoint_refused_maybe_deliveredshould_treat_every_transport_failure_as_maybe_deliveredshould_read_a_wait_stage_consensus_verdict_as_unconfirmedshould_read_the_sdk_s_unconfirmed_outcome_as_maybe_deliveredshould_send_a_transition_a_node_is_behind_on_to_another_nodeshould_restore_a_spend_that_exited_between_its_broadcast_and_its_resultshould_retire_the_record_of_a_spend_that_never_left_the_walletshould_not_dispatch_a_spend_whose_record_cannot_be_savedshould_report_a_shield_unconfirmed_once_a_dispatch_may_have_reached_a_nodeshould_report_a_shield_failed_only_when_nothing_was_sentshould_report_a_masternode_withdrawal_unconfirmed_once_a_dispatch_may_have_reached_a_nodeshould_report_a_masternode_withdrawal_failed_only_when_nothing_was_sentshould_hold_a_wallet_published_with_no_shielded_store_until_its_journal_is_read, which also covers the wallet registered again from the same seed by a new managershould_hold_an_identity_reloaded_marking_an_unresolved_private_paymentshould_hold_an_identity_whose_marked_record_outlived_its_wallet, run with and withoutshieldedshould_hold_a_loaded_identity_whose_record_a_persister_kept_markedshould_configure_again_without_waiting_on_a_busy_storeshould_retry_an_unfinished_restoration_before_a_contact_requestshould_report_why_a_restoration_failed_while_the_wallet_is_heldshould_refuse_removing_a_sender_holding_a_restored_paymentshould_read_an_entry_landed_since_the_last_read_before_a_public_requestshould_read_the_private_store_before_every_private_sendshould_adopt_a_reply_sealed_under_a_key_another_device_addedshould_read_past_a_full_page_from_a_node_behind_before_a_public_requestshould_not_end_a_rewound_read_at_an_older_walk_target_before_a_public_requestshould_refuse_claiming_a_public_pair_once_the_read_before_it_is_rewoundshould_carry_the_largest_proved_store_size_across_bounded_passesshould_not_end_a_public_request_s_read_below_a_size_an_earlier_call_provedshould_leave_a_wallet_created_again_alone_from_a_stale_private_send, run with and withoutshieldedshould_discharge_a_settled_record_only_once_its_request_is_durableshielded:should_hold_an_unconfirmed_public_accept_until_proof_settles_itshould_broadcast_an_unconfirmed_public_request_again_as_its_own_bytesshould_leave_a_replacement_s_unconfirmed_public_request_to_itshould_adopt_a_request_already_on_platform_instead_of_sending_anothershould_refuse_a_private_request_and_a_removal_while_a_public_one_is_unconfirmedshould_not_attribute_a_document_batch_whose_contract_nonce_is_used(SDK)should_refuse_a_private_payment_over_a_backend_that_keeps_no_markershould_make_a_private_payment_s_marker_durable_before_payingshould_report_an_unfinished_private_store_read_by_its_own_codeshould_tell_a_store_the_network_does_not_serve_yet_by_its_own_codeshould_report_an_identity_the_wallet_does_not_hold_by_its_own_codeshould_report_an_unconfirmed_public_request_by_its_own_coders-sdk-ffiandrs-dapi-clienttests at this head, and thedash-sdktests beyond the filters above;Breaking Changes
None for consensus. Host-visible:
ErrorPrivateContactRequestNeedsPrivateReply: the contact's requests came or went privately, so the public send and the public accept refuse; nothing was sent.ErrorShieldedBuildRequired: a private reply needs a build withshielded. No export in this PR returns it yet.ErrorProtocolVersionNotActive: the network is proven below the protocol version that serves the request. It lifts when the network upgrades.ErrorPrivateContactRequestPending: a private request to that identity is in flight, or paid and not read back, or a journal record holds the pair. It is pending, not failed: do not submit it again.ErrorPrivateContactPaymentsNotRestored: the wallet's private payments are not restored from the journal yet, or the identity was loaded with its marker set. Every contact request is refused until a restoration succeeds.ErrorPrivateContactRecoveryUnsupported: the persister does not declare bits 15 and 0, so no private request is paid for.ErrorPrivateContactStoreNotRead: the private store read did not finish before the request was decided; nothing was done, and calling again continues.ErrorSdkPinnedBelowProtocolVersion: the SDK is pinned below the version the request needs. Only raising or removing the pin lifts it.ErrorPublicContactRequestUnconfirmed: a public request to that identity may have been delivered and no proof settled it yet. It is unconfirmed, not failed: do not submit it again; a contact request sync settles it.dash_sdk::Error::StateTransitionResultUnconfirmedreaches the host asErrorTransactionBroadcastUnconfirmed(20), through the shielded spend contract (map_spend_result) too, rather than asErrorWalletOperation(6).PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATEis bit 15. A persister that declares it, withATOMIC_CHANGESETS(bit 0), must:ContactChangeSet::private_contact_requests_positions(read position, key fingerprint and theprivate_payments_unresolvedmarker),private_shielded_addressesandremoved_private_shielded_addresses;ClientStartState::retained_private_contact_scan_states, keyed by wallet id and owner. A registration and a load read them and hold the identities they name.ShieldedStoreimplementors:arm_private_contact_payment, which takes&[PrivatePaymentEntry]and the sender'sstore_position;private_contact_redrives_for_walletandretire_undelivered_spend;private_payment_journal_open,open_private_payment_journal,private_payment_journal,journal_private_payments,discharge_private_payment);mark_pending_by,clear_pending_reserved_byandnote_anchor_recorded;legacy_anchor_tree_bound;set_pending_spendtakesanchor_height,StalePendingSpendgains a fourth element, andPendingRedrivegainsanchor_height;PrivatePaymentRecordcarriesentry_hashandstore_position.PlatformWalletError::IdentityNotFoundnow reaches the host asErrorIdentityNotFound(7), not the catch-all (99).PlatformWalletErrorgains variants:PrivateContactRequestNeedsPrivateReply,PrivateContactRequestPending,PrivateContactStoreNotRead,PrivateContactPaymentsNotRestored,PrivateContactRecoveryUnsupported,ShieldedBroadcastMaybeDelivered. The enum is exhaustive, so a downstream match that names every variant needs an arm for each.PlatformWalletErroralso gainsPublicContactRequestUnconfirmed.TransitionOutcome::ExecutedOrSupersededis now a struct variant withconsumed_by_height(unreleased API from feat(sdk): prove a state transition's outcome, and add getRecentStateTransitionStatus #5823);Sdk::sign_contact_requestandSignedContactRequestare new.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
PR Hygiene ·
54ae054/skip-botsproceeds without the ones not yet reported/self-reviewedkotlin-sdk(packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/IdentityRegistrationController.kt,packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/RegistrationCoordinator.kt,packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.ktand 4 more) — HashEngineeringrs-platform-wallet-ffi(packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md,packages/rs-platform-wallet-ffi/src/contact_request.rs,packages/rs-platform-wallet-ffi/src/dashpay.rsand 4 more) — HashEngineering or ZocoLini or llbartekll or romchornyirs-platform-wallet(packages/rs-platform-wallet/Cargo.toml,packages/rs-platform-wallet/README.md,packages/rs-platform-wallet/src/broadcast_outcome.rsand 44 more) — HashEngineering or ZocoLini or llbartekll or romchornyirust-sdk-ffi(packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs) — lklimek or shumkovrust-sdk(packages/rs-sdk/src/mock/sdk.rs,packages/rs-sdk/src/platform/dashpay/contact_request.rs,packages/rs-sdk/src/platform/dashpay/mod.rsand 5 more) — lklimek or shumkovswift-sdk(packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift,packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift,packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swiftand 6 more) — llbartekll or romchornyiWhen every merge requirement is met, the
PR Hygienecheck passes. Reviewer limits do not block merging; other required GitHub checks and protections still apply.Summary by CodeRabbit