Repository navigation
feat(kotlin-sdk): send, receive and accept DashPay private contact requests - #5813
Conversation
|
Caution Review failedThe pull request is closed.
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 ec344df. 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
Verified the findings against head 655d1e3. The acceptance fallback introduces wallet orchestration in the Kotlin example app contrary to the repository's ownership rules; native-handle cleanup also lacks nonempty regression coverage. This was a static review with no local builds or tests; current CI reports the Kotlin SDK build and emulator tests passing, with only the automated review pending.
🔴 1 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); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
normalbygpt-6.1-sol(effort low) — The change spans JNI, Kotlin wrappers, and example-app flows, but delegates private sends, signing, and acceptance to existing platform-wallet implementations rather than changing intricate funds-movement or cryptographic logic. - 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) - Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort high); 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/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt`:
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt:73-76: Move public-to-private acceptance orchestration into platform-wallet
This catch re-reads the incoming request and automatically switches the Accept action from a refused public operation to a shielded-funded private operation. Rust enforces the privacy refusal, but the host still owns the subsequent workflow. The book's "Where code goes" section explicitly assigns mobile DashPay orchestration to rs-platform-wallet, and "FFI and mobile SDKs hold no business logic" requires hosts to call an existing Rust operation rather than make wallet decisions. Copying the Swift example's fallback adds another host-owned implementation; that existing implementation does not exempt the new code from the rule. Add a Rust acceptance entry point with the required spending context, resolve the incoming request and select the public/private route there, then expose it through FFI/JNI so this helper makes one call. Keep the explicitly public API's refusal. Test the unified workflow offline at its Rust owner, including public success, private acceptance, unrelated-error propagation, missing requests, and already-reciprocated requests; the current error-mapping and JNI-binding tests do not exercise this routing.
In `packages/rs-unified-sdk-jni/src/dashpay.rs`:
- [SUGGESTION] packages/rs-unified-sdk-jni/src/dashpay.rs:1520-1525: Exercise cleanup of populated request-handle arrays
The only cleanup test supplies ContactRequestHandleArray::empty(), so neither the per-handle destruction loop nor the allocated-buffer release executes. The FFI array-free function deliberately frees only the buffer: individual requests remain in CONTACT_REQUEST_STORAGE until contact_request_destroy removes them. Removing the destruction loop would therefore leak requests on every nonempty private sync while the new tests still passed. Add a JVM-independent test that creates at least two real handles through contact_request_create, puts them in a boxed-slice allocation matching the FFI array contract, and invokes release_contact_request_handles. Assert the returned count, null pointer and zero count afterward, and that getters reject both released handles. Use real registered handles rather than fabricated values so the test verifies destruction as well as buffer cleanup.
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
The two prior findings are fixed: populated request-array cleanup is covered, and public/private reply selection now belongs to platform-wallet. One blocking regression remains: both example-app acceptance flows unconditionally invoke a shielded-only JNI symbol, breaking public acceptance on the documented transparent-only build. This was a static verification at 13f4784; current CI reports the Kotlin build/emulator job passing with shielded enabled, policy checks passing, and the review check pending.
🔴 1 blocking
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); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
normalbygpt-6.1-sol(effort low) — The cross-language change is substantial, but it adds JNI/Kotlin adapters to existing private-contact operations rather than changing funds-movement, cryptographic, signature, or consensus logic itself. - 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) - Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort high); 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/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt`:
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt:66-67: Preserve public acceptance on transparent-only builds
Requests and Add Contact now route every acceptance through this call, including ordinary public requests. However, `DashpayNative.acceptContactRequestReplyingInKind` has its JNI export behind `#[cfg(feature = "shielded")]` in `rs-unified-sdk-jni/src/dashpay.rs:1497`, and the underlying FFI export is similarly gated. The Kotlin README explicitly supports `./build_android.sh --no-shielded`, which disables that JNI feature. On that build, accepting a public request previously used the available public bridge but now throws `UnsatisfiedLinkError`; neither `mapNativeErrors` nor the screens' `catch (e: Exception)` handlers catches it. Make unified acceptance available without shielded support, retaining route selection in platform-wallet and returning a typed unsupported error only when a private reply requires the missing capability. Add public-acceptance coverage against the transparent-only native library: the passing emulator job builds with shielded enabled, while a Rust no-default-features compile cannot detect this missing-symbol failure at runtime.
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
Verified the findings against head 89d2330. All three prior findings are fixed, but one blocking issue remains: screen-local pending state allows an uncertain private acceptance to initiate another paid reply after navigation or through another screen. This was a static review; no local builds or tests were run, and the current Kotlin emulator build/test check passes.
🔴 1 blocking
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); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
normalbygpt-6.1-sol(effort low) — The cross-language feature is substantial but primarily marshals existing private-contact operations and handles application outcomes, without changing the underlying funds movement, cryptography, or signing algorithms. - 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) - Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort high); 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/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt`:
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt:95-98: Retain unresolved private acceptances in wallet-owned state
After `ShieldedSpendUnconfirmed`, this `remember` set prevents another acceptance only while this composition survives. Leaving and reopening Requests clears it, and Add Contact's separate `unconfirmedNotice` is not shared with Requests. The incoming-only row therefore offers Accept again before the first submission is reconciled.
The Rust path does not prevent the second payment: `send_private_contact_request` returns at `pay(entry).await?` before recording an outgoing request, while `needs_private_reply` and `accept_contact_request_replying_with` recognize only recorded outgoing or established requests. `reserve_notes_covering` excludes the original spend's reserved notes but allows another spend from other available notes. A second acceptance can consequently seal a different entry and pay another fee while the first transition can still land. `kickDashPaySync` runs the ordinary DashPay sweep, which does not read the private request store; the native shielded-sync poke likewise does not establish the missing contact-level pending state.
Retain the unresolved reply in `rs-platform-wallet`, keyed by wallet and identity pair and associated with its pending spend. Subsequent accepts must return the pending outcome rather than build another reply until reconciliation establishes whether replacement is safe. Expose that Rust-owned status to both screens instead of relying on their lifetimes. Add owner-level coverage where an ambiguous first payment is followed by another acceptance before private-store reconciliation, including reconstruction/re-entry, and assert that the payment callback runs only once. This PR introduces private acceptance into both screens and adds this local guard, so the gap is in scope; wallet-level retry decisions also belong in Rust under the book's mobile-orchestration ownership rule.
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
Verified head 2890ed8: wallet-owned acceptance routing, transparent-only exports, and populated-handle cleanup coverage address three prior findings. One blocking issue remains: process restart loses the contact-level reservation for an unresolved private reply, allowing another fee-paying acceptance before reconciliation. This was a static review with no local builds or tests; the current Kotlin SDK build and emulator check passed.
🔴 1 blocking
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
Review provenance
Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: 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); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
normalbygpt-6.1-sol(effort low) — The substantial cross-language change adds JNI and Kotlin orchestration for existing wallet operations rather than changing funds movement algorithms, cryptography, signing rules, or consensus itself. - 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) - Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort high); 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/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt`:
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt:165-169: Retain unresolved private acceptances in wallet-owned state
(existing thread: https://github.com/dashpay/platform/pull/5813#discussion_r4507144191)
The fresh wallet snapshot fixes screen re-entry, but it reports no pending reply after process restart. `DashPayState.pending_private_replies` is memory-only, and `Dashpay.privateRepliesPending` explicitly documents the reset at `Dashpay.kt:400–407`. Restoring the persisted incoming request does not restore this reservation, so Requests offers Accept again while Rust's `needs_private_reply` still sees no outgoing counterpart.
This can duplicate fees even when the first payment was confirmed: `send_reserved_private_contact_request` completes `pay(entry).await?`, fails to read the entry back from the private store, and returns `PrivateContactRequestPending` without recording an outgoing request. If the process then restarts before successful private-store reconciliation, another acceptance reserves the now-empty recipient slot, seals a fresh entry, and pays again. The ambiguous `ShieldedSpendUnconfirmed` path has the same gap while the original signed spend remains recoverable. Shielded note reservations protect only the selected notes; `reserve_notes_covering` can fund the second submission from other available notes. The private store is an append-only log, not contact-level deduplication.
The #5810 restoration work must be consumed here before this newly exposed acceptance flow is safe across process death. Persist the wallet/identity/recipient reservation and its entry/spend association before broadcast, restore it before pending reads and submissions become available, and retain it until authoritative reconciliation establishes settlement or safe replacement. Keep Kotlin as a consumer of that Rust-owned status. Add owner-level restart coverage for both an ambiguous payment and a successful payment followed by failed read-back, asserting that another acceptance does not invoke payment again.
…with the private type A client whose context provider holds a contract from before its private type was declared, as a wallet hydrated across the protocol-15 upgrade holds DashPay v2, could not verify a page of the type's store: `PrivateDocumentEntries::FromProof` resolves the contract from the provider and refuses one without the type. Only the DashPay reader refreshed the contract first. `Sdk::fetch_private_document_contract(contract_id, document_type_name)` now does that for any private type: it uses a held contract that declares the type and never replaces it, otherwise fetches the contract, refuses one without the type, gives it to the provider, reads it back and fails when the provider still holds none with the type. `fetch_private_contact_request_contract` calls it after its protocol gate, and `Sdk::scan_private_document_page` resolves the contract through it before fetching the page. The test context provider moves next to it to be shared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reader of a private document type's store fetched one page, one chunk, at a time. `Sdk::private_document_pages(contract_id, document_type_name, start_position, max_concurrent)` now yields the proved pages from a cursor to the store's end, as the first page reports it, in store order. A page reads to the end of the chunk holding its start, and the client is not told the chunk size. So the first page and the next one, from the chunk boundary the first ends at, are read one at a time. That second page, short of the store's end, is a whole chunk, a power of two, which sets every later page's start. Those are then fetched with up to `max_concurrent` in flight (`MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES` is 16, as the shielded note sync uses) and yielded in order. A later page that does not hold its whole chunk is an error, not a gap. A store with two chunks or fewer past the cursor is read one page at a time. Drive is unchanged. The stream resolves the contract through `Sdk::fetch_private_document_contract` first, and ends with a failed fetch's error after the pages before it. 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>
…ges at once The private sync fetched one page of DashPay's store, scanned it, ingested it and only then fetched the next; and every scan derived the scan keys again, a mnemonic resolve each in the FFI signer. `sync_private_contact_requests` now reads `Sdk::private_document_pages`, with up to `MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES` (16) fetches in flight while earlier pages are processed, and scans the pages ready at once in batches of up to 16. Each page is still processed in order under the existing rules: identity fetches, ingest, and a cursor advance only once the ingest reached disk. Pages before a failed fetch are processed, then the error ends the pass; `max_pages` is still the pages one pass reads. `ContactCryptoProvider::scan_private_document_pages` scans a batch. Its default calls `scan_private_document_page` once a page, so other providers keep working; the test provider derives the keys once a batch. With pages cheap, a send's read-back after paying reads one window of 16 pages, and the read before an identity's first private send reads up to 256 pages a sync, at most 4 syncs. `futures` becomes an unconditional dependency, as the sync is not behind the `shielded` feature. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… across restarts Each identity's position in DashPay's private contact request store, and each private contact's shielded address, lived in memory only, so every launch read the whole store again before the first private send. Both now go through the host persister, for a backend attesting the new `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 14): - `ContactChangeSet` carries `private_contact_requests_positions` (owner to position, last write wins), `private_shielded_addresses` ((owner, contact) to the 43-byte address) and its tombstone set `removed_private_shielded_addresses`. - A sync stores a page's addresses after the page's requests, and the identity's new position only after both reached disk. A failed address store holds the cursor back like a failed ingest; a failed position store leaves the cursor advanced in memory, so the next launch reads from the last position stored and ingest repeats harmlessly. - Un-ignoring a sender stores the rewound position. - A loader restores them with `ManagedIdentity::apply_private_contact_requests_position` and `apply_private_shielded_address`; the changeset replay applies them too. A backend without the bit is sent none of it, and a load drops any position or address it restored, so it keeps today's behaviour: the store is read from its start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…private one reserves it The public send's privacy check was a snapshot. A public send could pass it, suspend during the recipient lookup, xpub derivation or signing, and then publish the pair after a private send reserved the recipient and paid. A public send now claims the pair (sender, recipient) in the same wallet lock as its channel check, and holds the claim through submission and the local record; a guard releases it when the send ends, dropped or not. A private reservation and a public claim exclude each other: a private send while the pair is claimed gets `PrivateContactRequestPending`, and a public send while it is reserved gets the existing refusals. Public sends still run concurrently with each other (claims count). The accept paths send through these, so they take part too. No lock is held across network work. 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 e050ab0. All four prior findings are fixed, but the new pending-state presentation helper still converts failed reads into an apparent absence of pending requests; a separate KDoc placement nitpick also remains. This was static verification only: the current Kotlin SDK build and emulator tests pass, and the review check is pending.
🔴 1 blocking | 💬 1 nitpick(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); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
normalbygpt-6.1-sol(effort low) — The cross-language implementation is substantial, but its JNI exports, Kotlin wrappers and UI state handling delegate signing and private transfers to existing wallet functionality rather than changing funds movement, cryptography or key-handling logic themselves. - 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) - Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort high); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort high); 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/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt`:
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt:56-57: Preserve unavailable pending state instead of reporting no pending requests
This catch makes an operational read failure indistinguishable from a successful snapshot containing no pending replies. The SDK already returns an empty result for an unmanaged identity while propagating other failures, and accessing a closed ManagedPlatformWallet throws. If a pending-state refresh fails, ContactRequestsScreen replaces walletPendingIds with this empty set, removing the pending notice and restoring Accept/Ignore; AddContactScreen similarly sets recipientPending to false and enables Send. No successful reconciliation established that the pending submission cleared. Rust's reservation and recovery guards prevent another private payment, but they do not make this report of wallet state correct. Preserve a distinct unavailable/error result, retain the last successful pending snapshot, and keep pending-sensitive controls unavailable until a successful read. Add coverage for a successful pending snapshot followed by a failed read, separately from a successful empty snapshot.
In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt`:
- [NITPICK] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt:331-338: Attach the in-kind acceptance KDoc to its declaration
This acceptance documentation is immediately followed by another KDoc block and managedIdentityPrivateContact, while acceptContactRequestReplyingInKind appears below both managed-identity reads without its documentation. Move this block directly above the acceptance declaration's @Suppress annotation so its requestHandle/account links and reply-routing explanation document the intended API.
… scanning keys change
Scan paths come from the cached managed identity, but the read position
was not tied to them. An identity that gained a DashPay encryption or
decryption key on another device was scanned with its older keys here,
the entries sealed to or from the new key never opened, and the position
moved past them; a later refresh folded the key in without rewinding the
position or clearing the read-to-the-end flag.
The position and the flag now hold for a fingerprint of the scanning keys:
SHA-256 over the HD index and each ECDSA_SECP256K1 ENCRYPTION or
DECRYPTION key (id, purpose and data). Before every pass and every read a
private send or accept runs, `reconcile_private_scan_keys` rewinds both to
0 when the keys no longer match, and stores the rewind. A pass records the
fingerprint it scans with, and advances or sets the flag only while that
is still the identity's and the one its position holds for, so a pass
with older keys commits nothing over an update made under it.
The stored position becomes a `PrivateContactRequestsPosition { position,
key_fingerprint }`, and a loaded one with another fingerprint is read
again from 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… may reach the network The send's cancellation guard was disarmed right before `pay` was called, but the in-kind accept's `pay` then awaits the host's payment resolver and the spend-key derivation, and the shielded payment awaits the restore check and the proof, all before anything is broadcast. Dropping the send in any of those left a reservation nothing would settle, blocking private and public sends to the contact until restart. `pay` now gets a `PaymentStarted` token (exported) that it marks right before anything it does may reach the network. The guard releases the reservation on drop until the token is marked, and keeps it after; the send also marks it once `pay` returns. The shielded payment marks it right before the type-29 transition is broadcast, after the payment source, the spend key, the restore check and the proof. The pay closure of the `DashPayView` private sends and accepts takes `(entry, PaymentStarted)`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is rejected for good A private send whose payment was unconfirmed kept its reservation. When the shielded coordinator later settled that spend as never executing (a definitive consensus rejection on redrive, or its anchor pruned), it freed the notes and marked the activity Failed, but the reservation stayed until restart, blocking every private and public request to the contact. On both outcomes the coordinator now notes the commitments of the type-29 entries the saved transition carried on the wallet's `WalletPersister`, which the wallet and the coordinator share. The wallet releases the reservations sealed as one of them, whichever attempt holds them, before a private request is reserved (so the in-kind and private accepts too), before a public request claims its pair, and before a private payment. A reservation is released only on that word, never for a missing redrive: a request paid and not yet read back has none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re broadcasting it A type-29 private document payment armed its redrive record only after the broadcast and the result wait returned unconfirmed, and the note reservation it rides on is memory-only. A process that exited once the node admitted the transition, before the wait returned, left nothing for the restart restore: another funded request to the pair could follow while the first could still execute. `private_document_create_from_pool_with_metadata` now saves the record durably right before the broadcast, as identity-funded shields arm theirs (`arm_redrive_before_broadcast`), and refuses to broadcast when it cannot be saved. Both outcomes resolve it as before: proven execution marks the notes spent and drops it, a definite failure cancels the notes' reservation and drops it, and an unconfirmed one keeps it for the redrive. The payment is marked started before the record is saved, as a saved record will be redriven. Other note spends keep arming their record on the unconfirmed outcome only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pair is taken The accept read whether a reply was already sent before the private reservation was taken. Accept B could read none, resume after accept A recorded its reply and settled its reservation, then reserve and pay a new rotation instead of adopting A's reply. The reply now checks for a tracked request to the sender in the same lock that takes the private reservation, after the store read since load, and in the same lock as the public claim for a public reply. When one is there, nothing is reserved, sealed or paid: the send reports `ReplyOutcome::AlreadyReplied`, and `accept_contact_request_replying_with` adopts it as it adopts a reply found up front, establishing the pair as it stands by then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not active `fetch_private_document_contract` resolved the contract before any protocol check, so a reader on a network below protocol version 15 got a generic "declares no private type" error from the contract the provider or the network gives, not the refusal a page fetch gives. It now runs the page fetch's protocol gate first, and refuses with `Error::ProtocolVersionNotActive` when the contract declares no such private type while the SDK runs below 15, so a reader can tell that there is no store to read yet from a failure. The page stream and `scan_private_document_page` inherit it. 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>
… public send or in-kind accept A private reply paid for before a restart left nothing a public send or the accept a host calls would see until a manual sync or a host restore: - Before a public send, a public accept, and before the in-kind accept chooses its reply, DashPay's private store is read to its end when it was not since load, as the private send already does. A network below protocol version 15 (a verified version, or a pinned SDK) serves no private documents, so its refusal reads as nothing to read and the public send goes on; a refusal that may lift soon still refuses. - A type 29 payment's record is saved with the request it pays for (sender, recipient, account reference) in a new nullable `private_contact_payment` column of the file store's pending spends. `PaymentStarted` names the request's account reference for that. Binding the wallet to its shielded coordinator restores those pairs' reservations directly, with no host call and no keys (`PlatformWallet::restore_private_contact_payments`), skipping any whose request or a later one is already recorded. - The host restore call stays for records saved before the column and for identities added after the bind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ument send below 15 Below protocol version 15 the limits admit no entries, so the synchronous builder refused every entry as one too many. It cannot ask the network, so it now refuses first, naming the SDK's protocol version and the refresh (`Sdk::refresh_protocol_version`) that lifts the refusal for an unpinned SDK. The async send already learns the network's version before building. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `tokens` setter dropped a nonempty map on a contract of structure version 0, which holds no tokens. It now refuses it, as the constructor does; an empty map still leaves such a contract as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eps every output `combined_security_level_requirement` is a shipped generation edited in place: the comment at the edited line records that `document_signing_levels` returns what the replaced code did for every requirement, which its tests and the batch tests pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vels leave to others - `PrivateContactRequest::from_properties` checks the fixed-size fields only; the schema's other bounds (label length, proof size) are checked by dpp's document validation when a request is sealed and read. The doc no longer promises every size check. - `private_contact_request_signing_security_levels` gives `[MASTER]` for a `privateContactRequest` type that is not private and requires MASTER, in a contract parsed without full validation; a private type cannot declare a requirement, so it has HIGH. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the key comparison The mismatched-key case named the recipient's AUTHENTICATION key, which the purpose check refuses first. A case now names an enabled ECDSA ENCRYPTION key, which sealing could encrypt to, holding another point than the one the entry opened with, and asserts the comparison's refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rowing store - Pages fetched at once that complete in reverse order are yielded in store order. - A later page's failure completing before an earlier page's success is yielded after it, and ends the stream. - A store of 30 entries in chunks of 4 that grows to 35 during the walk is read through the last chunk the first page's count reaches (position 31), each position once; a walk from the cursor left reads the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efuse another store's `PrivateDocumentScan` now carries the store it was read from (`contract_id`, `document_type_name`), set by `scan_page` from its target, with `is_of_store`. `private_contact_requests_from_scan` refuses a scan of any other store before reading it, even one holding no entry the wallet's keys open: its cursor is that store's, and a wallet persisting it as DashPay's would skip unread requests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…in-private-contact-requests Brings in the FFI branch's open-ended readiness refresh (c092a62). The one conflict was ERROR_CODE_REGISTRY.md, in row 72. It takes the FFI branch's row, with its refresh cadence, and re-applies this branch's Kotlin names to rows 68 to 75. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… waits, with no last refresh Port of the SwiftExampleApp fix. - Requests and Add Contact stopped refreshing after 20 automatic refreshes (a minute). A restoration the wallet deferred behind a scan that held the store for longer was never seen. - While the payments stay unrestored and the screen is shown, each now refreshes every 3 s for the first minute, then every 15 s, with no limit. PrivatePaymentsRecovery.recheckDelayMs sets the pace, and PrivatePaymentsRecovery.waitForRecheck is the wait both share. - The wait ends when the readiness reads restored, before any read succeeded, or when the screen leaves the composition. - Both screens also refresh when a shielded sync pass ends. - Tests: shouldKeepRefreshingWhileARestorationStaysDeferred and shouldStopRefreshingWhenRestoredOrWhenTheScreenGoesAway replace shouldRefreshAgainWhileARestorationIsDeferred. Also, in rs-unified-sdk-jni's wallet_manager.rs, nativeCreate and nativeEffectivePersistenceCapabilityBits now build the bridge's persistence through one helper (bridge_persistence): the declaration, the vtable, the account-writer restriction and the extension. The capability check therefore cannot drift from what a manager is created with. 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>
…in-private-contact-requests Brings in the Swift scan state scope fix (80e945f). No conflicts. 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>
DashpayNative.publicContactRequestUnconfirmed bridges platform_wallet_get_public_contact_request_unconfirmed, and Dashpay.publicContactRequestsUnconfirmed reads it per contact, empty for an identity the wallet does not manage, as privateRepliesPending does. Native code 76 maps to DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed, apart from a private request pending (71); DashSdkErrorTest pins it, FfiSmokeTest binds the new symbol, and registry row 76 names the Kotlin types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ough until the wallet settles it Requests 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 Requests 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. As SwiftExampleApp does. The parity manifest adds the read to shared_symbols and the capability's shared APIs, describes the public hold for both hosts, lists its tests as verification, and says the readiness timer runs for as long as the screen is shown, which the round before left as a bounded timer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#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>
…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>
|
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>
|
Waiting for bot review — thepastaclaw not yet. Wait for the missing reviews, or a writer can post |
Basic explanation
What this does: Android apps can now sync, send and accept DashPay private contact requests through the Kotlin SDK, over the same platform-wallet FFI the Swift SDK uses.
Value: parity with iOS for private contact requests.
Risks: low.
Issue being fixed or feature implemented
Part of anonymous DashPay contact requests for 5.1, on top of #5812.
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?
JNI (
rs-unified-sdk-jni/src/dashpay.rs)syncPrivateContactRequests(walletHandle, coreSignerHandle, maxPages)returns the pass's six counters as along[]. It destroys every returned request handle, since Kotlin reads the requests from Room.sendPrivateContactRequest(...)andacceptPrivateContactRequest(...):shieldedfeature;SignerHandlethe publicsendContactRequesttakes;acceptContactRequestReplyingInKind(...)is exported in every build.managedIdentityPrivateContact(identityHandle, contactId)returns how a contact's requests came, as JSON, or null.privatePairHeld(walletHandle, senderId, recipientId)bridgesplatform_wallet_get_private_pair_held.publicContactRequestUnconfirmed(walletHandle, senderId, recipientId)bridgesplatform_wallet_get_public_contact_request_unconfirmed(feat(swift-sdk): send, receive and accept DashPay private contact requests #5812).privateContactReadiness(walletHandle, identityId)returns four flags, in this order:privatePaymentsRestoredprivateStoreReadunresolvedPrivatePaymentprivatePaymentsRestoreFailedprivatePaymentsRestoreFailure(walletHandle, identityId)returns the reason, or null.rs-unified-sdk-jni/src/persistence.rswires the five scan state slots, only for a bridge that overrides the store, the load, the wallet list (onLoadPrivateContactScanStateWallets,()[B, the 32-byte ids packed back to back) and the contact upsert.nativeCreateandnativeEffectivePersistenceCapabilityBitsbuild the bridge's persistence through one helper (bridge_persistence): the declaration, the vtable, the account-writer restriction and the extension. The capability check therefore cannot drift from what a manager is created with.Kotlin SDK
Dashpay:syncPrivateContactRequests(coreSignerHandle, maxPages)returns aPrivateContactSyncResult.privateContact(ourIdentityId, contactId)returns how a contact's requests came.privateRepliesPending(ourIdentityId, contactIds)returns the contacts whose pair the wallet holds, one pair-hold read per contact, as a send decides it.publicContactRequestsUnconfirmed(ourIdentityId, contactIds)returns the contacts a public request to which awaits its outcome, one read per contact.privateContactReadiness(ourIdentityId)returns aPrivateContactReadinesswithprivatePaymentsRestoreFailed.privatePaymentsRestoreFailure(ourIdentityId)returns the reason, or null.incomingContactRequest(ourIdentityId, senderId)(Swift'sgetIncomingContactRequest(senderId:)) gives an accept its request.PlatformWalletManager:sendPrivateContactRequest(...),acceptContactRequestReplyingInKind(...)andacceptPrivateContactRequest(...)sit with the other shielded spends.DashSdkError.PlatformWallettypes codes 68 to 76:PrivateContactRequestNeedsPrivateReplyShieldedBuildRequiredProtocolVersionNotActivePrivateContactRequestPendingPrivateContactPaymentsNotRestoredPrivateContactRecoveryUnsupportedPrivateContactStoreNotRead, retryableSdkPinnedBelowProtocolVersionPublicContactRequestUnconfirmedNativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATEis1L shl 15. Bit 14 isASSET_LOCK_SHIELDED_RECIPIENTS, which Android does not declare.MIGRATION_15_16, two tables, a new DAO).onLoadPrivateContactScanStateWalletslists every wallet with stored positions (walletIdsWithPositions).KotlinExampleApp
DashPaySyncHelpers.acceptIncomingContactRequestaccepts withacceptContactRequestReplyingInKind. Requests and Add Contact's collision accept use it.AppContaineropens the shielded store beforeloadPersistedWallets.SendTransactionScreen's shielded path reads 20 as it reads 18: "may have gone through, do not retry" (shieldedOperationMayHaveGoneThrough).contactRequestHolds): the private one and the public one.Parity
docs/sdk/sdk-parity-manifest.jsonhasdashpay.private_contact_requestsanddashpay.private_contact_request_scan_state, with the FFI symbols inshared_symbols, includingplatform_wallet_get_private_pair_held,platform_wallet_get_public_contact_request_unconfirmedandplatform_wallet_get_private_payments_restore_failure. The public hold's tests are listed as its verification.partial. A send or accept needs a protocol version 15 network with shielded funds, and no gate is recorded yet.PARITY_SUMMARY.mdis regenerated withscripts/check_sdk_parity_manifest.py --write-summary.Current behaviour
The wallet rules are the same as on iOS (#5812, #5810).
Dashpay.privateRepliesPendingreads this per pair, applying discharged journal records first.Dashpay.publicContactRequestsUnconfirmedreads the hold per pair.AppContaineropens the shielded store before persisted wallets load.AppContainer.shieldedStoreFailureholds why, and the screens show it.configureShieldedsucceeds with nothing recorded. So on every refresh of Requests or Add Contact, while the store did not open or the readiness reads not restored:AppContainer.configureShieldedStoreAgain; 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;isBoundchange.PrivateRepliesPendingSnapshotkeeps the last good snapshot and leaves the controls off until a read succeeds.What KotlinExampleApp does for each code (
ContactRequestOutcomesTestpins these, asContactRequestOutcomesTests.swiftdoes):publicContactRequestsUnconfirmedno longer lists itshieldedpublicContactRequestsUnconfirmedno longer lists itHow Has This Been Tested?
cargo fmt --all -- --check: clean.cargo clippy -p rs-unified-sdk-jni --all-targets -- -D warnings, with default features and with--no-default-features: clean.cargo test -p rs-unified-sdk-jni --lib: 63 passed. These include::sdk:kspDebugKotlin,:sdk:compileDebugKotlin,:sdk:compileDebugAndroidTestKotlinand:app:compileDebugKotlincompile.DashSdkErrorTest: 36 passed, including codes 68 to 76 (shouldReportAnUnconfirmedPublicRequestByItsOwnCode).RegistrationCoordinatorTest: 12 passed.PrivateContactScanStatePersistenceTest(9 passed) andPlatformWalletPersistenceHandlerTest(162 passed, including the declared mask0x8bbfwithout bit 14) ran on the previous head; the last merge changed neither, nor the persistence they test.ContactRequestOutcomesTest: 29 passed. They cover one or more per code above (20 byshouldKeepAnUnconfirmedStateTransitionPending, for the contact request and the shielded send), plus the refresh recovery:shouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefreshshouldShowTheReadinessReadAfterTheRetryshouldNotAskTheWalletToRetryWhenThePaymentsAreRestoredshouldOpenTheStoreAgainWhileAStoreFailureIsRecordedshouldKeepRefreshingWhileARestorationStaysDeferred(past the first minute, 200 refreshes)shouldStopRefreshingWhenRestoredOrWhenTheScreenGoesAwayshouldKeepAPublicAcceptHeldAfterCode20ThroughAnEmptyPrivateReadAndAnUnsettledSync: the accept returns 20; a sync after which the read fails, and one after which the private-pending read is empty and the public hold stands, both keep the row held; a second accept is not offered, and one made anyway is refused with 76 and shows no error to retry; once the read says settled, Accept comes back.shouldShowARefusedUnconfirmedPublicRequestAsBeingChecked(76).shouldKeepAnUnconfirmedSubmissionAReadDidNotCover.shouldReadBothHoldsAndTellAFailedReadFromNothingHeld: the read covers both holds; a failure reads as unread, and a cancellation is rethrown.python3 scripts/check_sdk_parity_manifest.py --write-summary: passes, with 29 capabilities.FfiSmokeTestandDashDatabaseMigrationTestlocally: they need an emulator, and both compile here; CI's emulator job runsFfiSmokeTest;build_android.sh, with or without--no-shielded;Breaking Changes
For a host built on #5812:
DashSdkError.PlatformWallet(a sealed class) gains subclasses for codes 68 to 76. An exhaustivewhenover it must handle them.MIGRATION_15_16adds two tables).configureShieldedwhen it activates the manager, before loading wallets, and call it again after a failure.deleteWalletDatano longer removes a scan state position record marked unresolved. It stays until a cleared record for the same wallet and identity replaces it.publicContactRequestsUnconfirmedas well asprivateRepliesPending, and runs the DashPay sync to settle it.DashSdkError.NotFound, where it used to be the catch-all 99.NativePersistenceBridgethat keeps scan state must also overrideonLoadPrivateContactScanStateWallets, or the JNI wires none of its scan state slots and the capability is not attested.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 ·
ec344df/skip-botsproceeds without the ones not yet reported/self-reviewedkotlin-sdk(packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt,packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt,packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.ktand 23 more) — HashEngineeringrs-platform-wallet-ffi(packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md) — HashEngineering or ZocoLini or 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.