Repository navigation
feat(swift-sdk): send, receive and accept DashPay private contact requests - #5812
Conversation
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 14 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
|
🕓 Review not started yet because the new head is waiting for the 30-minute push debounce.
Commit f3878ca. Normal review starts when eligible; priority review starts as soon as a slot is available. |
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Phase 1 + Phase 2
Static verification at 6787f32 confirms four blocking issues involving ambiguous-spend retries, post-payment persistence failures, teardown admission, and acceptance orchestration ownership. The cryptographic delegation and ABI mapping are otherwise sound; the claimed missing Rust pin for code 68 already exists. Current CI reports passing Kotlin build/tests and no failed code checks; no local builds or tests were run.
🔴 4 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: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); 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 cross-language change directly implements cryptographic key derivation, private-document scanning and request sealing in packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs, including scan_private_document_page and seal_private_contact_request. - 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift:471-479: Keep unconfirmed private requests pending instead of enabling a fresh send
The private send can throw `shieldedSpendUnconfirmed`, but the generic catch treats it as an ordinary failure and the defer clears `isSending`. The native send has not recorded an outgoing contact request at this point, although its transition may already have executed or remain queued for byte-identical re-drive. With other spendable notes available, another tap can build a different transition and pay another fee; reserving the first transition's notes does not prevent this. The newly routed private accepts in this view and ContactRequestsView likewise restore their Accept controls after an ambiguous completion. Handle this outcome as pending, following the distinction already made by SendViewModel, and reconcile native spend recovery plus private-store ingestion before allowing a fresh submission. Add injected-unconfirmed coverage for both send and accept.
In `packages/rs-platform-wallet-ffi/src/private_contact_request.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/private_contact_request.rs:528-533: Preserve the committed accept outcome after a contact-store failure
This exported accept can report an ordinary failure after its shielded reply was confirmed. In the native private send, `pay(entry)` succeeds before `add_sent_contact_request`; a failure of that store is logged and swallowed. Because `add_sent_contact_request` persists before updating memory, the failed write leaves neither a sent request nor an established contact. `accept_contact_request_replying_with` then fails its final established-contact lookup with `ContactRequestNotFound`, which this export maps to a generic wallet-operation failure. Retrying before private-store sync recovers the reply can therefore broadcast and pay for another reply. Fix the native private-accept outcome contract so a confirmed payment followed by local persistence failure remains committed/recovery-required, with protection against a fresh reply. Test a failed contact store after successful payment and verify that retry does not invoke payment again.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift:156-162: Include private spends in teardown admission
Both new manager methods detach their native calls without entering `admitNativeOp`/`finishNativeOp`. Consequently, `deleteWallet` can pass its zero-active-operation guard while either spend is running, and shutdown does not drain these calls. The FFI retains wallet and coordinator Arcs, so removal does not cancel an operation already proving. After proof construction, `private_document_create_from_pool_with_metadata` records activity and broadcasts without a wallet-generation liveness gate; `save_activity` uses `entry(id).or_default()`, allowing removed shielded state to be recreated and persisted after the wipe. Count both Swift operations through completion, and make the native private-spend path participate in the existing generation lifecycle guard so direct native/FFI callers are also protected. Test removal and shutdown while proving is paused.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift:249-257: Move automatic acceptance routing into platform-wallet
This helper attempts public acceptance, interprets the wallet's privacy refusal, and substitutes a shielded-funded private acceptance. That is DashPay orchestration, not argument marshalling or an explicit user-selected channel. The coding conventions assign mobile DashPay orchestration to rs-platform-wallet and require bindings to call an existing Rust operation rather than make these decisions; the motivating example explicitly includes logic in a Swift view. The Rust refusal correctly prevents public disclosure, but every other client must currently reproduce the replacement-operation policy. Add a channel-aware native acceptance operation using the wallet-owned request provenance and reciprocity state, expose it through a thin binding, and have the views supply credentials and acceptance intent. The explicitly public-only API can retain its refusal contract.
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift:233-235: Expose failed or incompletely persisted private refreshes
This helper suppresses every private-sync error and discards the report. An unsupported pre-v15 network can be an expected case, but network failures, unavailable resolver material, and a nonzero `unpersistedIdentityCount` do not mean the request list was successfully refreshed. This is the example app's only private-store ingestion call, so these outcomes leave stale or empty results without indicating that private requests could not be checked or durably ingested. Handle the expected unsupported-network case separately and expose operational errors and incomplete persistence through refresh error/banner state.
In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay.rs:1300-1305: Preserve the missing-mnemonic key-availability signal
When the resolver returns NOT_FOUND during private-store scanning, `MnemonicResolverSignerError::NotFound` carries the reserved key-unavailable marker at position zero. This conversion wraps it in `InvalidIdentityData`, which has no dedicated FFI mapping and therefore becomes `ErrorUnknown` (99). The existing code-31 restoration only recognizes the marker structurally inside `Sdk(Protocol(Generic(...)))`, so it cannot restore the signal from this variant. A caller consequently cannot distinguish missing signing material from an unknown internal failure. Map NotFound explicitly to a key-unavailable outcome and add an FFI mapping test; preserve the structural discriminator rather than introducing substring matching.
In `packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`:
- [SUGGESTION] packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs:712-726: Share recipient and sender scan-key assembly
`PrivateDocumentKeyPaths::scan_keys` in platform-wallet already performs the recipient-secret and sender-outgoing-viewing-key transformation through a derivation callback. This adds another implementation in the FFI crate, with independently maintained temporary-key cleanup. The wallet still supplies the roles and paths, so this is not an incorrect key-selection finding, but the two implementations can drift as scan-key construction or erasure changes. Keep mnemonic resolution and derivation inside the resolver adapter, and extract the shared assembly into a lower Rust-library helper, such as a callback-based constructor in rs-sdk's private_document_scan module. Preserve this implementation's wipe-on-error handling when sharing it.
- [SUGGESTION] packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs:181-187: Cover the remaining documented seal-refusal inputs
The new error contract documents malformed requests and contracts without the private type, but the signer tests exercise SDK refusals only through an unrelated encryption key and another key's signature. Add malformed-request and missing-private-type cases that assert `MnemonicResolverSignerError::PrivateContactRequest` and verify that no sealed entry is returned. Validation should remain in rs-sdk; these tests would pin the adapter's forwarding and typed-error contract rather than add validation to the binding.
…t/ffi-swift-private-contact-requests
… the FFI `platform_wallet_manager_accept_contact_request_replying_in_kind(handle, wallet_id_bytes, mnemonic_resolver_handle, account, request_handle, shielded_address, signer_handle, out_established_handle)` (`shielded` feature) exposes `PlatformWallet::accept_contact_request_replying_in_kind`: the one accept a host calls. The wallet replies privately to a request that came privately with no reply yet, and with a document (or by adopting the reply already sent) otherwise, so hosts no longer catch code 68 and call the private accept themselves. It is built as `platform_wallet_manager_accept_private_contact_request` is: same arguments and null checks, `catch_spend_panic` and `map_spend_result` for the shielded spend contract, and a null-signer test. The docs of code 68 name it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`PlatformWalletManager.acceptContactRequestReplyingInKind(walletId:resolver:account:request:shieldedAddress:signer:)` is a thin wrapper over the new FFI export: the wallet decides whether the reply is private or a document. The explicitly public and private accepts stay. The file header and the code 68 docs point hosts at it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quest is replied to The request list and Add Contact's collision alert call `acceptContactRequestReplyingInKind` once instead of trying the public accept and falling back to the private one on code 68. The `acceptIncomingContactRequest` helper that held that choice is removed. 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 findings against head 1c07538. Wallet-owned acceptance routing fixes the previous layering issue, but four blocking issues remain: private operations bypass teardown admission, committed and ambiguous replies can enable duplicate fee-bearing requests, and public acceptance unnecessarily requires shielded configuration. This was a static review with no builds or tests run; current CI shows Kotlin build/tests passing and the review pending, with no Rust or iOS validation jobs listed.
🔴 4 blocking | 🟡 3 suggestion(s)
6 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: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); 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 cross-language diff directly changes cryptographic key derivation, private-document scanning, request sealing, and signature handling in packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs, including scan_private_document_page and seal_private_contact_request. - 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-ffi/src/private_contact_request.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/private_contact_request.rs:616-623: Acquire shielded resources only when acceptance needs a private reply
The new in-kind entrypoint calls resolve_wallet_and_coordinator before the wallet chooses public reply, private reply, or adoption. That helper rejects a loaded wallet unless configure_shielded has run, so public acceptance and adoption now fail with 'shielded support not configured' even though neither needs a shielded payment. Both example-app acceptance sites have switched unconditionally to this entrypoint. Resolving the seed here also imposes spend-authority access before the wallet can determine that no payment is needed; delaying derive_spend_keyset inside the wallet does not avoid these earlier failures. Keep routing in rs-platform-wallet, but make shielded-context and spend-authority acquisition lazy behind its private-payment branch. Cover public and adopt-only acceptance without a shielded coordinator.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/private_contact_request.rs:531-536: Preserve the committed accept outcome after a contact-store failure
(existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333080)
A confirmed private reply can reach this Err arm as an ordinary operation failure. DashPayView::send_private_contact_request awaits pay successfully, logs a failed add_sent_contact_request, and returns Ok. That identity update persists before inserting the established contact into memory, so the enclosing accept_contact_request_replying_with then fails its final lookup with ContactRequestNotFound. Both accept exports map this to ErrorWalletOperation, and the views offer acceptance again despite the first reply already having committed and consumed its fee. With no recorded outgoing request to adopt, another attempt can pay for another private entry. Preserve a committed-but-local-repair-pending outcome in rs-platform-wallet's acceptance orchestration and marshal it through both bindings; test a persister failure after successful payment without allowing a second paid reply.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift:158-164: Include private spends in teardown admission
(existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333083)
sendPrivateContactRequest, acceptPrivateContactRequest, and acceptContactRequestReplyingInKind capture the manager handle and suspend into Task.detached without calling admitNativeOp/finishNativeOp. shutdown() drains only activeNativeOpCount, and deleteWallet() uses that same count to exclude concurrent work, so either can overtake these operations. The native exports release the registry guard after cloning wallet/coordinator Arcs; those preserve object lifetime but do not stop a proof already in progress from broadcasting and writing activity after teardown or deletion. Enroll all three wrappers in balanced admission before their first suspension and retain it through native completion and result conversion. Add lifecycle coverage demonstrating that shutdown waits and deletion is refused while a private operation is active.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift:471-479: Keep unconfirmed private requests pending instead of enabling a fresh send
(existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333077)
When this call throws shieldedSpendUnconfirmed, the generic catch only sets errorMessage and defer clears isSending, immediately enabling Send Request again. The wallet records the outgoing contact request only after pay returns confirmed metadata, so this ambiguous attempt has no contact record that prevents another submission. Shielded reservations protect the first attempt's notes, not the recipient: another call can select other available notes and append another independently sealed request, while the original may still confirm or be re-driven by sync. Both acceptance actions similarly release their in-flight guards on this outcome. Retain the unresolved contact operation and its reconciliation status in rs-platform-wallet, expose it through the bindings, and render it as pending until resolved, including after reopening or restart.
In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay.rs:1300-1305: Preserve the missing-mnemonic key-availability signal
(existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333098)
MnemonicResolverSignerError::NotFound carries the reserved key-unavailable marker, but this catch-all wraps it in InvalidIdentityData. A missing mnemonic during private scanning therefore becomes ErrorUnknown rather than ErrorSigningKeyUnavailable (31); map_spend_result likewise cannot recover the classification from this wrapper. Keeping the marker somewhere in the rendered message is insufficient because both conversions deliberately recognize it only in the structured SDK ProtocolError::Generic payload. Preserve NotFound through the existing key-unavailable representation while keeping malformed mnemonic and derivation errors distinct. Add a resolver-provider-to-FFI boundary test asserting code 31, not only the signer's intermediate NotFound variant.
In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift:233-235: Expose failed or incompletely persisted private refreshes
(existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333092)
This helper discards every thrown error and the successful PrivateContactSyncResult. Consequently, a network, proof, or unavailable-mnemonic failure looks like a completed empty refresh, and unpersistedIdentityCount > 0 is ignored even though those requests did not reach durable storage. The wallet retains affected cursors for retry, but the request view receives no indication that recovery is needed. This is the example app's private-store receiving path. Distinguish expected unsupported-network behavior from operational failures, and expose failed or incompletely persisted passes through the refresh/error state with a retry indication.
In `packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`:
- [SUGGESTION] packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs:712-726: Share recipient and sender scan-key assembly
(existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333099)
This helper adds a second implementation of the scan-key assembly already exposed by rs-platform-wallet's PrivateDocumentKeyPaths::scan_keys and used by the resident provider: derive each recipient's keyed secrets and derive an outgoing viewing key for each sender. The implementations already handle temporary cleanup differently, and future construction changes must now be maintained separately for resident and resolver-backed scans. Extract the common assembly into a lower library accessible to both callers, such as an SDK builder accepting a derivation closure. Retain the resolver's single resolve_master scope and wiping guards, and do not introduce a dependency from rs-sdk-ffi back to platform-wallet. This is a shared-helper maintainability suggestion, not a claim that the current code selects incorrect keys.
Out-of-scope follow-up suggestions (1)
These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.
- Apply lifecycle admission to the existing shieldedTransfer wrapper — PlatformWalletManagerShieldedSync.swift's pre-existing shieldedTransfer also captures the manager handle and runs detached native work without admission. Correcting the new private-contact wrappers alone leaves this older spend able to outlive the manager's admission drain.
- Follow-up: A separate follow-up PR should enroll shieldedTransfer in balanced native-operation admission and test shutdown and deletion during an in-flight transfer.
…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>
…t/ffi-swift-private-contact-requests
… derive - `scan_private_document_page` builds its keys with `PrivateDocumentScanKeys::derive`, which wipes the derived keys on an error too. The signer keeps only the mnemonic resolve and the per-path derivation; its own scan-keys helper and `WipingSecretKeys` are gone. - `seal_private_contact_request` takes `platform_version` last and passes it to the SDK seal, which validates the payload with dpp. - Tests follow the SDK: signing data and seals take the platform version, and the read takes `ProvedIdentity` entries proved at the heights the scan needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it is one, in every build `platform_wallet_manager_accept_contact_request_replying_in_kind` no longer looks up the shielded coordinator or reads the mnemonic up front. It hands the wallet a payment that, only for a private reply, looks the coordinator up and resolves the seed on a blocking thread. A public or adopted reply therefore works on a manager without `platform_wallet_manager_configure_shielded` and reads no mnemonic for the payment (an FFI test pins both). A failure of that lookup returns the code the lookup reported. A reply for which no payment was asked keeps the codes the public accept returns; once one was asked, errors keep the shielded spend contract. The export and its JNI-facing contract exist in every build. Without the `shielded` feature it calls `DashPayView::accept_contact_request_replying_in_kind` with a payment that refuses: a request that needs a private reply fails with the new `ErrorShieldedBuildRequired` (69), and a public or adopted reply works as before. No existing code meant "shielded unsupported". Also: - `ErrorProtocolVersionNotActive` (70): the SDK's lasting `ProtocolVersionNotActive` refusal, such as the private store before protocol version 15, was `ErrorUnknown`; hosts can now stay quiet about it. A refusal that may lift soon keeps the catch-all. - A missing mnemonic while scanning or sealing the private store maps to the key-unavailable outcome (31), through the signer's position-0 marker in a `ProtocolError::Generic`, matched by variant; a test drives it through the resolver-backed provider. - The seal passes `platform_version`. - The private store sync runs on `run_on_big_stack_thread`: the SDK's identity fetch stream trips rust-lang/rust#100013 against `block_on_worker`'s `Send + 'static` bounds. Both codes are in `ERROR_CODE_REGISTRY.md`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…type codes 69 and 70 - `sendPrivateContactRequest`, `acceptPrivateContactRequest` and `acceptContactRequestReplyingInKind` are admitted with `admitNativeOp` and finished with `finishNativeOp`, so a teardown waits for them as it does for the other counted manager calls. - `PlatformWalletError.shieldedBuildRequired` (69) and `.protocolVersionNotActive` (70), with `ErrorHandlingTests` pins. - The in-kind accept's docs say a public or adopted reply needs neither shielded support nor a payment mnemonic read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and report private refresh failures - A private send or an in-kind accept whose result could not be confirmed (`shieldedSpendUnconfirmed`) stays pending, as `SendViewModel` treats an unconfirmed spend: Add Contact shows a Done-only notice and no longer enables Send, and the request row keeps its pending state without its buttons until the next sync settles it. - The request list's private refresh stays quiet when the network does not serve the private store yet (code 70), and shows any other failure, and requests read but not saved, in a notice above the list. - `ContactRequestSubmissionOutcome` and `PrivateContactRefreshNotice` hold those two readings, pinned by `ContactRequestOutcomesTests`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…t/ffi-swift-private-contact-requests
…code and read it per contact - `PlatformWalletError::PrivateContactRequestPending` maps to the new `ErrorPrivateContactRequestPending` (71), both in the blanket conversion and in the shielded spend contract the private send and accepts use, which would otherwise flatten it to `ErrorWalletOperation`. A private request to that contact is in flight or paid and not yet read back; nothing new was paid, and a later private sync settles it. Recorded in `ERROR_CODE_REGISTRY.md`. - `managed_identity_get_private_reply_pending(identity_handle, contact_id, out_pending)` is a pure read of `DashPayState::private_reply_pending`, so hosts disable a second send or accept from wallet-owned state. It is a getter rather than a `PrivateContactFFI` field because the latter answers NotFound until a request has come or gone privately, while a first private send is already pending. - The private store sync runs on `block_on_worker` again: the SDK's identity fetch future is `Send` now, so the big-stack thread is no longer needed (`block_on_worker` already polls on the 8 MB worker stack). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er contact - `PlatformWalletError.privateContactRequestPending` (71), with an `ErrorHandlingTests` pin. The private send, private accept and in-kind accept docs name it. - `ManagedIdentity.privateReplyPending(contactId:)` wraps `managed_identity_get_private_reply_pending`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The request rows and Add Contact's Send read whether the wallet has a private request to the contact in flight or not yet read back (`privateRepliesPending`, over `ManagedIdentity.privateReplyPending`), instead of keeping an unconfirmed accept in screen-local state. While it is set, the row shows pending without its buttons and Send is off; it clears when a private sync settles it, and it holds when the screen is left and reopened. The views re-read it on appear, after an accept or send, and after each sync pass. `privateContactRequestPending` (71) is the same outcome as an unconfirmed spend: pending, no fresh submission. `ContactRequestOutcomesTests` covers it and the row message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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
…t/ffi-swift-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>
… 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>
…tact-requests' into feat/ffi-swift-private-contact-requests Brings in the wallet at 207bd72: #5837 merged into it, v5.1-dev (#5556, #5823 and others), and the spend contract keeping code 20. Conflicts: - shielded_send.rs: the wallet's catch_spend_panic doc is taken, and the function stays pub(crate), as the private contact request exports call it. - PlatformWalletResult.swift: the wallet's docs for 16 and 18 (the result code and the typed error) are taken. They are newer: a note spend's verdict and a shield's are both 18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…act requests and shielded sends Since the wallet's spend contract keeps code 20 (an SDK outcome left unconfirmed after a possible acceptance), a private send or accept can return it, and a public one through the blanket conversion. So can a shielded transfer, unshield or withdrawal. Both screens showed it as an ordinary, retryable failure. - ContactRequestSubmissionOutcome maps `transactionBroadcastUnconfirmed` to mayHaveGoneThrough, as it maps 18. - SendViewModel's shielded path reads both 18 and 20 (`SendViewModel.mayHaveGoneThrough`) as "may have gone through, do not retry". - The private send and accept docs name 20. - Tests: - testShouldKeepAnUnconfirmedStateTransitionPending - testShouldNotOfferARetryForAShieldedOperationThatMayHaveGoneThrough 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
The head still contains two blocking lifecycle and recovery defects. Public unconfirmed accepts are re-enabled without a durable hold, and the optional-network persistence configuration can silently miss retained recovery records for non-testnet wallets; the supplied CI snapshot has no failed code checks, but Rust and Swift validation are not independently reported.
🔴 2 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: rust-quality); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); 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 is a large, intricate change that modifies cryptographic key handling and signing, shielded funds movement, private request scanning, persistence, and Swift/Rust FFI boundaries in functions such as seal_private_contact_request and the private send/accept exports. - 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
🤖 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift:336-340: Keep public unconfirmed accepts pending until their outcome is reconciled
This branch now treats code 20 as may-have-gone-through for both private and public replies, but only private payments create the wallet-owned pair hold that `privatePairHeld` reads. The native public path releases its `PublicContactClaim` on every return, while an SDK unconfirmed error exits before `add_sent_contact_request`, so the subsequent `reloadPendingReplies()` sees no hold. Once the task's `inFlightIds` defer runs, the row offers Accept and Ignore again even though the first public reply may still execute; another accept can therefore build and submit a second transition. Preserve an unresolved public-submission association in the owning wallet and expose it to this control state, or otherwise keep the row non-actionable until the first outcome is reconciled. Add a regression covering the actual public accept, reload, and retry sequence.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:5285-5301: Do not interpret an unbound persistence network as testnet during recovery
The raw-pointer `configure(sdkPointer:modelContainer:network:)` API and the persistence handler both allow `network` to be nil. However, `loadPrivateContactScanStateWalletIds()` converts nil to `.testnet`, while `scanStateNetworkRaw(walletId:)` also falls back to testnet when the wallet row is gone. A retained unresolved position for a purged mainnet or devnet wallet was stored under its actual network, so the wallet-list callback returns successful absence and the per-wallet callback searches the wrong namespace. Rust consequently receives no retained marker and can restore the identity without the hold that prevents another private payment. Require a real network before advertising this recovery capability, or query/preserve the stored network scope in both callbacks; do not report successful absence for an unknown scope. Add a regression using the default nil-network raw-pointer configuration with a retained non-testnet record after wallet removal.
…waits, with no last refresh A shielded scan can hold the store for minutes, and the wallet runs a restoration it deferred only once the scan frees the store. The views stopped refreshing after 20 automatic refreshes (a minute), so a restoration that completed later was never seen until the user acted. - While the payments stay unrestored and the view is shown, it refreshes every 3 s for the first minute, then every 15 s, with no limit. PrivatePaymentsRecovery.recheckDelay sets the pace. - PrivatePaymentsRecovery.waitForRecheck is the wait both views share. It ends when the readiness reads restored, before any read succeeded, or when the view's task is cancelled. - Each refresh is local: the in-memory reads, and a repeat configure that checks which wallets are still held and queues at most one deferred restoration per wallet. - Both views also refresh when a shielded sync pass ends (isSyncing turns false), since that is when a deferred restoration can run. The wallet emits no restoration-complete event to observe instead. - The registry's row 72 describes the cadence. - Tests: testShouldKeepRefreshingWhileARestorationStaysDeferred and testShouldStopRefreshingWhenRestoredOrWhenTheViewGoesAway replace testShouldRefreshAgainWhileARestorationIsDeferred. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k when the handler has none
A handler configured with no network, as configure(sdkPointer:
modelContainer:network:) makes by default, guessed testnet in two places:
the wallet list, and the per-wallet load once the wallet's row was gone.
A marked position record kept for a removed mainnet or devnet wallet is
stored under its real network, so both reads came back empty. Rust then
got no retained marker and could restore the identity without its hold.
- The wallet list and the per-wallet load filter on the handler's network
when it has one, else on the wallet row's.
- When neither is known, they read the wallet's rows under every stored
network instead of guessing one, so a kept record never reads as absent.
- A scan state write with no network known refuses, rather than filing the
state under a guessed network.
- Tests:
- testShouldHandBackAKeptMainnetRecordToAHandlerWithNoNetwork: a
nil-network handler, a mainnet wallet's marked record, and the
wallet's removal; the record comes back through both reads and their
C shims, and a testnet-scoped handler still does not see it.
- testShouldRefuseToStoreScanStateWithNoNetworkToStoreItUnder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…tact-requests' into feat/ffi-swift-private-contact-requests Brings in the wallet at 54ae054: the wallet-owned hold for an unconfirmed public contact request (code 76), the contact request cleanup fenced to the wallet generation, and a settled private payment record discharged only once durable. The one conflict was ERROR_CODE_REGISTRY.md. It keeps this branch's rows 68 to 75 and adds the wallet's row 76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts its outcome platform_wallet_get_public_contact_request_unconfirmed exposes DashPayView::public_contact_request_unconfirmed per pair, as platform_wallet_get_private_pair_held does for the private hold: it reads outside the registry guard, answers false for a free pair, NotFound for an unknown wallet and ErrorIdentityNotFound (7) for a sender the wallet does not hold. A host reads it beside the private hold, so a public send or accept that returned 20 is shown as awaiting its outcome rather than offered again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PlatformWalletResultCode.errorPublicContactRequestUnconfirmed (76) maps to PlatformWalletError.publicContactRequestUnconfirmed, apart from a private request pending (71), and ManagedPlatformWallet.publicContactRequestUnconfirmed wraps the new read. ErrorHandlingTests pins the code; the registry row names the export and the Swift types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ugh until the wallet settles it The request list and Add Contact read only the private pair hold, so after a public accept or send that returned 20 the reload found nothing held and offered Accept or Send again while the first one could still execute. They now read both of the wallet's holds per contact (contactRequestHolds): while either holds the pair, no request or accept is offered, and a public hold shows "This request may have gone through; it is being checked." A 20 or a 76 from a send or accept shows the same at once and starts a DashPay sync, never a retry. The request list's pull-to-refresh runs that sync, which settles the hold first, and Add Contact gets a "Check again" button that does the same; both read again whenever a DashPay sync pass ends. The invitation claim's contact request reads 18, 20 and 76 the same way instead of as a failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in-private-contact-requests Brings in #5812 at 7496810: the wallet at 54ae054 with its hold for a public contact request whose outcome is unconfirmed (code 76), the platform_wallet_get_public_contact_request_unconfirmed export, Swift's code 76 typing and read, and SwiftExampleApp holding such a request until the wallet settles it. The one conflict was ERROR_CODE_REGISTRY.md. It keeps this branch's rows 68 to 75, which name the Kotlin types, and takes #5812's row 76. 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 |
#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>
Code 76 (`ErrorPublicContactRequestUnconfirmed`) has its row, but the summary line above the table still named 76 as the next free integer and 68 to 75 as the private contact request block. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
Waiting for bot review — thepastaclaw not yet. Wait for the missing reviews, or a writer can post |
#5812 was squash-merged into v5.1-dev, so its changes arrive a second time. The one conflict is the result code registry: rows 68 to 76, where this branch's rows also name the Kotlin types and tests. This branch's rows are kept, and the merge changes nothing else in the tree. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Conflict in SwiftExampleApp's WalletManagerStore.swift against #5812 (private contact requests and the shielded store): - The warm-cache check keeps this branch's SDK identity (`managerSdks`, which v5.1-dev still keys by handle) and takes #5812's retry of a shielded store that failed to open. - A new manager opens its shielded store after the replaced manager's shutdown, which releases the same store path, and before its wallets are restored, as #5812 requires. - Both helpers are kept: this branch's `restoreWallets` and #5812's `configureShieldedStore`, `shieldedStoreFailure` and `configureShieldedAgain`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Basic explanation
What this does: iOS apps can now send, receive and accept DashPay private contact requests.
KeychainSignerthe app passes for public requests.Value: the private request flow from #5810, usable from iOS without the app handling any key.
Risks: low.
Issue being fixed or feature implemented
Part of anonymous DashPay contact requests for 5.1, on top of #5810.
Known limit, tracked in #4270. Platform cannot yet prove to a client whether a state transition executed, or that it can no longer execute, so the wallet infers the outcome of an unconfirmed transition and holds notes and contact pairs until it has evidence it can verify itself. The result codes for unconfirmed outcomes and held pairs (18, 20, 71, 72, 76) and what the app shows for them follow from that. The handling is interim, as #5810's description explains; #4270 tracks the gap in the proofs and #5822 what the wallet needs from it.
What was done?
rs-sdk-ffi (
MnemonicResolverCoreSigner)scan_private_document_pagesorts a page using these keys, all derived inside the signer from one resolve of the mnemonic:scan_private_document_pagesdoes the same for a batch of pages with one resolve.PrivateDocumentScanKeys::derive) and wipes them.seal_private_contact_requestderives the sender's encryption key, seals the request with the identity signer's signature, and wipes the key.resolve_and_deriveis now a one-path case of the newresolve_master.MnemonicResolverSignerError::PrivateContactRequest.platform-wallet-ffi
ResolverContactCryptoProviderimplements the wallet's three private methods over that signer: scan a page, scan a batch of pages, and seal.platform_wallet_sync_private_contact_requests(wallet_handle, core_signer_handle, max_pages, out_requests, out_report)platform_wallet_manager_send_private_contact_request(handle, wallet_id_bytes, mnemonic_resolver_handle, account, sender_identity_id, recipient_identity_id, account_label, auto_accept_proof, auto_accept_proof_len, shielded_address, signer_handle, out_request_handle)(shieldedfeature)platform_wallet_manager_accept_private_contact_request(handle, wallet_id_bytes, mnemonic_resolver_handle, account, request_handle, shielded_address, signer_handle, out_established_handle)(shieldedfeature)platform_wallet_manager_accept_contact_request_replying_in_kind(...): same arguments as the private accept, exported in every build.managed_identity_get_private_contact(identity_handle, contact_id, out_contact)platform_wallet_get_private_pair_held(wallet_handle, sender_id, recipient_id, out_held)platform_wallet_get_public_contact_request_unconfirmed(wallet_handle, sender_id, recipient_id, out_unconfirmed): whether a public request from the sender to the recipient awaits its outcome (DashPayView::public_contact_request_unconfirmed, feat(platform-wallet): send, receive and accept DashPay private contact requests #5810). It reads outside the registry guard, as the pair-hold read does.platform_wallet_get_private_payments_restore_failure(wallet_handle, identity_id, out_reason): a string the caller frees withplatform_wallet_string_free, or null.signer_handleis theSignerHandlethatplatform_wallet_send_contact_request_with_signertakes. The send and both accepts follow the shielded spend contract (catch_spend_panic,map_spend_result). That contract keeps codes 68, 71, 72, 73 and 74, and maps a removed wallet toNotFound(98).PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE(1 << 15);on_persist_private_contact_scan_state_fn;on_load_private_contact_scan_state_fn;on_load_private_contact_scan_state_free_fn;on_load_private_contact_scan_state_wallets_fn, which lists the ids of every wallet the host keeps scan state for;on_load_private_contact_scan_state_wallets_free_fn;PrivateContactRequestsPositionFFIandPrivateShieldedAddressFFI;retained_private_contact_scan_states, every marked position record no restored identity takes: those of a wallet it did not return, and those of an identity it did not restore.ErrorIdentityNotFound(7), as the readiness does.ERROR_CODE_REGISTRY.md: rows 68 to 76 name the exports and Swift cases on this branch.swift-sdk
ManagedPlatformWallet:syncPrivateContactRequests(maxPages:)returns aPrivateContactSyncResult;privateContactReadiness(identityId:)returns aPrivateContactReadiness;privatePaymentsRestoreFailure(identityId:)returns the reason, or nil;privatePairHeld(senderId:recipientId:)returns whether the pair is held;publicContactRequestUnconfirmed(senderId:recipientId:)returns whether a public request to the pair awaits its outcome.PlatformWalletManager:sendPrivateContactRequest(...),acceptContactRequestReplyingInKind(...)andacceptPrivateContactRequest(...).signer: KeychainSigner, as the public send and accept do.ManagedIdentity.privateContact(contactId:)returnsPrivateContactInfo(came privately, went privately, shielded address), or nil.PlatformWalletErrortypes codes 68 to 76:privateContactRequestNeedsPrivateReplyshieldedBuildRequiredprotocolVersionNotActiveprivateContactRequestPendingprivateContactPaymentsNotRestoredprivateContactRecoveryUnsupportedprivateContactStoreNotReadsdkPinnedBelowProtocolVersionpublicContactRequestUnconfirmedPlatformWalletPersistenceCapabilities.privateContactRequestScanStateis1 << 15. The SwiftData handler declares it with atomic changesets.loadPrivateContactScanStateWalletIds).SwiftExampleApp
acceptContactRequestReplyingInKind. Add Contact's collision accept does too, unless "Send privately" is on: then it replies privately.WalletManagerStoreopens the shielded store when it activates a manager, before any wallet is loaded or created.SendViewModel's shielded path reads 20 as it reads 18: "may have gone through, do not retry" (SendViewModel.mayHaveGoneThrough).PrivatePaymentsRecovery.refreshis the request list's and Add Contact's readiness refresh. It asks the wallet to retry before it reads the readiness it shows, and keeps re-reading on a timer while the payments stay unrestored.contactRequestHolds): the private one and the public one.Current behaviour
acceptContactRequestReplyingInKindlets the wallet pick the reply:KeychainSignerthe public send uses.platform_wallet_get_private_pair_held(privatePairHeld) reads one pair as a send decides it. It applies discharged journal records first.platform_wallet_get_public_contact_request_unconfirmed(publicContactRequestUnconfirmed) reads the hold per pair.platform_wallet_get_private_contact_readiness. ItsPrivateContactReadinessFFIhas four fields:private_payments_restoredprivate_store_readunresolved_private_paymentprivate_payments_restore_failedplatform_wallet_get_private_payments_restore_failuregives the reason behindprivate_payments_restore_failed.configureShieldedsucceeds with nothing recorded. So on every refresh of the request list or Add Contact, while the store did not open or the readiness reads not restored:configureShieldedagain; on a store already open, that is the wallet's own retry of every wallet still held, and it never waits on a busy store;ASSET_LOCK_SHIELDED_RECIPIENTS.ATOMIC_CHANGESETS(bit 0). A host without both gets 73 for a private send or accept, and reads the store from its start after a restart.configure(sdkPointer:modelContainer:network:)) reads it under every stored network, rather than reading it as absent under a guessed testnet. With no network known, a scan state write refuses rather than filing the state under a guessed network.NotFound(98).IdentityManager.removeIdentitydocuments it.What SwiftExampleApp does for each code (
ContactRequestOutcomesTestspins these):publicContactRequestUnconfirmedsays settledshieldedprivatePairHeldsays freepublicContactRequestUnconfirmedsays settledHow Has This Been Tested?
cargo clippy --all-targets -- -D warningsis clean on:-p rs-sdk-ffi;-p platform-wallet-ffi, with and without--features shielded.cargo test -p platform-wallet-ffi --lib -- private readiness persistence:: manager:: error:: shielded_send::: 213 passed; with--features shielded: 249 passed.should_hand_back_the_marked_records_no_restored_identity_takesandshould_hold_an_identity_a_kept_marked_record_names_when_its_wallet_is_registered_again.map_spend_result../build_ios.sh --target sim(release): BUILD SUCCEEDED.ErrorHandlingTests: 71, including codes 68 to 76 (testShouldReportAnUnconfirmedPublicRequestByItsOwnCode) and every readiness field.PrivateContactScanStatePersistenceTests: 10, including:testShouldHandBackAKeptMainnetRecordToAHandlerWithNoNetwork: a nil-network handler and a mainnet wallet's kept record after the wallet's removal;testShouldRefuseToStoreScanStateWithNoNetworkToStoreItUnder.SDKUnconfirmedErrorTests: 4.TopUpUnconfirmedErrorTests: 1.ContactRequestOutcomesTestspassed 32 of 32. They cover one or more per code above (20 bytestShouldKeepAnUnconfirmedStateTransitionPendingand, for the shielded send,testShouldNotOfferARetryForAShieldedOperationThatMayHaveGoneThrough), plus the refresh recovery:testShouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefreshtestShouldShowTheReadinessReadAfterTheRetrytestShouldNotAskTheWalletToRetryWhenThePaymentsAreRestoredtestShouldOpenTheStoreAgainWhileAStoreFailureIsRecordedtestShouldKeepRefreshingWhileARestorationStaysDeferred(past the first minute, 200 refreshes)testShouldStopRefreshingWhenRestoredOrWhenTheViewGoesAwaytestShouldHoldAPublicAcceptThatMayHaveGoneThroughThroughAReloadAndARetryUntilTheWalletSettlesIt: the accept returns 20; a reload whose sync did not settle it reads an empty private hold and the public hold, and keeps the row held; a second accept is not offered, and one made anyway is refused with 76 and shows no error to retry; a failed read keeps it held; once the read says settled, Accept comes back.testShouldShowARefusedUnconfirmedPublicRequestAsBeingChecked(76).testShouldKeepAnUnconfirmedSubmissionAReadDidNotCover.testShouldReadBothHoldsAndTellAnUnmanagedIdentityFromAFailedRead: the read covers both holds for every contact; 7 reads as nothing held, any other failure as unread.scripts/check-storage-explorer.sh: all 38 model types covered, on the previous head. The last merge changed no SwiftData model.InvitationPersistenceTestson this head (it passed on the previous one, and the last merge did not touch it);rs-sdk-ffi's signer tests on this head;Breaking Changes
For a host built on #5810:
PlatformWalletErrorandPlatformWalletResultCodegain cases for codes 68 to 76. An exhaustiveswitchoverPlatformWalletErrormust handle them.privateContactRequestScanState(bit 15) with atomic changesets. The wallet then holds every wallet loaded or created while no shielded store is configured (72). A host using the handler must callconfigureShieldedwhen it activates the manager, before loading or creating wallets, and call it again after a failure.PersistenceCallbacksExtensiongrows by five trailing slots after feat(platform-wallet): persist the Orchard recipient of a shielded asset-lock funding #5762's recipient slots, under the same version, gated bystruct_size. A host passing the older, smaller struct gets the scan state slots as absent. A host compiled against the new header must rebuild.PRIVATE_CONTACT_REQUEST_SCAN_STATEis attested only with all five. A host declaring it must keep a position record marked unresolved through its wallet's removal, and list that wallet fromon_load_private_contact_scan_state_wallets_fn. The SwiftData handler does both.publicContactRequestUnconfirmedas well asprivatePairHeld, and runs the DashPay sync to settle it.ErrorIdentityNotFound(7), from feat(platform-wallet): send, receive and accept DashPay private contact requests #5810, notErrorUnknown(99).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 ·
f3878ca/skip-botsproceeds without the ones not yet reported/self-reviewedrs-platform-wallet-ffi(packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md,packages/rs-platform-wallet-ffi/src/contact_persistence.rs,packages/rs-platform-wallet-ffi/src/contact_request.rsand 8 more) — HashEngineering or ZocoLini or llbartekll or romchornyiswift-sdk(packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift,packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift,packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swiftand 22 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.