Skip to content

feat(kotlin-sdk): send, receive and accept DashPay private contact requests - #5813

Merged
QuantumExplorer merged 340 commits into
v5.1-devfrom
feat/kotlin-private-contact-requests
Nov 10, 2026
Merged

QuantumExplorer merged 340 commits into
v5.1-devfrom
feat/kotlin-private-contact-requests

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

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.

  • The request is signed by the identity signer the app already uses for public requests.
  • The wallet picks how an accepted request is replied to.
  • Result codes 68 to 76 become typed errors.
  • KotlinExampleApp:
    • shows a contact as pending while the wallet holds the pair for a private request;
    • shows a public request or accept that may have gone through as being checked, with no second one offered, until a DashPay sync settles it;
    • shows "setting up" (or the reason a restoration failed) while the wallet's private payments are not restored;
    • opens the shielded store when it activates the manager.

Value: parity with iOS for private contact requests.

Risks: low.

  • No consensus change.
  • New JNI exports and Kotlin wrappers. The public contact request bridges are unchanged.
  • The Room handler now declares the private scan state capability (bit 15). Hosts that use it must open the shielded store when they activate the manager (see Breaking Changes).

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 a long[]. It destroys every returned request handle, since Kotlin reads the requests from Room.
  • sendPrivateContactRequest(...) and acceptPrivateContactRequest(...):
    • built with the shielded feature;
    • the signer crosses as the SignerHandle the public sendContactRequest takes;
    • a negative account, or a shielded address that is not 43 bytes, is refused before anything crosses.
  • acceptContactRequestReplyingInKind(...) is exported in every build.
  • managedIdentityPrivateContact(identityHandle, contactId) returns how a contact's requests came, as JSON, or null.
  • privatePairHeld(walletHandle, senderId, recipientId) bridges platform_wallet_get_private_pair_held.
  • publicContactRequestUnconfirmed(walletHandle, senderId, recipientId) bridges platform_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:
    • privatePaymentsRestored
    • privateStoreRead
    • unresolvedPrivatePayment
    • privatePaymentsRestoreFailed
  • privatePaymentsRestoreFailure(walletHandle, identityId) returns the reason, or null.
  • rs-unified-sdk-jni/src/persistence.rs wires 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.
  • nativeCreate and nativeEffectivePersistenceCapabilityBits 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.

Kotlin SDK

  • Dashpay:
    • syncPrivateContactRequests(coreSignerHandle, maxPages) returns a PrivateContactSyncResult.
    • 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 a PrivateContactReadiness with privatePaymentsRestoreFailed.
    • privatePaymentsRestoreFailure(ourIdentityId) returns the reason, or null.
    • incomingContactRequest(ourIdentityId, senderId) (Swift's getIncomingContactRequest(senderId:)) gives an accept its request.
  • PlatformWalletManager:
    • sendPrivateContactRequest(...), acceptContactRequestReplyingInKind(...) and acceptPrivateContactRequest(...) sit with the other shielded spends.
    • They use the manager's resolver and identity signer, behind the manager's teardown gate.
  • DashSdkError.PlatformWallet types codes 68 to 76:
    • 68 PrivateContactRequestNeedsPrivateReply
    • 69 ShieldedBuildRequired
    • 70 ProtocolVersionNotActive
    • 71 PrivateContactRequestPending
    • 72 PrivateContactPaymentsNotRestored
    • 73 PrivateContactRecoveryUnsupported
    • 74 PrivateContactStoreNotRead, retryable
    • 75 SdkPinnedBelowProtocolVersion
    • 76 PublicContactRequestUnconfirmed
  • Scan state persistence:
    • NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE is 1L shl 15. Bit 14 is ASSET_LOCK_SHIELDED_RECIPIENTS, which Android does not declare.
    • Room goes to version 16 (MIGRATION_15_16, two tables, a new DAO).
    • The handler declares the capability with atomic changesets, stages the writes into the changeset round, and purges them with the wallet, except a position record marked unresolved (see below).
    • onLoadPrivateContactScanStateWallets lists every wallet with stored positions (walletIdsWithPositions).
  • Each new wrapper's KDoc names its Swift counterpart.
// The one accept a host calls: the wallet replies the way the request came.
manager.acceptContactRequestReplyingInKind(walletId, request).close()

// Whether to offer a send or reply to this contact.
val held = dashpay.privateRepliesPending(ourIdentityId, listOf(contactId)).isNotEmpty() ||
    dashpay.publicContactRequestsUnconfirmed(ourIdentityId, listOf(contactId)).isNotEmpty()

// Whether the wallet's private payments are restored, and why not.
val readiness = dashpay.privateContactReadiness(ourIdentityId)
if (readiness.privatePaymentsRestoreFailed) {
    val reason = dashpay.privatePaymentsRestoreFailure(ourIdentityId)
}

KotlinExampleApp

  • DashPaySyncHelpers.acceptIncomingContactRequest accepts with acceptContactRequestReplyingInKind. Requests and Add Contact's collision accept use it.
  • Pull-to-refresh on Requests also reads the private store.
  • There is no "Send privately" toggle yet. A public send to a private contact shows the wallet's refusal (68), as the iOS app does with its toggle off.
  • AppContainer opens the shielded store before loadPersistedWallets.
  • SendTransactionScreen's shielded path reads 20 as it reads 18: "may have gone through, do not retry" (shieldedOperationMayHaveGoneThrough).
  • Requests and Add Contact read both of the wallet's holds per contact (contactRequestHolds): the private one and the public one.
    • While either holds a pair, the row or Add Contact offers no Accept, Ignore or Send.
    • A public hold shows "This request may have gone through; it is being checked. The next sync settles it."
    • A send or accept that returns 20, or 76, shows the same at once, never a retry.
    • The Requests pull-to-refresh runs the DashPay sync, which settles the hold first, and then reads it. Add Contact gets a "Check again" button that does the same. Both also read again when any DashPay sync pass ends, the background one included.
  • The invitation claim's contact request reads 18, 20 and 76 as "may have gone through; it is being checked", not as a failure.

Parity

  • docs/sdk/sdk-parity-manifest.json has dashpay.private_contact_requests and dashpay.private_contact_request_scan_state, with the FFI symbols in shared_symbols, including platform_wallet_get_private_pair_held, platform_wallet_get_public_contact_request_unconfirmed and platform_wallet_get_private_payments_restore_failure. The public hold's tests are listed as its verification.
  • Both hosts are partial. A send or accept needs a protocol version 15 network with shielded funds, and no gate is recorded yet.
  • PARITY_SUMMARY.md is regenerated with scripts/check_sdk_parity_manifest.py --write-summary.

Current behaviour

The wallet rules are the same as on iOS (#5812, #5810).

  • A read before every request. Every contact request and acceptance first reads DashPay's private store for the identity to its end. While that read cannot finish, the request is refused with 74 and nothing is sent.
  • A pair is held until the wallet can prove what happened. The pair stays held while:
    • a private request is in flight;
    • it is paid and not yet read back; or
    • the shielded store's payment journal keeps it unresolved.
    • An endpoint's refusal does not free it. It is freed when the entry is read back or the payment's anchor is proven pruned, up to about 1100 blocks after it was recorded.
    • Dashpay.privateRepliesPending reads this per pair, applying discharged journal records first.
  • A public request that may have gone through holds the pair (feat(platform-wallet): send, receive and accept DashPay private contact requests #5810). A public send or accept whose broadcast may have been delivered returns 20, and the wallet keeps the pair held with the signed transition.
    • Until a DashPay sync settles it, every request or accept to the pair, and removing the sender identity, is refused with 76. Nothing new is signed.
    • The sync settles it first: a request found is recorded as sent; a nonce proved used frees the pair; otherwise it broadcasts the same bytes again and keeps the hold.
    • Dashpay.publicContactRequestsUnconfirmed reads the hold per pair.
  • Wallet readiness (72). While a wallet's private payments are not restored, every contact request is refused with 72. Causes:
    • a wallet published with no shielded store configured, by a persister that declares the scan state;
    • a wallet that bound a shielded account in an earlier process;
    • a restoration that failed or waits for a busy store;
    • an identity loaded with its unresolved-payment marker;
    • an identity added to a wallet registered or loaded under an id whose record the host kept marked after an earlier wallet under it was removed.
  • Opening the store at activation. AppContainer opens the shielded store before persisted wallets load.
    • If that fails, AppContainer.shieldedStoreFailure holds why, and the screens show it.
    • The bind-time calls stay. On the same path they do not wait on the store.
  • Recovery on a DashPay refresh. A restoration can fail after the store opened, and then configureShielded succeeds with nothing recorded. So on every refresh of Requests or Add Contact, while the store did not open or the readiness reads not restored:
    • the app calls 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;
    • it then reads the readiness the screen shows, followed by the pair-hold reads;
    • the app clears or rebuilds no hold itself.
    • While the payments stay unrestored, the effect keeps refreshing for as long as the screen is shown: every 3 s for the first minute, then every 15 s, with no last refresh. It also refreshes when a shielded sync pass ends.
    • A scan can hold the store for minutes, and the wallet runs the restoration it deferred only once the scan frees it, so the gate lifts when that restoration completes, with no isBound change.
    • Each refresh is local: in-memory reads, and a repeat configure that queues at most one deferred restoration per wallet.
    • It stops when the readiness reads restored or the screen leaves the composition.
    • No bind, restart or submitted request is needed.
  • A marked record outlives its wallet. The Room handler purges a wallet's scan state with the wallet, except a position record marked unresolved: the private payment it stands for may still execute.
    • It keeps that record until a record stored cleared for the same wallet and identity replaces it.
    • The next load hands it back as retained, so a wallet registered again under the id holds the identity it names (72) until a restoration shows the payment resolved.
  • Reads. The readiness and pair-hold reads each apply discharges themselves, so the screens read them in either order. A screen's read always covers both holds: a read of the private hold alone would offer a public request awaiting its outcome again.
  • A failed read is kept apart from "nothing pending": PrivateRepliesPendingSnapshot keeps the last good snapshot and leaves the controls off until a read succeeds.

What KotlinExampleApp does for each code (ContactRequestOutcomesTest pins these, as ContactRequestOutcomesTests.swift does):

Code Meaning KotlinExampleApp
16 For a note spend: refused before anything was sent; notes released Ordinary failure; retry offered
18 A note spend that may have been delivered; notes kept "This may have gone through"; no retry offered
20 A state transition the SDK could not confirm after a possible acceptance; the spend contract keeps it for a private send or accept, and a public one returns it through the blanket conversion, the wallet holding the pair until a DashPay sync settles it "This may have gone through"; no retry offered. A public one's row or Add Contact shows "This request may have gone through; it is being checked", with no Accept or Send, until publicContactRequestsUnconfirmed no longer lists it
68 A public send or accept to a contact whose requests went privately Failure message
69 A private reply on a build without shielded Failure message
70 The network is proven below protocol version 15 Failure on a send; a private refresh stays quiet
71 The pair is held for a private request Row or Add Contact shows pending; Accept, Ignore and Send off; private sync offered; controls come back only when the per-pair read says free
72 The wallet's private payments are not restored Every contact waits, showing "Setting up this wallet's private payments", or why the restoration failed, or why the shielded store did not open; each refresh asks the wallet to retry, and a timer refreshes again while the screen is shown (every 3 s for a minute, then every 15 s)
73 The persister cannot keep the scan state with atomic changesets Storage configuration error
74 The private store read did not finish "Still reading private contact requests"; no pending row; the user may try again
75 The SDK is pinned below the protocol version Developer-facing message, on a send and on a refresh
76 A public request to the pair awaits its outcome; nothing was signed "This request may have gone through; it is being checked"; no retry offered; a DashPay sync is started, and the controls come back only when publicContactRequestsUnconfirmed no longer lists it
a private reply whose payment the network refused
  the wallet keeps the pair held; the row shows the pending notice, with Accept off
  once the payment's anchor is proven pruned, privateRepliesPending no longer lists it and the next reload re-enables Accept
a public accept whose result cannot be confirmed (20)
  the row shows "This request may have gone through; it is being checked", with Accept off
  a refresh whose sync fails, or does not settle it, keeps the row so; the private-pending read is empty and changes nothing
  a second accept is not offered; one made anyway is refused with 76, nothing signed
  once a sync records the request, the pair moves to Contacts; once it proves the nonce used, Accept comes back
opening the shielded store fails at activation
  every contact shows "The shielded store could not be opened: <reason>"
  each DashPay reload tries again; once it opens, the wallet restores its payments and the notice goes away

How 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:
    • the readiness flags in the order Kotlin reads them;
    • the restore-failed flag on its own;
    • the sync counter order;
    • handle release;
    • the five scan state slots wired together, and the wallet list unpacking.
  • Gradle on the JBR runtime:
    • :sdk:kspDebugKotlin, :sdk:compileDebugKotlin, :sdk:compileDebugAndroidTestKotlin and :app:compileDebugKotlin compile.
    • DashSdkErrorTest: 36 passed, including codes 68 to 76 (shouldReportAnUnconfirmedPublicRequestByItsOwnCode).
    • RegistrationCoordinatorTest: 12 passed.
    • PrivateContactScanStatePersistenceTest (9 passed) and PlatformWalletPersistenceHandlerTest (162 passed, including the declared mask 0x8bbf without 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 by shouldKeepAnUnconfirmedStateTransitionPending, for the contact request and the shielded send), plus the refresh recovery:
      • shouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh
      • shouldShowTheReadinessReadAfterTheRetry
      • shouldNotAskTheWalletToRetryWhenThePaymentsAreRestored
      • shouldOpenTheStoreAgainWhileAStoreFailureIsRecorded
      • shouldKeepRefreshingWhileARestorationStaysDeferred (past the first minute, 200 refreshes)
      • shouldStopRefreshingWhenRestoredOrWhenTheScreenGoesAway
    • And the public hold:
      • shouldKeepAPublicAcceptHeldAfterCode20ThroughAnEmptyPrivateReadAndAnUnsettledSync: 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.
  • Not run:
    • FfiSmokeTest and DashDatabaseMigrationTest locally: they need an emulator, and both compile here; CI's emulator job runs FfiSmokeTest;
    • build_android.sh, with or without --no-shielded;
    • a test that restarts a process;
    • a UI run on an emulator, including the timed refresh;
    • a private send or accept against a protocol version 15 network.

Breaking Changes

For a host built on #5812:

  • Kotlin error types. DashSdkError.PlatformWallet (a sealed class) gains subclasses for codes 68 to 76. An exhaustive when over it must handle them.
  • Room schema. The Room database moves to version 16 (MIGRATION_15_16 adds two tables).
  • The shielded store must open first. The Room persistence handler now declares the private scan state (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 call configureShielded when it activates the manager, before loading wallets, and call it again after a failure.
  • Native library. The Kotlin SDK calls JNI symbols that only a native library built from this branch has.
  • Wallet deletion keeps marked records. deleteWalletData no longer removes a scan state position record marked unresolved. It stays until a cleared record for the same wallet and identity replaces it.
  • A public request awaiting its outcome. From feat(platform-wallet): send, receive and accept DashPay private contact requests #5810, a public send or accept that returns 20 holds the pair, and later ones get 76. A host shows the pair from publicContactRequestsUnconfirmed as well as privateRepliesPending, and runs the DashPay sync to settle it.
  • Unknown identities. The readiness, pair-hold, public-unconfirmed and restore-failure reads answer an identity the wallet does not hold with code 7, from feat(platform-wallet): send, receive and accept DashPay private contact requests #5810, which Kotlin maps to DashSdkError.NotFound, where it used to be the catch-all 99.
  • Bridge overrides. A NativePersistenceBridge that keeps scan state must also override onLoadPrivateContactScanStateWallets, or the JNI wires none of its scan state slots and the capability is not attested.

Checklist:

  • I have performed a self-review of my own code
  • My Rust, FFI, WASM, Swift or Kotlin changes follow the coding conventions
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed
  • If I added or changed GroveDB structure, I described it in the area's structure.rs, regenerated grovedb-structure.json, and checked the structure viewer link posted on this pull request
  • If I added or changed a system contract's document schemas, I checked the contract viewer link posted on this pull request

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

PR Hygiene · ec344df

  • Bots — thepastaclaw not yet — /skip-bots proceeds without the ones not yet reported
  • Self-review — post /self-reviewed
  • Reviewer requests paused — your 5 review slots are occupied; this PR is excluded from reviewers' queues. Required approvals still count without a slot.
  • Build running
  • Approvals
    • files with no dedicated owner — you own it
    • kotlin-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.kt and 23 more) — HashEngineering
    • rs-platform-wallet-ffi (packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md) — HashEngineering or ZocoLini or llbartekll or romchornyi

When every merge requirement is met, the PR Hygiene check passes. Reviewer limits do not block merging; other required GitHub checks and protections still apply.

@QuantumExplorer QuantumExplorer added this to the v5.1.0 milestone Nov 8, 2026
@coderabbitai

coderabbitai Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Share

@thepastaclaw

thepastaclaw commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

🕓 Review not started yet because the new head is waiting for the 30-minute push debounce.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit ec344df. Normal review starts when eligible; priority review starts as soon as a slot is available.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: normal by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort high); agent phase2-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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: normal by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort high); agent phase2-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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: normal by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort high); agent phase2-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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: normal by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort high); agent phase2-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.

QuantumExplorer and others added 6 commits November 9, 2026 04:45
…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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: normal by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort high); agent phase2-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.

QuantumExplorer and others added 15 commits November 9, 2026 05:28
… 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>
QuantumExplorer and others added 22 commits November 10, 2026 21:22
…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>
Base automatically changed from feat/ffi-swift-private-contact-requests to v5.1-dev November 10, 2026 17:40
@github-actions

Copy link
Copy Markdown
Contributor

Waiting for bot review — thepastaclaw not yet. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added the waiting-bots Waiting for the review bots to report on this head label Nov 10, 2026
#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>
@github-actions

Copy link
Copy Markdown
Contributor

Waiting for bot review — thepastaclaw not yet. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@QuantumExplorer
QuantumExplorer merged commit 185d0e6 into v5.1-dev Nov 10, 2026
22 of 25 checks passed
@QuantumExplorer
QuantumExplorer deleted the feat/kotlin-private-contact-requests branch November 10, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Client Only waiting-bots Waiting for the review bots to report on this head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants