Skip to content

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

Merged
QuantumExplorer merged 273 commits into
v5.1-devfrom
feat/ffi-swift-private-contact-requests
Nov 10, 2026
Merged

QuantumExplorer merged 273 commits into
v5.1-devfrom
feat/ffi-swift-private-contact-requests

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Basic explanation

What this does: iOS apps can now send, receive and accept DashPay private contact requests.

  • The Swift SDK wraps the wallet's private request calls:
    • sync the private store;
    • send;
    • accept the way the request came;
    • accept with a private reply;
    • read how a contact's requests came;
    • read whether a pair is held for a private request;
    • read whether a public request to a contact awaits its outcome;
    • read whether the wallet's private payments are restored, and why a restoration failed.
  • The request is signed by the same KeychainSigner the app passes for public requests.
  • The DashPay encryption key stays in the Keychain-backed resolver signer, which opens the store and seals the entry.
  • SwiftExampleApp gets a "Send privately" toggle, accepts the way each request came, and opens the shielded store when it activates a wallet manager.
  • A public request or accept that may have gone through no longer comes back as a fresh Accept or Send: the row shows it being checked until a DashPay sync settles it.

Value: the private request flow from #5810, usable from iOS without the app handling any key.

Risks: low.

  • No consensus change.
  • New C exports. The existing public contact request exports keep their signatures.
  • Swift now types the private contact result codes 68 to 76. A host must route 71 (pair held), 72 (wallet not restored), 74 (store read not finished) and 76 (a public request awaiting its outcome). It needs to route 68 only if it does not use the accept-in-kind call.
  • The SwiftData 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 #5810.

Known limit, tracked in #4270. Platform cannot yet prove to a client whether a state transition executed, or that it can no longer execute, so the wallet infers the outcome of an unconfirmed transition and holds notes and contact pairs until it has evidence it can verify itself. The result codes for unconfirmed outcomes and held pairs (18, 20, 71, 72, 76) and what the app shows for them follow from that. The handling is interim, as #5810's description explains; #4270 tracks the gap in the proofs and #5822 what the wallet needs from it.

What was done?

rs-sdk-ffi (MnemonicResolverCoreSigner)

  • scan_private_document_page sorts a page using these keys, all derived inside the signer from one resolve of the mnemonic:
    • the recipients' secret keys;
    • each sender encryption key's outgoing viewing key.
  • scan_private_document_pages does the same for a batch of pages with one resolve.
  • The SDK builds the scan keys (PrivateDocumentScanKeys::derive) and wipes them.
  • seal_private_contact_request derives the sender's encryption key, seals the request with the identity signer's signature, and wipes the key.
  • resolve_and_derive is now a one-path case of the new resolve_master.
  • The SDK's refusals come back as MnemonicResolverSignerError::PrivateContactRequest.

platform-wallet-ffi

  • ResolverContactCryptoProvider implements the wallet's three private methods over that signer: scan a page, scan a batch of pages, and seal.
  • New C exports:
    • platform_wallet_sync_private_contact_requests(wallet_handle, core_signer_handle, max_pages, out_requests, out_report)
    • platform_wallet_manager_send_private_contact_request(handle, wallet_id_bytes, mnemonic_resolver_handle, account, sender_identity_id, recipient_identity_id, account_label, auto_accept_proof, auto_accept_proof_len, shielded_address, signer_handle, out_request_handle) (shielded feature)
    • platform_wallet_manager_accept_private_contact_request(handle, wallet_id_bytes, mnemonic_resolver_handle, account, request_handle, shielded_address, signer_handle, out_established_handle) (shielded feature)
    • platform_wallet_manager_accept_contact_request_replying_in_kind(...): same arguments as the private accept, exported in every build.
    • managed_identity_get_private_contact(identity_handle, contact_id, out_contact)
    • platform_wallet_get_private_pair_held(wallet_handle, sender_id, recipient_id, out_held)
    • platform_wallet_get_public_contact_request_unconfirmed(wallet_handle, sender_id, recipient_id, out_unconfirmed): whether a public request from the sender to the recipient awaits its outcome (DashPayView::public_contact_request_unconfirmed, feat(platform-wallet): send, receive and accept DashPay private contact requests #5810). It reads outside the registry guard, as the pair-hold read does.
    • platform_wallet_get_private_payments_restore_failure(wallet_handle, identity_id, out_reason): a string the caller frees with platform_wallet_string_free, or null.
  • signer_handle is the SignerHandle that platform_wallet_send_contact_request_with_signer takes. The send and both accepts follow the shielded spend contract (catch_spend_panic, map_spend_result). That contract keeps codes 68, 71, 72, 73 and 74, and maps a removed wallet to NotFound (98).
  • Scan state persistence:
    • capability PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE (1 << 15);
    • five extension slots:
      • on_persist_private_contact_scan_state_fn;
      • on_load_private_contact_scan_state_fn;
      • on_load_private_contact_scan_state_free_fn;
      • on_load_private_contact_scan_state_wallets_fn, which lists the ids of every wallet the host keeps scan state for;
      • on_load_private_contact_scan_state_wallets_free_fn;
    • row types PrivateContactRequestsPositionFFI and PrivateShieldedAddressFFI;
    • the load hands the wallet, as retained_private_contact_scan_states, every marked position record no restored identity takes: those of a wallet it did not return, and those of an identity it did not restore.
  • The pair-hold, public-unconfirmed and restore-failure exports answer an identity the wallet does not hold with ErrorIdentityNotFound (7), as the readiness does.
  • ERROR_CODE_REGISTRY.md: rows 68 to 76 name the exports and Swift cases on this branch.

swift-sdk

  • ManagedPlatformWallet:
    • syncPrivateContactRequests(maxPages:) returns a PrivateContactSyncResult;
    • privateContactReadiness(identityId:) returns a PrivateContactReadiness;
    • privatePaymentsRestoreFailure(identityId:) returns the reason, or nil;
    • privatePairHeld(senderId:recipientId:) returns whether the pair is held;
    • publicContactRequestUnconfirmed(senderId:recipientId:) returns whether a public request to the pair awaits its outcome.
  • PlatformWalletManager:
    • sendPrivateContactRequest(...), acceptContactRequestReplyingInKind(...) and acceptPrivateContactRequest(...).
    • They take the resolver, as the other shielded calls do, and signer: KeychainSigner, as the public send and accept do.
  • ManagedIdentity.privateContact(contactId:) returns PrivateContactInfo (came privately, went privately, shielded address), or nil.
  • PlatformWalletError types codes 68 to 76:
    • 68 privateContactRequestNeedsPrivateReply
    • 69 shieldedBuildRequired
    • 70 protocolVersionNotActive
    • 71 privateContactRequestPending
    • 72 privateContactPaymentsNotRestored
    • 73 privateContactRecoveryUnsupported
    • 74 privateContactStoreNotRead
    • 75 sdkPinnedBelowProtocolVersion
    • 76 publicContactRequestUnconfirmed
  • PlatformWalletPersistenceCapabilities.privateContactRequestScanState is 1 << 15. The SwiftData handler declares it with atomic changesets.
  • The SwiftData handler keeps a position record marked unresolved when it purges its wallet, and lists the wallets it keeps scan state for (loadPrivateContactScanStateWalletIds).
// The one accept a host calls: the wallet replies the way the request came.
_ = try await manager.acceptContactRequestReplyingInKind(
    walletId: walletId, resolver: resolver, request: request, signer: signer)

// Whether to offer a send or reply to this contact: false while the wallet
// holds the pair for a private request.
let held = try wallet.privatePairHeld(senderId: ourIdentityId, recipientId: contactId)

// Also false while a public request to the contact awaits its outcome; a
// DashPay sync settles it.
let unconfirmed = try wallet.publicContactRequestUnconfirmed(
    senderId: ourIdentityId, recipientId: contactId)

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

SwiftExampleApp

  • Add Contact has a "Send privately" toggle that pays from the wallet's shielded balance.
  • The request list accepts with acceptContactRequestReplyingInKind. Add Contact's collision accept does too, unless "Send privately" is on: then it replies privately.
  • Pull-to-refresh on the request list also reads the private store.
  • WalletManagerStore opens the shielded store when it activates a manager, before any wallet is loaded or created.
  • SendViewModel's shielded path reads 20 as it reads 18: "may have gone through, do not retry" (SendViewModel.mayHaveGoneThrough).
  • PrivatePaymentsRecovery.refresh is the request list's and Add Contact's readiness refresh. It asks the wallet to retry before it reads the readiness it shows, and keeps re-reading on a timer while the payments stay unrestored.
  • The request list 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 footer 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 request list's 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.

Current behaviour

  • One accept. acceptContactRequestReplyingInKind lets the wallet pick the reply:
    • private when the request came privately and nothing was sent back yet;
    • otherwise a document, or the reply already sent.
    • Only a private reply is paid. A public or adopted reply needs neither shielded support nor a mnemonic read for a payment.
  • Signed by the identity signer. The resolver signer seals each request with a signature by the identity's signing key, from the same KeychainSigner the public send uses.
  • A read before every request. Every contact request and acceptance, public or private, first reads DashPay's private store for the identity from its cursor to the end. It uses the keys Platform proves the identity holds.
    • If that read cannot finish, the request is refused with 74. Nothing is reserved or sent, and calling again continues the read.
    • The private send and the private accept first refuse with 73 or 72, before reading.
  • A pair is held until the wallet can prove what happened.
    • Before a private payment can reach the network, the shielded store records it in its private payment journal.
    • The pair stays held while the request is in flight, while it is paid and not yet read back, or while the journal keeps the payment unresolved. That holds even after the sender identity is removed and added again.
    • An endpoint's answer proves nothing, so a refused payment is not freed on a refresh. The pair is freed when:
      • the entry is read back from the store; or
      • the payment's anchor is proven pruned, up to about 1100 blocks after it was recorded (longer on an idle pool).
    • platform_wallet_get_private_pair_held (privatePairHeld) reads one pair as a send decides it. It applies 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.
    • platform_wallet_get_public_contact_request_unconfirmed (publicContactRequestUnconfirmed) reads the hold per pair.
  • The readiness read is platform_wallet_get_private_contact_readiness. Its PrivateContactReadinessFFI has four fields:
    • private_payments_restored
    • private_store_read
    • unresolved_private_payment
    • private_payments_restore_failed
    • It also applies discharged journal records first, taking the wallet's write lock briefly. Both reads apply discharges themselves, so a host may read them in either order.
    • platform_wallet_get_private_payments_restore_failure gives the reason behind private_payments_restore_failed.
  • The wallet holds every contact request (72) while its private payments are not restored. That happens when:
    • it was published (created or loaded) with no shielded store configured, and its persister declares the scan state; or
    • it bound a shielded account in an earlier process; or
    • its last restoration failed or waits for a busy store; or
    • an identity was loaded with its scan state marking a payment that may be unresolved; or
    • an identity was 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. Configuring the shielded store restores the payments with no keys. SwiftExampleApp therefore opens the store when it activates a manager, before loading wallets.
    • If opening fails, the app records why, tries again on the next activation and on each DashPay refresh, and shows the reason.
    • 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 the request list or Add Contact, while the store did not open or the readiness reads not restored:
    • the app calls configureShielded again; 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 it shows, followed by the pair-hold reads.
    • While the payments stay unrestored, the view keeps refreshing for as long as it 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.
    • 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 view goes away.
    • No bind, restart or submitted request is needed.
  • Scan state survives a restart. Each identity's read position, its key fingerprint, its unresolved-payment marker and each private contact's shielded address are persisted, so a relaunch resumes the private store read.
    • They go through the three size-gated extension slots behind bit 15. Bit 14 is feat(platform-wallet): persist the Orchard recipient of a shielded asset-lock funding #5762's ASSET_LOCK_SHIELDED_RECIPIENTS.
    • The wallet keeps the state, and pays for a private request, only together with ATOMIC_CHANGESETS (bit 0). A host without both gets 73 for a private send or accept, and reads the store from its start after a restart.
    • Swift stores the state in two SwiftData models in live schema V5, with a lightweight V4 to V5 migration. Storage Explorer lists them.
  • A marked record outlives its wallet. The 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.
    • A kept record is read under the network it was stored under. A handler configured with no network (the default of configure(sdkPointer:modelContainer:network:)) reads it under every stored network, rather than reading it as absent under a guessed testnet. With no network known, a scan state write refuses rather than filing the state under a guessed network.
    • V5 also holds feat(platform-wallet): persist the Orchard recipient of a shielded asset-lock funding #5762's asset-lock recipient field; both changes are unreleased.
  • Removed wallets. A wallet removed before a private payment pays nothing. The private-payment exports return NotFound (98).
  • Identity removal is refused with 71, 72 or 76 while the identity's contact request channel is held. IdentityManager.removeIdentity documents it.

What SwiftExampleApp does for each code (ContactRequestOutcomesTests pins these):

Code Meaning SwiftExampleApp
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 (any endpoint verdict, or a transport failure after a dispatch); 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 footer shows "This request may have gone through; it is being checked", with no Accept or Send, until publicContactRequestUnconfirmed says settled
68 A public send or accept to a contact whose requests went privately Failure message; a public send with "Send privately" off shows it, and the in-kind accept picks the private reply itself
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 footer shows pending; Accept, Ignore and Send off; private sync offered; controls come back only when privatePairHeld 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 publicContactRequestUnconfirmed says settled
98 The wallet was removed before the payment Failure message
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, privatePairHeld reads false 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 pull-to-refresh whose sync cannot settle it rebroadcasts the same bytes; the row stays so
  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
a send while the private store read cannot finish
  74: nothing sent, no pending row; Send stays on and a second tap continues the read

How Has This Been Tested?

  • cargo clippy --all-targets -- -D warnings is clean on:
    • -p rs-sdk-ffi;
    • -p platform-wallet-ffi, with and without --features shielded.
  • cargo test -p platform-wallet-ffi --lib -- private readiness persistence:: manager:: error:: shielded_send::: 213 passed; with --features shielded: 249 passed.
    • These include the pair-hold, public-unconfirmed and restore-failure reads, which cover a free pair, an unknown wallet, an unknown identity (7) and null outputs.
    • They include the retained records: should_hand_back_the_marked_records_no_restored_identity_takes and should_hold_an_identity_a_kept_marked_record_names_when_its_wallet_is_registered_again.
    • They include the size gate of the two new slots.
    • They also include code 73 kept through map_spend_result.
  • ./build_ios.sh --target sim (release): BUILD SUCCEEDED.
  • SwiftDashSDK tests on an iPhone 17 simulator, all passed:
    • ErrorHandlingTests: 71, including codes 68 to 76 (testShouldReportAnUnconfirmedPublicRequestByItsOwnCode) and every readiness field.
    • PrivateContactScanStatePersistenceTests: 10, including:
      • bit 15 pinned to the C constant;
      • a marked record kept through its wallet's purge until a cleared one replaces it;
      • the wallet list shim;
      • testShouldHandBackAKeptMainnetRecordToAHandlerWithNoNetwork: a nil-network handler and a mainnet wallet's kept record after the wallet's removal;
      • testShouldRefuseToStoreScanStateWithNoNetworkToStoreItUnder.
    • SDKUnconfirmedErrorTests: 4.
    • TopUpUnconfirmedErrorTests: 1.
  • SwiftExampleApp built, and ContactRequestOutcomesTests passed 32 of 32. They cover one or more per code above (20 by testShouldKeepAnUnconfirmedStateTransitionPending and, for the shielded send, testShouldNotOfferARetryForAShieldedOperationThatMayHaveGoneThrough), plus the refresh recovery:
    • testShouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh
    • testShouldShowTheReadinessReadAfterTheRetry
    • testShouldNotAskTheWalletToRetryWhenThePaymentsAreRestored
    • testShouldOpenTheStoreAgainWhileAStoreFailureIsRecorded
    • testShouldKeepRefreshingWhileARestorationStaysDeferred (past the first minute, 200 refreshes)
    • testShouldStopRefreshingWhenRestoredOrWhenTheViewGoesAway
  • And the public hold:
    • testShouldHoldAPublicAcceptThatMayHaveGoneThroughThroughAReloadAndARetryUntilTheWalletSettlesIt: the accept returns 20; a reload whose sync did not settle it reads an empty private hold and the public hold, and keeps the row held; a second accept is not offered, and one made anyway is refused with 76 and shows no error to retry; a failed read keeps it held; once the read says settled, Accept comes back.
    • testShouldShowARefusedUnconfirmedPublicRequestAsBeingChecked (76).
    • testShouldKeepAnUnconfirmedSubmissionAReadDidNotCover.
    • testShouldReadBothHoldsAndTellAnUnmanagedIdentityFromAFailedRead: the read covers both holds for every contact; 7 reads as nothing held, any other failure as unread.
  • scripts/check-storage-explorer.sh: all 38 model types covered, on the previous head. The last merge changed no SwiftData model.
  • Not run:
    • InvitationPersistenceTests on this head (it passed on the previous one, and the last merge did not touch it);
    • rs-sdk-ffi's signer tests on this head;
    • a private send or accept against a network (it needs a protocol version 15 network with shielded funds);
    • a test that restarts a process;
    • a UI run on a simulator, including the timed refresh.

Breaking Changes

For a host built on #5810:

  • Swift error types. PlatformWalletError and PlatformWalletResultCode gain cases for codes 68 to 76. An exhaustive switch over PlatformWalletError must handle them.
  • The shielded store must open first. The SwiftData persistence handler now declares privateContactRequestScanState (bit 15) with atomic changesets. The wallet then holds every wallet loaded or created while no shielded store is configured (72). A host using the handler must call configureShielded when it activates the manager, before loading or creating wallets, and call it again after a failure.
  • SwiftData V5. V5 gains two models. The base branch's V5 already holds feat(platform-wallet): persist the Orchard recipient of a shielded asset-lock funding #5762's asset-lock recipient field, so a store created at that V5 shares the version identifier and is not expected to open with this build. Both changes are unreleased.
  • PersistenceCallbacksExtension grows by five trailing slots after feat(platform-wallet): persist the Orchard recipient of a shielded asset-lock funding #5762's recipient slots, under the same version, gated by struct_size. A host passing the older, smaller struct gets the scan state slots as absent. A host compiled against the new header must rebuild.
  • The scan state capability needs all five slots. PRIVATE_CONTACT_REQUEST_SCAN_STATE is attested only with all five. A host declaring it must keep a position record marked unresolved through its wallet's removal, and list that wallet from on_load_private_contact_scan_state_wallets_fn. The SwiftData handler does both.
  • 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 publicContactRequestUnconfirmed as well as privatePairHeld, 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 ErrorIdentityNotFound (7), from feat(platform-wallet): send, receive and accept DashPay private contact requests #5810, not ErrorUnknown (99).

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 · f3878ca

  • 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
    • rs-platform-wallet-ffi (packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md, packages/rs-platform-wallet-ffi/src/contact_persistence.rs, packages/rs-platform-wallet-ffi/src/contact_request.rs and 8 more) — HashEngineering or ZocoLini or llbartekll or romchornyi
    • swift-sdk (packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift, packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift, packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift and 22 more) — 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

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 14 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 623530c and f3878ca.

Files selected for processing: 36

  • packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
  • packages/rs-platform-wallet-ffi/src/contact_persistence.rs
  • packages/rs-platform-wallet-ffi/src/contact_request.rs
  • packages/rs-platform-wallet-ffi/src/dashpay.rs
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/manager.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet-ffi/src/private_contact_request.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet-ffi/src/test_fixtures.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityManager.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift
  • packages/swift-sdk/schema-models.json
  • 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 f3878ca. 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

Static verification at 6787f32 confirms four blocking issues involving ambiguous-spend retries, post-payment persistence failures, teardown admission, and acceptance orchestration ownership. The cryptographic delegation and ABI mapping are otherwise sound; the claimed missing Rust pin for code 68 already exists. Current CI reports passing Kotlin build/tests and no failed code checks; no local builds or tests were run.

🔴 4 blocking | 🟡 4 suggestion(s)

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 8: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large cross-language change directly implements cryptographic key derivation, private-document scanning and request sealing in packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs, including scan_private_document_page and seal_private_contact_request.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); 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)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); 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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift:471-479: Keep unconfirmed private requests pending instead of enabling a fresh send
  The private send can throw `shieldedSpendUnconfirmed`, but the generic catch treats it as an ordinary failure and the defer clears `isSending`. The native send has not recorded an outgoing contact request at this point, although its transition may already have executed or remain queued for byte-identical re-drive. With other spendable notes available, another tap can build a different transition and pay another fee; reserving the first transition's notes does not prevent this. The newly routed private accepts in this view and ContactRequestsView likewise restore their Accept controls after an ambiguous completion. Handle this outcome as pending, following the distinction already made by SendViewModel, and reconcile native spend recovery plus private-store ingestion before allowing a fresh submission. Add injected-unconfirmed coverage for both send and accept.

In `packages/rs-platform-wallet-ffi/src/private_contact_request.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/private_contact_request.rs:528-533: Preserve the committed accept outcome after a contact-store failure
  This exported accept can report an ordinary failure after its shielded reply was confirmed. In the native private send, `pay(entry)` succeeds before `add_sent_contact_request`; a failure of that store is logged and swallowed. Because `add_sent_contact_request` persists before updating memory, the failed write leaves neither a sent request nor an established contact. `accept_contact_request_replying_with` then fails its final established-contact lookup with `ContactRequestNotFound`, which this export maps to a generic wallet-operation failure. Retrying before private-store sync recovers the reply can therefore broadcast and pay for another reply. Fix the native private-accept outcome contract so a confirmed payment followed by local persistence failure remains committed/recovery-required, with protection against a fresh reply. Test a failed contact store after successful payment and verify that retry does not invoke payment again.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift:156-162: Include private spends in teardown admission
  Both new manager methods detach their native calls without entering `admitNativeOp`/`finishNativeOp`. Consequently, `deleteWallet` can pass its zero-active-operation guard while either spend is running, and shutdown does not drain these calls. The FFI retains wallet and coordinator Arcs, so removal does not cancel an operation already proving. After proof construction, `private_document_create_from_pool_with_metadata` records activity and broadcasts without a wallet-generation liveness gate; `save_activity` uses `entry(id).or_default()`, allowing removed shielded state to be recreated and persisted after the wipe. Count both Swift operations through completion, and make the native private-spend path participate in the existing generation lifecycle guard so direct native/FFI callers are also protected. Test removal and shutdown while proving is paused.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift:249-257: Move automatic acceptance routing into platform-wallet
  This helper attempts public acceptance, interprets the wallet's privacy refusal, and substitutes a shielded-funded private acceptance. That is DashPay orchestration, not argument marshalling or an explicit user-selected channel. The coding conventions assign mobile DashPay orchestration to rs-platform-wallet and require bindings to call an existing Rust operation rather than make these decisions; the motivating example explicitly includes logic in a Swift view. The Rust refusal correctly prevents public disclosure, but every other client must currently reproduce the replacement-operation policy. Add a channel-aware native acceptance operation using the wallet-owned request provenance and reciprocity state, expose it through a thin binding, and have the views supply credentials and acceptance intent. The explicitly public-only API can retain its refusal contract.
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift:233-235: Expose failed or incompletely persisted private refreshes
  This helper suppresses every private-sync error and discards the report. An unsupported pre-v15 network can be an expected case, but network failures, unavailable resolver material, and a nonzero `unpersistedIdentityCount` do not mean the request list was successfully refreshed. This is the example app's only private-store ingestion call, so these outcomes leave stale or empty results without indicating that private requests could not be checked or durably ingested. Handle the expected unsupported-network case separately and expose operational errors and incomplete persistence through refresh error/banner state.

In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay.rs:1300-1305: Preserve the missing-mnemonic key-availability signal
  When the resolver returns NOT_FOUND during private-store scanning, `MnemonicResolverSignerError::NotFound` carries the reserved key-unavailable marker at position zero. This conversion wraps it in `InvalidIdentityData`, which has no dedicated FFI mapping and therefore becomes `ErrorUnknown` (99). The existing code-31 restoration only recognizes the marker structurally inside `Sdk(Protocol(Generic(...)))`, so it cannot restore the signal from this variant. A caller consequently cannot distinguish missing signing material from an unknown internal failure. Map NotFound explicitly to a key-unavailable outcome and add an FFI mapping test; preserve the structural discriminator rather than introducing substring matching.

In `packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`:
- [SUGGESTION] packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs:712-726: Share recipient and sender scan-key assembly
  `PrivateDocumentKeyPaths::scan_keys` in platform-wallet already performs the recipient-secret and sender-outgoing-viewing-key transformation through a derivation callback. This adds another implementation in the FFI crate, with independently maintained temporary-key cleanup. The wallet still supplies the roles and paths, so this is not an incorrect key-selection finding, but the two implementations can drift as scan-key construction or erasure changes. Keep mnemonic resolution and derivation inside the resolver adapter, and extract the shared assembly into a lower Rust-library helper, such as a callback-based constructor in rs-sdk's private_document_scan module. Preserve this implementation's wipe-on-error handling when sharing it.
- [SUGGESTION] packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs:181-187: Cover the remaining documented seal-refusal inputs
  The new error contract documents malformed requests and contracts without the private type, but the signer tests exercise SDK refusals only through an unrelated encryption key and another key's signature. Add malformed-request and missing-private-type cases that assert `MnemonicResolverSignerError::PrivateContactRequest` and verify that no sealed entry is returned. Validation should remain in rs-sdk; these tests would pin the adapter's forwarding and typed-error contract rather than add validation to the binding.

QuantumExplorer and others added 4 commits November 8, 2026 20:14
… the FFI

`platform_wallet_manager_accept_contact_request_replying_in_kind(handle,
wallet_id_bytes, mnemonic_resolver_handle, account, request_handle,
shielded_address, signer_handle, out_established_handle)` (`shielded`
feature) exposes `PlatformWallet::accept_contact_request_replying_in_kind`:
the one accept a host calls. The wallet replies privately to a request that
came privately with no reply yet, and with a document (or by adopting the
reply already sent) otherwise, so hosts no longer catch code 68 and call
the private accept themselves.

It is built as `platform_wallet_manager_accept_private_contact_request`
is: same arguments and null checks, `catch_spend_panic` and
`map_spend_result` for the shielded spend contract, and a null-signer test.
The docs of code 68 name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`PlatformWalletManager.acceptContactRequestReplyingInKind(walletId:resolver:account:request:shieldedAddress:signer:)`
is a thin wrapper over the new FFI export: the wallet decides whether the
reply is private or a document. The explicitly public and private accepts
stay. The file header and the code 68 docs point hosts at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quest is replied to

The request list and Add Contact's collision alert call
`acceptContactRequestReplyingInKind` once instead of trying the public
accept and falling back to the private one on code 68. The
`acceptIncomingContactRequest` helper that held that choice is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@thepastaclaw 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 1c07538. Wallet-owned acceptance routing fixes the previous layering issue, but four blocking issues remain: private operations bypass teardown admission, committed and ambiguous replies can enable duplicate fee-bearing requests, and public acceptance unnecessarily requires shielded configuration. This was a static review with no builds or tests run; current CI shows Kotlin build/tests passing and the review pending, with no Rust or iOS validation jobs listed.

🔴 4 blocking | 🟡 3 suggestion(s)

6 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 8: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large cross-language diff directly changes cryptographic key derivation, private-document scanning, request sealing, and signature handling in packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs, including scan_private_document_page and seal_private_contact_request.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); 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)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); 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/rs-platform-wallet-ffi/src/private_contact_request.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/private_contact_request.rs:616-623: Acquire shielded resources only when acceptance needs a private reply
  The new in-kind entrypoint calls resolve_wallet_and_coordinator before the wallet chooses public reply, private reply, or adoption. That helper rejects a loaded wallet unless configure_shielded has run, so public acceptance and adoption now fail with 'shielded support not configured' even though neither needs a shielded payment. Both example-app acceptance sites have switched unconditionally to this entrypoint. Resolving the seed here also imposes spend-authority access before the wallet can determine that no payment is needed; delaying derive_spend_keyset inside the wallet does not avoid these earlier failures. Keep routing in rs-platform-wallet, but make shielded-context and spend-authority acquisition lazy behind its private-payment branch. Cover public and adopt-only acceptance without a shielded coordinator.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/private_contact_request.rs:531-536: Preserve the committed accept outcome after a contact-store failure
  (existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333080)
  A confirmed private reply can reach this Err arm as an ordinary operation failure. DashPayView::send_private_contact_request awaits pay successfully, logs a failed add_sent_contact_request, and returns Ok. That identity update persists before inserting the established contact into memory, so the enclosing accept_contact_request_replying_with then fails its final lookup with ContactRequestNotFound. Both accept exports map this to ErrorWalletOperation, and the views offer acceptance again despite the first reply already having committed and consumed its fee. With no recorded outgoing request to adopt, another attempt can pay for another private entry. Preserve a committed-but-local-repair-pending outcome in rs-platform-wallet's acceptance orchestration and marshal it through both bindings; test a persister failure after successful payment without allowing a second paid reply.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift:158-164: Include private spends in teardown admission
  (existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333083)
  sendPrivateContactRequest, acceptPrivateContactRequest, and acceptContactRequestReplyingInKind capture the manager handle and suspend into Task.detached without calling admitNativeOp/finishNativeOp. shutdown() drains only activeNativeOpCount, and deleteWallet() uses that same count to exclude concurrent work, so either can overtake these operations. The native exports release the registry guard after cloning wallet/coordinator Arcs; those preserve object lifetime but do not stop a proof already in progress from broadcasting and writing activity after teardown or deletion. Enroll all three wrappers in balanced admission before their first suspension and retain it through native completion and result conversion. Add lifecycle coverage demonstrating that shutdown waits and deletion is refused while a private operation is active.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift:471-479: Keep unconfirmed private requests pending instead of enabling a fresh send
  (existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333077)
  When this call throws shieldedSpendUnconfirmed, the generic catch only sets errorMessage and defer clears isSending, immediately enabling Send Request again. The wallet records the outgoing contact request only after pay returns confirmed metadata, so this ambiguous attempt has no contact record that prevents another submission. Shielded reservations protect the first attempt's notes, not the recipient: another call can select other available notes and append another independently sealed request, while the original may still confirm or be re-driven by sync. Both acceptance actions similarly release their in-flight guards on this outcome. Retain the unresolved contact operation and its reconciliation status in rs-platform-wallet, expose it through the bindings, and render it as pending until resolved, including after reopening or restart.

In `packages/rs-platform-wallet-ffi/src/dashpay.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/dashpay.rs:1300-1305: Preserve the missing-mnemonic key-availability signal
  (existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333098)
  MnemonicResolverSignerError::NotFound carries the reserved key-unavailable marker, but this catch-all wraps it in InvalidIdentityData. A missing mnemonic during private scanning therefore becomes ErrorUnknown rather than ErrorSigningKeyUnavailable (31); map_spend_result likewise cannot recover the classification from this wrapper. Keeping the marker somewhere in the rendered message is insufficient because both conversions deliberately recognize it only in the structured SDK ProtocolError::Generic payload. Preserve NotFound through the existing key-unavailable representation while keeping malformed mnemonic and derivation errors distinct. Add a resolver-provider-to-FFI boundary test asserting code 31, not only the signer's intermediate NotFound variant.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift:233-235: Expose failed or incompletely persisted private refreshes
  (existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333092)
  This helper discards every thrown error and the successful PrivateContactSyncResult. Consequently, a network, proof, or unavailable-mnemonic failure looks like a completed empty refresh, and unpersistedIdentityCount > 0 is ignored even though those requests did not reach durable storage. The wallet retains affected cursors for retry, but the request view receives no indication that recovery is needed. This is the example app's private-store receiving path. Distinguish expected unsupported-network behavior from operational failures, and expose failed or incompletely persisted passes through the refresh/error state with a retry indication.

In `packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs`:
- [SUGGESTION] packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs:712-726: Share recipient and sender scan-key assembly
  (existing thread: https://github.com/dashpay/platform/pull/5812#discussion_r4506333099)
  This helper adds a second implementation of the scan-key assembly already exposed by rs-platform-wallet's PrivateDocumentKeyPaths::scan_keys and used by the resident provider: derive each recipient's keyed secrets and derive an outgoing viewing key for each sender. The implementations already handle temporary cleanup differently, and future construction changes must now be maintained separately for resident and resolver-backed scans. Extract the common assembly into a lower library accessible to both callers, such as an SDK builder accepting a derivation closure. Retain the resolver's single resolve_master scope and wiping guards, and do not introduce a dependency from rs-sdk-ffi back to platform-wallet. This is a shared-helper maintainability suggestion, not a claim that the current code selects incorrect keys.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Apply lifecycle admission to the existing shieldedTransfer wrapper — PlatformWalletManagerShieldedSync.swift's pre-existing shieldedTransfer also captures the manager handle and runs detached native work without admission. Correcting the new private-contact wrappers alone leaves this older spend able to outlive the manager's admission drain.
    • Follow-up: A separate follow-up PR should enroll shieldedTransfer in balanced native-operation admission and test shutdown and deletion during an in-flight transfer.

QuantumExplorer and others added 19 commits November 8, 2026 21:52
…y heights

Review fixes:

- Requests are checked against their type's schema with dpp's document
  validation (`DataContract::validate_document_properties`), before
  encoding when sealing and after decoding when reading, as the payload
  codec asks. The SDK's own copies of the label and proof bounds are gone;
  `to_properties` is a typed conversion. The SDK enables dpp's `validation`
  feature. `seal_private_contact_request` and
  `private_contact_request_signing_data` take `platform_version` last.
- The reader takes each identity with the height its proof is of
  (`ProvedIdentity`). `private_contact_request_identities` lists, per
  identity, the latest block whose entry it checks; a snapshot proved
  before it, which a node may serve within the SDK's height tolerance, or
  one not given, fails the read instead of judging the entry against keys
  as they were before it. Only signed entries need identities.
- `Sdk::fetch_private_contact_request_identities` fetches them four at a
  time with their metadata and fetches a stale one once more.
- Both SDK helpers establish protocol version 15 before resolving DashPay,
  so an unpinned SDK starting below it does not read a DashPay without the
  private type.
- `PrivateDocumentScanKeys::derive` builds scan keys from key paths with a
  derivation callback, wiping what it derives, for signers that keep keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes (with the SDK changes of #5809 merged in):

- A private request whose store fails after it was paid for is kept in
  memory (`add_paid_sent_contact_request`): the contact it answers
  establishes, so a retried accept adopts it instead of paying for another
  reply, and the next add of the same request (the private sync recovers it
  from the store) stores it. Before, the failed store left no request and no
  contact, so the accept failed and a retry paid again.
- `PlatformWallet::accept_contact_request_replying_in_kind` takes what a
  private reply is paid with as `payment`, a closure called only for a
  private reply (`PrivateReplyPayment`: coordinator, seed, account, prover).
  A public reply or an adopted one needs neither shielded support nor the
  seed.
- The private sync checks each page against identities proved at or after
  its entries (`ProvedIdentity`), fetching a few at a time only those it
  lacks or holds from an older proof.
- Scan keys are built with the SDK's `PrivateDocumentScanKeys::derive`; the
  crypto provider's seal takes `platform_version`, which the SDK's schema
  validation needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… derive

- `scan_private_document_page` builds its keys with
  `PrivateDocumentScanKeys::derive`, which wipes the derived keys on an
  error too. The signer keeps only the mnemonic resolve and the per-path
  derivation; its own scan-keys helper and `WipingSecretKeys` are gone.
- `seal_private_contact_request` takes `platform_version` last and passes
  it to the SDK seal, which validates the payload with dpp.
- Tests follow the SDK: signing data and seals take the platform version,
  and the read takes `ProvedIdentity` entries proved at the heights the
  scan needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it is one, in every build

`platform_wallet_manager_accept_contact_request_replying_in_kind` no
longer looks up the shielded coordinator or reads the mnemonic up front.
It hands the wallet a payment that, only for a private reply, looks the
coordinator up and resolves the seed on a blocking thread. A public or
adopted reply therefore works on a manager without
`platform_wallet_manager_configure_shielded` and reads no mnemonic for the
payment (an FFI test pins both). A failure of that lookup returns the code
the lookup reported. A reply for which no payment was asked keeps the
codes the public accept returns; once one was asked, errors keep the
shielded spend contract.

The export and its JNI-facing contract exist in every build. Without the
`shielded` feature it calls `DashPayView::accept_contact_request_replying_in_kind`
with a payment that refuses: a request that needs a private reply fails
with the new `ErrorShieldedBuildRequired` (69), and a public or adopted
reply works as before. No existing code meant "shielded unsupported".

Also:
- `ErrorProtocolVersionNotActive` (70): the SDK's lasting
  `ProtocolVersionNotActive` refusal, such as the private store before
  protocol version 15, was `ErrorUnknown`; hosts can now stay quiet about
  it. A refusal that may lift soon keeps the catch-all.
- A missing mnemonic while scanning or sealing the private store maps to
  the key-unavailable outcome (31), through the signer's position-0
  marker in a `ProtocolError::Generic`, matched by variant; a test drives
  it through the resolver-backed provider.
- The seal passes `platform_version`.
- The private store sync runs on `run_on_big_stack_thread`: the SDK's
  identity fetch stream trips rust-lang/rust#100013 against
  `block_on_worker`'s `Send + 'static` bounds.

Both codes are in `ERROR_CODE_REGISTRY.md`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…type codes 69 and 70

- `sendPrivateContactRequest`, `acceptPrivateContactRequest` and
  `acceptContactRequestReplyingInKind` are admitted with `admitNativeOp`
  and finished with `finishNativeOp`, so a teardown waits for them as it
  does for the other counted manager calls.
- `PlatformWalletError.shieldedBuildRequired` (69) and
  `.protocolVersionNotActive` (70), with `ErrorHandlingTests` pins.
- The in-kind accept's docs say a public or adopted reply needs neither
  shielded support nor a payment mnemonic read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and report private refresh failures

- A private send or an in-kind accept whose result could not be confirmed
  (`shieldedSpendUnconfirmed`) stays pending, as `SendViewModel` treats an
  unconfirmed spend: Add Contact shows a Done-only notice and no longer
  enables Send, and the request row keeps its pending state without its
  buttons until the next sync settles it.
- The request list's private refresh stays quiet when the network does not
  serve the private store yet (code 70), and shows any other failure, and
  requests read but not saved, in a notice above the list.
- `ContactRequestSubmissionOutcome` and `PrivateContactRefreshNotice` hold
  those two readings, pinned by `ContactRequestOutcomesTests`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he private type

Review fixes:

- A signing key past its `expires_at` signs nothing: the reader checks it
  at the entry's block time with dpp's `is_expired_at` (the expiry instant
  is expired), and the key list and the seal check it at this device's
  clock, so an expired key is not offered.
- `private_document_create_from_shielded_pool` establishes protocol
  version 15 before building the transition, which an older SDK's limits
  would refuse for any entries.
- A DashPay the context provider holds from before the private type is
  refetched from the network and registered with the provider, which the
  page's proof is verified against.
- The reader refuses an identity given under another identity's id, and a
  recipient key id that is not the key the entry is encrypted to.
- The identity fetch iterates owned pairs, so its future is `Send`
  (rust-lang/rust#100013), and its retry logic is a function tests drive
  with scripted proofs.
- An OS RNG failure is an error, not a panic.
- The wasm-sdk contract fixture gives its indexed `rarity` string the
  `maxLength` dpp's now enabled validation requires of an indexed string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ne at a time

Review fixes (with the SDK changes of #5809 merged in):

- A paid private request is recorded only as the store proves it: the
  type 29 proof shows the notes spent, not the entry appended, so a send
  reads the store back with a short private sync and returns the request
  recorded from it. Not read back yet, it returns the new
  `PrivateContactRequestPending`.
- One private request to a recipient is in flight at a time: reserved
  before sealing, it keeps a retried or concurrent send or accept from
  paying again (`PrivateContactRequestPending`) while it is being paid
  for, while its payment is unconfirmed, and until a sync reads its entry
  back, which settles it. A payment that definitely failed releases it.
  Pending requests are kept in memory.
- The keep-in-memory recording of a paid request whose store failed is
  gone: nothing is recorded outside the sync, whose failed ingest holds the
  identity's cursor while the request stays pending.
- Collapsing a sweep's requests orders by the reference's version first,
  as passing over older requests does (`freshness`), so a rotation and a
  later replay of the request it rotated past collapse to the rotation.
- A cursor rewound under a pass (unignoring a sender) is not advanced again
  that pass.
- The recipients of recovered sent requests are proved at or after their
  entries, each fetched on its own: one that cannot be read holds back its
  senders' cursors rather than failing the pass or skipping the request.
- An OS RNG failure is an error, not a panic.
- The public send documents that its privacy refusal needs the private
  sync to have read the store on a restored device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…code and read it per contact

- `PlatformWalletError::PrivateContactRequestPending` maps to the new
  `ErrorPrivateContactRequestPending` (71), both in the blanket conversion
  and in the shielded spend contract the private send and accepts use,
  which would otherwise flatten it to `ErrorWalletOperation`. A private
  request to that contact is in flight or paid and not yet read back;
  nothing new was paid, and a later private sync settles it. Recorded in
  `ERROR_CODE_REGISTRY.md`.
- `managed_identity_get_private_reply_pending(identity_handle, contact_id,
  out_pending)` is a pure read of `DashPayState::private_reply_pending`, so
  hosts disable a second send or accept from wallet-owned state. It is a
  getter rather than a `PrivateContactFFI` field because the latter
  answers NotFound until a request has come or gone privately, while a
  first private send is already pending.
- The private store sync runs on `block_on_worker` again: the SDK's
  identity fetch future is `Send` now, so the big-stack thread is no
  longer needed (`block_on_worker` already polls on the 8 MB worker stack).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er contact

- `PlatformWalletError.privateContactRequestPending` (71), with an
  `ErrorHandlingTests` pin. The private send, private accept and in-kind
  accept docs name it.
- `ManagedIdentity.privateReplyPending(contactId:)` wraps
  `managed_identity_get_private_reply_pending`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The request rows and Add Contact's Send read whether the wallet has a
private request to the contact in flight or not yet read back
(`privateRepliesPending`, over `ManagedIdentity.privateReplyPending`),
instead of keeping an unconfirmed accept in screen-local state. While it is
set, the row shows pending without its buttons and Send is off; it clears
when a private sync settles it, and it holds when the screen is left and
reopened. The views re-read it on appear, after an accept or send, and
after each sync pass.

`privateContactRequestPending` (71) is the same outcome as an unconfirmed
spend: pending, no fresh submission. `ContactRequestOutcomesTests` covers
it and the row message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the refreshed DashPay

A private contact request's encryption keys, the sender's and the recipient's,
are now used only inside their contract bounds: unbound, bound to DashPay, or
bound to DashPay's contactRequest or privateContactRequest. contactRequest is
allowed because DashPay requires bound keys for it and apps register their
DashPay keys bound to it. Keys bound to another contract, another DashPay type
or a contract group are refused by the seal and the reader.

dpp decides the document-type scope once, in ContractBounds::check_document_type
(Allowed, Denied, or needing the group's members, which a client refuses). The
signing key check and encrypted_for's key check use it too.

After registering a refreshed DashPay, the helper reads it back from the
context provider and fails if the provider still lacks the private type, as
every page's proof would. The reader's docs no longer claim a key was the
identity's when the entry was appended.

Tests: encryption key bounds for both roles in the seal and the reader; the
DashPay refresh on an unpinned SDK, the already-current shortcut, and a
refresh the provider does not take; a send on an unpinned SDK learns the
network's version before it builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pending, and report only recorded requests

A public `contactRequest` to a recipient a private request is still pending
to is refused with `PrivateContactRequestPending`: that private request may
already be on chain, and the document would publish the pair.

`PrivateContactSyncReport::requests` now lists the requests the pass
recorded, received and sent, instead of every candidate the page opened:
`ingest_sent_requests` reports what it recorded, as the received ingest
already did, so an ignored sender's request, a replay older than the tracked
one, one already tracked, or one whose store failed is not reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QuantumExplorer and others added 4 commits November 10, 2026 19:44
… the single-dispatch broadcast and the spend contract

Since #5556 the SDK puts a broadcast that may have been delivered under
Error::StateTransitionResultUnconfirmed, a duplicate answer included
(a gRPC AlreadyExists is classified as possibly accepted). The wallet's
single-dispatch broadcast read only a bare AlreadyExists as admitted, so
a node already holding the transition now read as maybe delivered and
the same bytes went on to the rest of the budget. A node holding the
transition, bare or under the unconfirmed or retry envelope, is admitted
again. The envelope is maybe delivered whatever it wraps, never a
refusal before anything was sent, and the identity shield never reads
it as a refusal.

The FFI's shielded spend contract (map_spend_result) flattened an
unclassified SDK unconfirmed outcome to ErrorWalletOperation (6), which
reads as a failure; it now keeps the blanket conversion's
ErrorTransactionBroadcastUnconfirmed (20).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns in the registry

#5556 routes the SDK's unconfirmed state transition outcome to
ErrorTransactionBroadcastUnconfirmed (20), and the shielded spend
contract keeps it there; the registry row now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tact-requests' into feat/ffi-swift-private-contact-requests

Brings in the wallet at 207bd72: #5837 merged into it, v5.1-dev
(#5556, #5823 and others), and the spend contract keeping code 20.

Conflicts:
- shielded_send.rs: the wallet's catch_spend_panic doc is taken, and the
  function stays pub(crate), as the private contact request exports call
  it.
- PlatformWalletResult.swift: the wallet's docs for 16 and 18 (the result
  code and the typed error) are taken. They are newer: a note spend's
  verdict and a shield's are both 18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…act requests and shielded sends

Since the wallet's spend contract keeps code 20 (an SDK outcome left
unconfirmed after a possible acceptance), a private send or accept can
return it, and a public one through the blanket conversion. So can a
shielded transfer, unshield or withdrawal. Both screens showed it as an
ordinary, retryable failure.

- ContactRequestSubmissionOutcome maps `transactionBroadcastUnconfirmed`
  to mayHaveGoneThrough, as it maps 18.
- SendViewModel's shielded path reads both 18 and 20
  (`SendViewModel.mayHaveGoneThrough`) as "may have gone through, do not
  retry".
- The private send and accept docs name 20.
- Tests:
  - testShouldKeepAnUnconfirmedStateTransitionPending
  - testShouldNotOfferARetryForAShieldedOperationThatMayHaveGoneThrough

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@thepastaclaw 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 head still contains two blocking lifecycle and recovery defects. Public unconfirmed accepts are re-enabled without a durable hold, and the optional-network persistence configuration can silently miss retained recovery records for non-testnet wallets; the supplied CI snapshot has no failed code checks, but Rust and Swift validation are not independently reported.

🔴 2 blocking

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — This is a large, intricate change that modifies cryptographic key handling and signing, shielded funds movement, private request scanning, persistence, and Swift/Rust FFI boundaries in functions such as seal_private_contact_request and the private send/accept exports.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); 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)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review (PastaClaw). How to work through them:
- Verify each finding against the code; fix only what is real, as narrowly as the finding.
- If a finding is wrong, decline it on its thread in one or two sentences; the bot answers once and stops.
- To defer one, link a tracking issue on its thread (or reply asking the bot to file one).
- Suggestions are optional: fix them forward in a later PR rather than pushing another round just for them.
- Address every current finding in one push: self-review the full diff first, and don't push piecemeal.
- Keep the PR's scope; don't grow it to satisfy hardening suggestions.
- Carried items were raised in an earlier round: read the thread first, and don't re-fix what you already fixed, declined or deferred there. Disputed and deferred items are already handled and are not listed.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift`:
- [BLOCKING] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift:336-340: Keep public unconfirmed accepts pending until their outcome is reconciled
  This branch now treats code 20 as may-have-gone-through for both private and public replies, but only private payments create the wallet-owned pair hold that `privatePairHeld` reads. The native public path releases its `PublicContactClaim` on every return, while an SDK unconfirmed error exits before `add_sent_contact_request`, so the subsequent `reloadPendingReplies()` sees no hold. Once the task's `inFlightIds` defer runs, the row offers Accept and Ignore again even though the first public reply may still execute; another accept can therefore build and submit a second transition. Preserve an unresolved public-submission association in the owning wallet and expose it to this control state, or otherwise keep the row non-actionable until the first outcome is reconciled. Add a regression covering the actual public accept, reload, and retry sequence.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:5285-5301: Do not interpret an unbound persistence network as testnet during recovery
  The raw-pointer `configure(sdkPointer:modelContainer:network:)` API and the persistence handler both allow `network` to be nil. However, `loadPrivateContactScanStateWalletIds()` converts nil to `.testnet`, while `scanStateNetworkRaw(walletId:)` also falls back to testnet when the wallet row is gone. A retained unresolved position for a purged mainnet or devnet wallet was stored under its actual network, so the wallet-list callback returns successful absence and the per-wallet callback searches the wrong namespace. Rust consequently receives no retained marker and can restore the identity without the hold that prevents another private payment. Require a real network before advertising this recovery capability, or query/preserve the stored network scope in both callbacks; do not report successful absence for an unknown scope. Add a regression using the default nil-network raw-pointer configuration with a retained non-testnet record after wallet removal.

QuantumExplorer and others added 13 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>
…k when the handler has none

A handler configured with no network, as configure(sdkPointer:
modelContainer:network:) makes by default, guessed testnet in two places:
the wallet list, and the per-wallet load once the wallet's row was gone.
A marked position record kept for a removed mainnet or devnet wallet is
stored under its real network, so both reads came back empty. Rust then
got no retained marker and could restore the identity without its hold.

- The wallet list and the per-wallet load filter on the handler's network
  when it has one, else on the wallet row's.
- When neither is known, they read the wallet's rows under every stored
  network instead of guessing one, so a kept record never reads as absent.
- A scan state write with no network known refuses, rather than filing the
  state under a guessed network.
- Tests:
  - testShouldHandBackAKeptMainnetRecordToAHandlerWithNoNetwork: a
    nil-network handler, a mainnet wallet's marked record, and the
    wallet's removal; the record comes back through both reads and their
    C shims, and a testnet-scoped handler still does not see it.
  - testShouldRefuseToStoreScanStateWithNoNetworkToStoreItUnder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eration

A handle taken before its wallet was removed and created again under the
same id could still act on the replacement: its reservation's release and
drop stored a private payment marker for the replacement's identity, its
managed-identity steps reserved on the replacement, and a public claim
could be taken there. Each such step now acts only while the wallet is
still the handle's generation: a send is refused with
WalletRemovedBeforeSend before anything is paid, and cleanup is a no-op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… once durable

A restoration discharged a journal record whose request the wallet tracks
even when that request was stored but not yet flushed. A backend that does
not commit inline is now flushed first, and a failed flush leaves the
record for a later restoration. A record whose read-back still waits for
its flush is left to that path, which a restoration now retries first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README said shields from Platform addresses and from an identity both
keep their own handling. A shield from Platform addresses now broadcasts in
single dispatches and reports ShieldedSpendUnconfirmed (FFI code 18) once a
dispatch may have reached a node; only the identity-funded shield keeps its
own handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TransitionOutcome::ExecutedOrSuperseded now carries consumed_by_height,
the lowest height of a read that showed one of the transition's inputs
consumed: the transition cannot execute after it. A caller that knows
what the transition would have written can then read it at or above that
height and tell a transition that never wrote it from one that did. A
single-nonce transition, such as a document create, is never
CannotExecute (that needs one input taken while another is free), so this
is the only proof that one will not write its document.

The outcome API is unreleased (#5823), so the variant changes in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sdk::sign_contact_request returns the signed contactRequest create
transition (SignedContactRequest), so a caller can keep the bytes and
broadcast the same transition again on a retry instead of signing a
second request under a new identity contract nonce.
Sdk::send_contact_request is now sign, broadcast and wait for the same
transition, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unconfirmed

A public contact request (a send, an accept, the in-kind accept's public
reply, a QR send, an auto-accept, a rotation) released its pair however
its send ended. After a broadcast that may have been delivered, the
SDK's unconfirmed outcome left nothing holding the pair, so a second
accept signed and submitted a second transition while the first could
still execute.

The send now signs, keeps the signed transition, and broadcasts it by the
rule shielded spends use. Once a dispatch may have reached an endpoint
and the document is not confirmed, the pair is held by the wallet as an
unconfirmed public request, bound to the wallet generation, and the host
still gets the SDK's unconfirmed outcome (FFI 20). While it stands, a
public or private request or accept to the pair is refused with
PublicContactRequestUnconfirmed, with nothing signed or paid, and the
sender identity cannot be removed. DashPayView::public_contact_request_unconfirmed
reads it per pair.

The public contact request sync, and each send or accept to the pair,
settles it from proved reads: the request found by DashPay's unique index
is recorded as sent; its identity contract nonce proved used, with the
request proved absent at or above that height, frees the pair; otherwise
the same signed bytes are broadcast again. Before signing, a send adopts
a request already on Platform that it would repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…request

PlatformWalletError::PublicContactRequestUnconfirmed maps to
ErrorPublicContactRequestUnconfirmed (76) in the blanket conversion and in
map_spend_result, which the private send and accepts use, so a host can
tell a public request awaiting its outcome from a private request pending
(71).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tact-requests' into feat/ffi-swift-private-contact-requests

Brings in the wallet at 54ae054: the wallet-owned hold for an
unconfirmed public contact request (code 76), the contact request cleanup
fenced to the wallet generation, and a settled private payment record
discharged only once durable.

The one conflict was ERROR_CODE_REGISTRY.md. It keeps this branch's rows
68 to 75 and adds the wallet's row 76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts its outcome

platform_wallet_get_public_contact_request_unconfirmed exposes
DashPayView::public_contact_request_unconfirmed per pair, as
platform_wallet_get_private_pair_held does for the private hold: it reads
outside the registry guard, answers false for a free pair, NotFound for an
unknown wallet and ErrorIdentityNotFound (7) for a sender the wallet does not
hold. A host reads it beside the private hold, so a public send or accept that
returned 20 is shown as awaiting its outcome rather than offered again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PlatformWalletResultCode.errorPublicContactRequestUnconfirmed (76) maps to
PlatformWalletError.publicContactRequestUnconfirmed, apart from a private
request pending (71), and ManagedPlatformWallet.publicContactRequestUnconfirmed
wraps the new read. ErrorHandlingTests pins the code; the registry row names
the export and the Swift types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ugh until the wallet settles it

The request list and Add Contact read only the private pair hold, so after a
public accept or send that returned 20 the reload found nothing held and
offered Accept or Send again while the first one could still execute. They
now read both of the wallet's holds per contact (contactRequestHolds): while
either holds the pair, no request or accept is offered, and a public hold
shows "This request may have gone through; it is being checked." A 20 or a
76 from a send or accept shows the same at once and starts a DashPay sync,
never a retry. The request list's pull-to-refresh runs that sync, which
settles the hold first, and Add Contact gets a "Check again" button that does
the same; both read again whenever a DashPay sync pass ends. The invitation
claim's contact request reads 18, 20 and 76 the same way instead of as a
failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QuantumExplorer added a commit that referenced this pull request Nov 10, 2026
…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>
Base automatically changed from feat/platform-wallet-private-contact-requests to v5.1-dev November 10, 2026 17:00
@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
QuantumExplorer and others added 2 commits November 11, 2026 00:12
#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>
@github-actions

github-actions Bot commented Nov 10, 2026 •

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 6915734 into v5.1-dev Nov 10, 2026
39 of 42 checks passed
@QuantumExplorer
QuantumExplorer deleted the feat/ffi-swift-private-contact-requests branch November 10, 2026 17:40
QuantumExplorer added a commit that referenced this pull request 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>
shumkov added a commit that referenced this pull request Nov 11, 2026
Conflict in SwiftExampleApp's WalletManagerStore.swift against #5812
(private contact requests and the shielded store):
- The warm-cache check keeps this branch's SDK identity (`managerSdks`,
  which v5.1-dev still keys by handle) and takes #5812's retry of a
  shielded store that failed to open.
- A new manager opens its shielded store after the replaced manager's
  shutdown, which releases the same store path, and before its wallets
  are restored, as #5812 requires.
- Both helpers are kept: this branch's `restoreWallets` and #5812's
  `configureShieldedStore`, `shieldedStoreFailure` and
  `configureShieldedAgain`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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