Skip to content

feat(platform-wallet): send, receive and accept DashPay private contact requests - #5810

Merged
QuantumExplorer merged 196 commits into
v5.1-devfrom
feat/platform-wallet-private-contact-requests
Nov 10, 2026
Merged

QuantumExplorer merged 196 commits into
v5.1-devfrom
feat/platform-wallet-private-contact-requests

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Basic explanation

What this does: the wallet can now send, receive and accept DashPay contact requests that nobody else can see.

  • A private request is sealed to the recipient's DashPay key and paid from the wallet's shielded (Orchard) notes, so nothing on chain names either identity.
  • It is signed with the identity signer the wallet signs public requests with.
  • The wallet reads DashPay's private store, keeps the requests addressed to its identities after checking each sender, and recovers the ones it sent after a restore from seed.
  • Accepting a private request replies privately. A contact whose requests came or went privately is never sent a public request.
  • Before any contact request or acceptance, public or private, the wallet reads the private store to its end, so a pair that went privately is never followed or answered publicly.
  • A private payment is recorded durably before it can reach the network, and the record is kept until something proves the payment resolved. A crash or restart then does not lead the wallet to pay for the pair again or to publish it; the limits are listed under Costs.

Value: private requests make DashPay contacts without publishing who knows whom. Once received, a private request establishes a contact and gets its payment accounts exactly as a public one does, so payments, rotation and restore keep working.

Risks: medium.

  • Client code only, no consensus change.
  • Every shielded spend that consumes notes now saves its recovery record before its first broadcast and reports a broadcast that may have been delivered as unconfirmed (FFI code 18), keeping its notes. On v5.1-dev, an endpoint's verdict and some transport failures after a dispatch reported failed (16) and released them, and most note spends saved their record only after an unconfirmed result. Hosts must not offer a retry with other notes on 18.
  • Shields from Platform addresses, to the wallet's own notes or to a recipient, and masternode credit withdrawals also report an unproved outcome as unconfirmed (18 for a shield, where it reported 21 or 16; 42 for a withdrawal), as a retry would sign a second transition with the next nonces.
  • A public contact request (a send, an accept, a QR send, an auto-accept, a rotation) whose broadcast may have been delivered and is not confirmed now holds its pair as an unconfirmed public request until a proved read settles it. Until then requests to the pair are refused with 76. An endpoint's verdict on a public request now reports the SDK's unconfirmed outcome (20) and holds the pair, where it reported the verdict and released it. Every public request makes one more proved read, of the request it would repeat, before it signs.
  • A private payment that never lands holds its pair until it is proven expired: up to about 1100 blocks after its anchor was recorded, longer on an idle pool.
  • A host whose persister keeps the private scan state gets code 72 on every contact request until it configures the shielded store, so it has to configure the store when the manager starts.
  • A few damaged-storage states keep contact requests refused until the wallet is removed and added again (listed under Costs).
  • Every contact request now starts with a proved read of the private store from the identity's cursor and a proved fetch of the identity.
  • The document send, accept and sync were refactored to share code with the private ones.
  • Deliberate behaviour changes:
    • the public send refuses a contact whose requests came or went privately, which also refuses a public accept that would broadcast a reply;
    • ingest, public and private, passes over a request older than the one tracked with the same contact. Public documents arrive in time order, so this only acts on the private store and on replays.

Issue being fixed or feature implemented

Part of anonymous DashPay contact requests for 5.1. It builds on the SDK calls in #5809 (send, scan and read private contact requests). Sam's decisions applied here:

  • accepting a private request replies with a private request;
  • the outgoing viewing key comes from the sender identity's DashPay encryption key;
  • the request is signed by the sender's signing key, as a public request is.

Known limit, tracked in #4270. Platform cannot yet prove to a client whether a state transition executed, or that it can no longer execute. Until it can, the wallet infers the outcome of a transition whose broadcast was not confirmed: it keeps the payment's notes reserved and the contact pair held, and releases them only on evidence it can verify itself (the entry read back from the store, the request proved on Platform, or the spend's anchor proved pruned). That is why a payment that never lands holds its pair for up to about 1100 blocks, and why this PR carries a payment journal, expiry evidence and several holds.

This handling is interim. It is meant to be replaced by a proof of execution or non-execution for each transition: #4270 describes the gap in the proofs, #5822 states what the wallet needs from it, #5823 is a first step, and #5269 covers a node's unproved view of a transition.

What was done?

Sending: PlatformWallet::send_private_contact_request(coordinator, seed, account, sender, recipient, label, auto_accept_proof, shielded_address, signer, crypto, prover)

  • It picks the keys, our receiving xpub for the pair, the masked account reference and the auto-accept proof exactly as a document send does. Those steps now live in DashPayView::prepare_contact_request, shared by both sends. A later private request to the same recipient rotates past this one; a later public one is refused (68).
  • signer signs the request with the first of the sender's keys it holds among private_contact_request_signing_keys: enabled, unexpired AUTHENTICATION keys of a signing key type, at a level DashPay's documents are signed at (HIGH or CRITICAL), bound to no other contract or document type.
  • The crypto provider seals it. It keeps the encryption key, which the outgoing viewing key comes from.
  • The pair's shared key is computed before paying, so nothing that can fail is left after the payment.
  • Before anything is paid, the sender's private scan state is stored with a marker saying a private payment may be unresolved. The payment's journal record and its redrive record are then saved in one durable write (see below).
  • The new shielded operation private_document_create_from_pool_with_metadata appends the entry with a type 29 transition paid from the account's notes:
    • new ShieldedFeeKind::PrivateDocumentCreate { entry_count, entry_size };
    • notes are reserved for the fee alone (amount 0);
    • it records as an own spend with its fee.
  • The type 29 proof shows the notes spent, not the entry appended. So after paying, the send reads the store back with a short private sync and returns the request recorded from it, then registers our receiving account for the contact. An entry not read back yet returns PrivateContactRequestPending (71) and the request stays pending.
send_private_contact_request(Alice -> Bob, label "Alice", shielded address A)
  chain: one 589-byte entry in DashPay's privateContactRequest store; the fee for 2 actions
         is 114,140,000 + (589 + 32) x 27,400 + 10,000,000 = 141,155,400 credits
  Alice's wallet: sent request to Bob (encryption key 1, recipient key 2, signed by key 0),
                  core height 0

One private request in flight per recipient. It is reserved before sealing. A retried or concurrent send or accept gets PrivateContactRequestPending (71) instead of paying again, and so does a public request to that recipient.

  • Each reservation belongs to the attempt that made it: an older attempt's cleanup can't release a newer one's.
  • Reservations, the markers stored for them and public claims are bound to the wallet generation. A handle taken before its wallet was removed and created again under its id refuses to send (WalletRemovedBeforeSend, 98) before anything is paid, and its release and drop cleanup act on nothing of the new wallet.
  • A send dropped before its payment started releases its reservation, and so does a payment whose transition provably never left the wallet. Any other outcome keeps it until the entry is read back or the payment is proven expired.
  • A request whose account reference would repeat the tracked one, once all 16 rotation versions are used, is refused before anything is signed or paid.

Receiving: DashPayView::sync_private_contact_requests(crypto, max_pages)

  • It reads the store from the earliest of the identities' cursors, several pages at a time.
  • The provider opens the pages. The wallet fetches, proved, the identities the entries name (the senders, and the wallet's identities they are to or from) and the recipient of each sent request, each at or above the height of the entries it checks. It then checks each entry against them (private_contact_requests_from_scan). That needs no secret key.
  • A recipient that can't be read holds back the cursors of its senders instead of skipping the request.
  • Received requests and sent ones (recovered with the outgoing viewing keys) are ingested with the same functions the document sync uses: newest per sender, rotation, auto-establish against a matching sent request.
  • Account builds go through the same candidate collection and builds as the document sync.
  • An identity's cursor advances only when its ingest reached disk. A pass that left one behind does not report itself complete. A cursor rewound mid-pass (unignoring a sender) stays rewound for that pass.
  • PrivateContactSyncReport::requests holds what the ingests recorded. Not included: a request already tracked, an ignored sender's, a replay, or one whose store failed.

How a private request is recorded: as a ContactRequest.

  • The entry carries the compact xpub and label in the clear. The wallet encrypts them under the pair's DIP-15 shared key, as a contactRequest document carries them, with IVs taken from the entry's commitment. So:
    • the existing decryption drain registers the contact's account unchanged;
    • a rescan rebuilds the same bytes, so the wallet's comparisons of a contact's encrypted xpub hold;
    • the contact request rows keep their shape. What this PR adds on disk is listed under Breaking Changes.
  • The record's core height is 0. DashPay requires every contactRequest document to carry its Platform-assigned $createdAtCoreBlockHeight, and no Core chain puts that at 0. So ContactRequest::came_privately() reads the channel from the record itself, and it holds across restarts. Our receiving account for the contact is scanned from the wallet's birth.
  • DashPayState::private_contact(contact) derives received_privately and sent_privately from the tracked requests. The contact's shielded address is taken from the request tracked from that contact. It is persisted for a backend that keeps the private scan state; otherwise the scan a cold start runs rebuilds it.
Bob syncs; the store holds Alice's entry
  before: no way to read it
  after:  Bob's incoming requests: Alice (account reference r, created at the block's time, core height 0);
          private_contact(Alice) = { received_privately, shielded_address: A }; cursor 1
Bob restarts
  private_contact(Alice) still says received_privately: it comes from the stored record

Accepting: PlatformWallet::accept_private_contact_request(coordinator, seed, account, request, shielded_address, signer, crypto, prover)

  • It replies privately, then registers the contact's account as the document accept does.
  • The accept body is now accept_contact_request_replying_with, shared by both accepts.
  • One accept for hosts: accept_contact_request_replying_in_kind, on DashPayView and on PlatformWallet with shielded, replies the way a request came.
    • It replies privately when the request came privately and no reply exists yet. Otherwise it replies with a document, or adopts the reply already sent. Hosts do not catch 68 and choose.
    • PlatformWallet's version takes what a private reply is paid with as a closure (PrivateReplyPayment), called only for a private reply. A public or adopted reply therefore needs neither shielded support nor the seed.
Bob accepts Alice's private request
  accept_contact_request_with_external_signer:
    before: would publish a contactRequest document naming Alice and Bob
    after:  Err(PrivateContactRequestNeedsPrivateReply(Alice))
  accept_private_contact_request: a private reply; Alice established; Bob's account paying her registered
Alice, who sent privately, tries a public request to Bob
  send_contact_request_with_external_signer: Err(PrivateContactRequestNeedsPrivateReply(Bob))

Older requests are no rotation. ingest_received_requests and ingest_sent_requests pass over a request older than the one tracked with the same contact. Older is compared by the account reference's version, which a rotation bumps, then by creation time. Collapsing a sweep orders the same way. A read from the store's start (a cold start without a persisted read position, or a read rewound after a key change) could otherwise replay a request that a public one already superseded. The same goes for an old request appended to the store again.

tracked from Carol: version 1, created at 100
  Carol's version 0 request, appended again at 200 -> passed over (before: taken as a rotation back)
  Carol's version 2 request, created at 300        -> rotation, as before

Auto-accept. A request that came privately carries no auto-accept proof into the wallet. Accepting it pays from the shielded pool, which the unattended auto-accept drain cannot do, so it is accepted by hand.

Crypto provider. ContactCryptoProvider gains three methods:

  • scan_private_document_page;
  • scan_private_document_pages, which sorts several pages with one key derivation and defaults to one scan_private_document_page call per page;
  • seal_private_contact_request, which takes the signature from the identity signer.

They take Rust-built key paths (PrivateDocumentKeyPaths), so the scalars never leave the signer. scan_private_document_page and seal_private_contact_request default to an "unsupported" error, so existing providers compile unchanged. The in-crate SeedCryptoProvider implements them. So does the FFI's ResolverContactCryptoProvider, through MnemonicResolverCoreSigner in rs-sdk-ffi, which derives the keys with the host's mnemonic resolver. The exports that send and accept private requests come with the FFI PR.

SDK. Sdk::sign_contact_request returns a signed contactRequest create (SignedContactRequest) without broadcasting it, so the wallet keeps the bytes and a retry broadcasts the same transition; Sdk::send_contact_request is now sign, broadcast and wait, as before. TransitionOutcome::ExecutedOrSuperseded carries consumed_by_height, the lowest height a read showed one of the transition's inputs consumed, after which it cannot execute (the outcome API, #5823, is unreleased). Sdk::private_document_pages_with_heights yields each page with the lowest and highest heights its reads were proved at (PrivateDocumentPageHeights). The mock SDK can answer each query at a set height, which the tests use for mixed-height pages.

Unignoring a sender rewinds the private cursor, as it rewinds the document cursor, so the skipped requests are read again.

Refactor. The document sync's teardown of stale external accounts moved into tear_down_stale_external_accounts, which both syncs call. The cursor advance moved ahead of it. That is behaviour-neutral, as all of it runs under one lock.

Private payments, holds and the store read

The private payment journal. Before a private request's payment can reach the network, the shielded store saves a journal record of it. The record and the spend's redrive record go in one durable write (SQLite synchronous=FULL with fullfsync). A record that cannot be saved refuses the payment.

The record keeps:

  • the sender and recipient identities and the request's account reference;
  • the entry's commitment and the SHA-256 of the whole entry;
  • the sender's read position;
  • the anchor and its proved height;
  • the spend's nullifiers.

No note release, redrive removal, account rebind or purge removes a record. A record is released only on proof:

  • its entry was read back from the store and the request recorded durably;
  • the transition provably never left the wallet (refused before anything was sent: no address to send it to, or the SDK's own refusal);
  • the payment is proven expired. Its anchor is absent from Platform's recorded anchors proved at a height H, at or above the height the anchor was read at, the note scan covers H, and no entry hashing to the record's hash is in the store. That store read starts at or before the position the record kept and runs to the end, with every page proved at or above H;
  • a restoration finds the request already recorded, as an earlier process read it back without discharging it, once that request is durable: a backend that commits inline stored it before tracking it; any other is flushed first, and a failed flush leaves the record. A record whose read-back still waits for its flush is left to that path, which the restoration retries first.

After a flush of the wallet's records failed for good, nothing is released on the strength of a tracked request for the rest of the process: no read-back is noted, a read of the entry again included, and a restoration leaves the records it finds settled. The tracked request may then be in memory only. A restoration that finds a record whose request its sender does not track rewinds the sender's read to the position the record kept, so the next process reads the entry again, records the request durably and releases the record.

An endpoint's answer, a consensus verdict included, proves nothing and releases nothing: no proof or quorum signature backs it.

Broadcast outcomes. Every shielded spend that consumes notes (transfers, unshields, withdrawals, identity top-ups and creations from the pool, and private document creations), every shield from Platform addresses (to the wallet's own notes or to a recipient) and every masternode credit withdrawal broadcasts in single dispatches and keeps what each one showed.

  • A dispatch that reached an endpoint, whatever it answered, or failed in transport, makes the broadcast possibly delivered, and so does the SDK's own unconfirmed outcome (StateTransitionResultUnconfirmed), whatever it wraps. A node's answer that it already holds the transition (AlreadyExists, bare or inside that outcome) counts as admitted. The transition then waits for its result.
  • An endpoint's verdict ends the dispatches, except a nonce-ahead answer from a node behind the one the nonce was read from: the same signed bytes go to the next dispatch. The SDK's own shared broadcast ends on every concrete refusal, that answer included.
  • Only a transition refused before anything was sent (no address to send it to, or the SDK's own refusal) is failed: ShieldedBroadcastFailed (16) for a note spend, its notes released, or for a shield; an ordinary, retryable error for a withdrawal.
  • A note spend saves its recovery record before its first broadcast (for a private contact request payment, with its journal record), so a process that exits during the dispatches or the result wait restores the notes' reservation after a restart. The record is retired only when the transition never left the wallet. If the wait does not prove the outcome, the spend reports ShieldedSpendUnconfirmed (18), its notes and record kept until it lands or its anchor is pruned; an identity creation reports ShieldedBroadcastUnconfirmed (17) instead.
  • A shield from Platform addresses reports 18 for every outcome short of a verified result that reads its inputs spent, an endpoint's refusal or verdict included. Its activity row stays pending until the scan sees its note. It reserves no notes and keeps no record.
  • A masternode credit withdrawal reports MasternodeWithdrawalUnconfirmed (42) for every outcome short of the proved remaining balance.
  • A shield from an identity keeps the SDK's broadcast and its durable guard on the signed transition; a refusal only makes it read the identity's nonce, proved, instead of waiting for the result.

Public requests whose outcome is unconfirmed. Every public contactRequest (a send, a public accept, the in-kind accept's public reply, a QR send, an auto-accept, a rotation) goes through one send, which signs, keeps the signed transition and broadcasts it by the rule above.

  • Before signing, it reads the request it would make by DashPay's ownerIdUserIdAndAccountRef unique index (sender, recipient, account reference), proved. One found, sent by a process that ended before recording it or by another device, is adopted as sent with nothing signed. A read that fails sends on.
  • Only a refusal before anything was sent reports its error with nothing held. Otherwise the send waits for the result (not after an endpoint's verdict) and records a confirmed request; anything else returns the SDK's unconfirmed outcome (20) and holds the pair as an unconfirmed public request, bound to the wallet generation, in memory.
  • While it stands, a public or private request or accept to the pair is refused with PublicContactRequestUnconfirmed (76), nothing signed or paid, and the sender cannot be removed.
  • The public contact request sync, and each send or accept to the pair, settles it: Sdk::state_transition_outcome reads the transition's identity contract nonce, then the unique index is read. A request found is recorded as sent and frees the pair. With none found, the nonce proved used at a height at or below the read that missed the request frees the pair. Otherwise the same signed bytes are broadcast again. No retry signs a second transition. A single-nonce transition is never CannotExecute, so the nonce-used-and-absent case is what frees a request that will not land.
  • Nothing is persisted. A fresh process reads the nonce from Platform, so an unexecuted earlier request and a new one share a nonce unless another DashPay transition of the sender executed in between (Platform keeps the earlier nonce open as a missing revision, up to 24 back). What keeps a second request off Platform then is the unique index: a request built from the same recorded requests carries the same account reference, Platform writes at most one, and the read before signing adopts it. A restart does not hold back a private request to the pair while the earlier public one may still execute.
Bob accepts Alice's public request; the broadcast reaches a node and the result wait times out
  before: Err(Sdk(StateTransitionResultUnconfirmed)) 20, the pair free; the app reloads, offers
          Accept again, and a second accept signs and submits a second transition
  after:  the same 20; public_contact_request_unconfirmed(Bob, Alice) = true,
          private_pair_held(Bob, Alice) = false
          a second accept: Err(PublicContactRequestUnconfirmed(Alice)) 76, nothing signed
A contact request sync that cannot read the request: still held
  the request proved on Platform:                 recorded as sent, the pair free
  its nonce proved used, no request at or above:  the pair free, a fresh accept signs again
  neither:                                        the same bytes broadcast again, still held
The app restarts while the request may still execute, and the request lands
  a new accept or send reads it by the unique index first and adopts it: nothing signed

What holds contact requests after a restart, and what lifts it.

Hold Code Set when Lifted by
Wallet (shielded build) 72 It was registered or loaded while no shielded store was configured and its persister keeps the private scan state; or it was loaded after binding a shielded account in an earlier process; or its last restoration failed or waits for a busy store A restoration from the journal: the first configure_shielded, a bind, or the one each contact request runs first without waiting on the store
Identity (every build) 72 It was loaded with its scan state marking a private payment that may be unresolved; or it was added to a wallet registered or loaded under an id whose record the persister kept marked after the wallet's removal A restoration from the journal; never in a build without shielded
Pair 71 A private request to the recipient is reserved, or a journal record holds the pair, whatever happened to the sender identity since The record's release above
Pair (public, in memory) 76 A public request's broadcast may have been delivered and is not confirmed Its request proved on Platform (recorded as sent), or its nonce proved used with no request at or above that height; a restart, after which the read before signing adopts a request on Platform
  • The first configure_shielded restores every held wallet, and every wallet whose payments the journal may keep.
  • A wallet registered or loaded later is restored as it is published.
  • A repeat call never waits for a store a scan holds: it restores only the wallets still held, deferring each while the store is busy.
  • PlatformWallet::restore_pending_private_contact_requests remains for payments saved before the journal kept them, and a restoration adds those to the journal.

The read before every contact request. Every contact request and acceptance, public or private, first reads the private store for the identity from its cursor to the end. This happens before anything is reserved or sent.

  • The read uses the keys a proved fetch shows the identity holds. A key another device added rewinds the read, and the store is read again with it.
  • The claim of a public pair checks again, in its lock, that the read still reaches the end.
  • A read ends only at the largest store size a page proved to the wallet in this process, in any pass or call, as the store only grows.
  • A read rewound after a key change counts only once the page that ends it was proved at or above the height the earlier read had reached.
  • While the read cannot finish, the request is refused with PrivateContactStoreNotRead (74), nothing reserved, sealed, paid or broadcast. Calling again continues the read.
  • On a network proven below protocol version 15, nothing is read. An SDK pinned below it is refused with 75.

Persister requirement. A private request is paid for only over a persister that declares PRIVATE_CONTACT_REQUEST_SCAN_STATE (bit 15) together with ATOMIC_CHANGESETS (bit 0).

  • Otherwise the private send, the private accept and the in-kind accept's private reply return PrivateContactRecoveryUnsupported (73) before anything is sealed or paid.
  • The marker is stored, and flushed for a backend that does not commit inline, before the payment may reach the network. A marker that cannot be stored refuses the payment.

A build without shielded keeps no shielded store, journal or restoration. It still holds an identity whose record is marked, loaded or kept through its wallet's removal (72), and still reads the private store before every contact request. It pays for no private request itself (the private send and accept take the payment as a caller's closure), never lifts a marker's hold, and holds a pair whose payment has not landed only through that marker.

Identity removal. IdentityManager::remove_identity (FFI identity_manager_remove_identity) refuses and removes nothing:

  • with 71 while the identity has a contact request in flight, or a journaled payment from it holds a pair;
  • with 72 while its loaded marker is not restored;
  • with 76 while a public request from it is unconfirmed.

Removed and added again, the identity would start with none of those holds.

Reads for hosts.

  • DashPayView::private_contact_readiness (FFI platform_wallet_get_private_contact_readiness) reads:
    • whether the payments are restored, and why the last restoration failed if it did;
    • whether the store was read to its end;
    • whether a payment may be unresolved, from the marker or from a journal record.
  • DashPayView::private_pair_held(sender, recipient) reads one pair's hold as a send decides it.
  • DashPayView::public_contact_request_unconfirmed(sender, recipient) reads whether a public request to the pair is unconfirmed, so a host tells it from a private request pending. Its FFI export comes with feat(swift-sdk): send, receive and accept DashPay private contact requests #5812.
  • Both apply journal discharges first, so a discharge shows without a send or sync in between.

Costs.

  • A payment that never lands holds its pair up to about 1100 blocks after the block that recorded its anchor. Platform keeps an anchor 1000 blocks and prunes every 100. On an idle pool it is longer, as the newest anchor is never pruned.
  • Every contact request costs a proved read from the identity's cursor and a proved identity fetch. A read rewound by a key change or an unignored sender starts again from position 0. One call reads up to 4 x 256 pages, then returns 74, and the next call continues.
  • These states keep contact requests refused with no exit in the wallet, as the persister keeps a marked record through the wallet's removal:
    • an identity marked as holding an unresolved payment after the shielded store was deleted or replaced, so the journal is new (restoring a known-good store file clears it);
    • in a build without shielded, an identity whose record is marked (opening the wallet in a shielded build with its store clears it).
  • A journal record that does not read back is never removed. It holds its wallet until a known-good store file is restored, and one whose wallet id does not read holds every wallet.
Alice pays for a private request to Bob; the app is killed before the entry lands
Relaunch, persister keeping the private scan state, before configure_shielded
  any contact request from Alice's wallet:  Err(PrivateContactPaymentsNotRestored)    72
configure_shielded reads the journal
  Alice -> Bob, public or private:          Err(PrivateContactRequestPending(Bob))    71
  private_contact_readiness(Alice):         restored, unresolved_private_payment
The entry lands and a private sync reads it back
  the record is discharged; Bob is now a private contact, so a public request to him is 68
The entry never lands
  once its anchor is pruned and the store proves the entry absent, the record is
  discharged and Alice -> Bob is free again
Alice's wallet is removed while that payment may still execute, then registered again
from the same seed, in this process or a new one, before configure_shielded
  Alice added afresh from Platform: held by the record the persister kept marked
  any contact request from Alice: 72, in every build
  with shielded, after configure_shielded: Alice -> Bob is 71 until the payment resolves
Alice's private request to Bob landed after Bob's last read of the store
Bob sends Alice a public request
  the send reads from Bob's cursor to the end first, records Alice's request, and refuses:
  Err(PrivateContactRequestNeedsPrivateReply(Alice))   68, nothing published
The read cannot reach the end (a node behind the store already seen, keys that kept changing)
  Err(PrivateContactStoreNotRead(Bob))                 74, calling again continues the read
An unshield's broadcast reaches a node, which answers with a consensus rejection
  before: ShieldedBroadcastFailed (16), notes released
  after:  the wallet waits for the result; unproven: ShieldedSpendUnconfirmed (18), notes and
          redrive record kept until the spend lands or its anchor is pruned
A shielded transfer refused before anything was sent
  before and after: ShieldedBroadcastFailed (16), notes released
The app exits while a shielded transfer waits for its result
  before: the transfer's record was saved only after an unconfirmed result, so a restart found
          none and its notes read as spendable
  after:  the record was saved before the first dispatch; a restart restores the reservation
A shield from Platform addresses gets a nonce verdict from the node it reached
  before: ErrorAddressNonceMismatch (21)
  after:  ShieldedSpendUnconfirmed (18); the activity row stays pending until the scan sees the note
A masternode credit withdrawal's node answers with a refusal after the dispatch
  before: an ordinary, retryable error
  after:  MasternodeWithdrawalUnconfirmed (42); re-read the identity nonce and claimable
          balance before submitting again

How Has This Been Tested?

  • cargo test -p platform-wallet --lib --features shielded,test-utils -- contact_request private lifecycle broadcast_outcome redrive masternode persister remove_identity: 443 passed.
  • The same filters with --features test-utils, without shielded: 352 passed.
  • cargo test -p platform-wallet-ffi --lib --features shielded -- private error persistence shielded_send contact: 219 passed.
  • cargo test -p dash-sdk --lib -- outcome dashpay broadcast put_document: 241 passed.
  • cargo clippy --all-targets -- -D warnings:
    • platform-wallet with shielded,test-utils: clean;
    • platform-wallet-ffi with shielded: clean;
    • dash-sdk: clean;
    • platform-wallet with test-utils only: two dead-code errors in wallet/asset_lock, which v5.1-dev has too.
  • The holds, the store read, the journal releases, the marker kept through a wallet's removal, the generation fences, the durable discharge, each step of the unconfirmed public request and the FFI codes were each reverted once to confirm a test fails.
  • Tests that pin the main behaviours:
    • flow:
      • should_send_read_and_accept_a_private_contact_request
      • should_recover_a_sent_private_contact_request_from_the_store
      • should_record_a_private_request_the_same_way_every_time
      • an_older_request_than_the_tracked_one_is_no_rotation
      • test_select_for_a_private_document_create_covers_its_fee_alone: 141,155,400 credits for one DashPay entry
    • journal and release:
      • should_restore_a_pending_request_paid_for_before_a_restart
      • should_free_a_pair_once_its_private_payment_provably_expired
      • should_not_take_an_entry_starting_with_a_commitment_as_its_payment
      • should_keep_a_private_payment_s_journal_record_unless_it_never_left
      • should_discharge_a_journal_record_a_restoration_finds_settled
      • should_drop_a_read_back_notice_whose_records_the_backend_dropped
      • should_hold_a_record_whose_request_a_lost_flush_dropped_until_a_restart_reads_it_again
      • should_keep_a_journal_record_s_entry_hash_and_position_across_its_bytes
      • should_open_the_recovery_connection_with_full_syncs
    • broadcast outcomes:
      • should_keep_a_broadcast_an_endpoint_refused_maybe_delivered
      • should_treat_every_transport_failure_as_maybe_delivered
      • should_read_a_wait_stage_consensus_verdict_as_unconfirmed
      • should_read_the_sdk_s_unconfirmed_outcome_as_maybe_delivered
      • should_send_a_transition_a_node_is_behind_on_to_another_node
      • should_restore_a_spend_that_exited_between_its_broadcast_and_its_result
      • should_retire_the_record_of_a_spend_that_never_left_the_wallet
      • should_not_dispatch_a_spend_whose_record_cannot_be_saved
      • should_report_a_shield_unconfirmed_once_a_dispatch_may_have_reached_a_node
      • should_report_a_shield_failed_only_when_nothing_was_sent
      • should_report_a_masternode_withdrawal_unconfirmed_once_a_dispatch_may_have_reached_a_node
      • should_report_a_masternode_withdrawal_failed_only_when_nothing_was_sent
    • holds:
      • should_hold_a_wallet_published_with_no_shielded_store_until_its_journal_is_read, which also covers the wallet registered again from the same seed by a new manager
      • should_hold_an_identity_reloaded_marking_an_unresolved_private_payment
      • should_hold_an_identity_whose_marked_record_outlived_its_wallet, run with and without shielded
      • should_hold_a_loaded_identity_whose_record_a_persister_kept_marked
      • should_configure_again_without_waiting_on_a_busy_store
      • should_retry_an_unfinished_restoration_before_a_contact_request
      • should_report_why_a_restoration_failed_while_the_wallet_is_held
      • should_refuse_removing_a_sender_holding_a_restored_payment
    • store read:
      • should_read_an_entry_landed_since_the_last_read_before_a_public_request
      • should_read_the_private_store_before_every_private_send
      • should_adopt_a_reply_sealed_under_a_key_another_device_added
      • should_read_past_a_full_page_from_a_node_behind_before_a_public_request
      • should_not_end_a_rewound_read_at_an_older_walk_target_before_a_public_request
      • should_refuse_claiming_a_public_pair_once_the_read_before_it_is_rewound
      • should_carry_the_largest_proved_store_size_across_bounded_passes
      • should_not_end_a_public_request_s_read_below_a_size_an_earlier_call_proved
    • generation and durability:
      • should_leave_a_wallet_created_again_alone_from_a_stale_private_send, run with and without shielded
      • should_discharge_a_settled_record_only_once_its_request_is_durable
    • unconfirmed public requests, run with and without shielded:
      • should_hold_an_unconfirmed_public_accept_until_proof_settles_it
      • should_broadcast_an_unconfirmed_public_request_again_as_its_own_bytes
      • should_leave_a_replacement_s_unconfirmed_public_request_to_it
      • should_adopt_a_request_already_on_platform_instead_of_sending_another
      • should_refuse_a_private_request_and_a_removal_while_a_public_one_is_unconfirmed
      • should_not_attribute_a_document_batch_whose_contract_nonce_is_used (SDK)
    • persister:
      • should_refuse_a_private_payment_over_a_backend_that_keeps_no_marker
      • should_make_a_private_payment_s_marker_durable_before_paying
    • FFI codes:
      • should_report_an_unfinished_private_store_read_by_its_own_code
      • should_tell_a_store_the_network_does_not_serve_yet_by_its_own_code
      • should_report_an_identity_the_wallet_does_not_hold_by_its_own_code
      • should_report_an_unconfirmed_public_request_by_its_own_code
  • Not run:
    • a real Orchard proof (the payment step is a closure, which the tests replace);
    • the full workspace suites;
    • the rs-sdk-ffi and rs-dapi-client tests at this head, and the dash-sdk tests beyond the filters above;
    • the unique-index read of a sent request against a live Drive (the mock answers it; the query is the equality on every property of a unique index DPNS uses for preorders);
    • a public request whose broadcast an endpoint answers with a consensus verdict (the mock answers no broadcast);
    • the Swift and Kotlin hosts, which come with their own PRs.

Breaking Changes

None for consensus. Host-visible:

  • New FFI codes:
    • 68 ErrorPrivateContactRequestNeedsPrivateReply: the contact's requests came or went privately, so the public send and the public accept refuse; nothing was sent.
    • 69 ErrorShieldedBuildRequired: a private reply needs a build with shielded. No export in this PR returns it yet.
    • 70 ErrorProtocolVersionNotActive: the network is proven below the protocol version that serves the request. It lifts when the network upgrades.
    • 71 ErrorPrivateContactRequestPending: a private request to that identity is in flight, or paid and not read back, or a journal record holds the pair. It is pending, not failed: do not submit it again.
    • 72 ErrorPrivateContactPaymentsNotRestored: the wallet's private payments are not restored from the journal yet, or the identity was loaded with its marker set. Every contact request is refused until a restoration succeeds.
    • 73 ErrorPrivateContactRecoveryUnsupported: the persister does not declare bits 15 and 0, so no private request is paid for.
    • 74 ErrorPrivateContactStoreNotRead: the private store read did not finish before the request was decided; nothing was done, and calling again continues.
    • 75 ErrorSdkPinnedBelowProtocolVersion: the SDK is pinned below the version the request needs. Only raising or removing the pin lifts it.
    • 76 ErrorPublicContactRequestUnconfirmed: a public request to that identity may have been delivered and no proof settled it yet. It is unconfirmed, not failed: do not submit it again; a contact request sync settles it.
  • Note spends: a broadcast that may have been delivered (an endpoint's verdict, or any transport failure after a dispatch) now reports 18 where it reported 16; an identity creation from the pool reports 17. Code 16 now means the spend was refused before anything was sent.
  • Shields from Platform addresses: an endpoint's refusal or verdict on a dispatched shield, a nonce verdict included, reports 18 where it reported 21 or 16. Transfers and withdrawals from Platform addresses, and identity registrations and top-ups from them, still return 21.
  • Masternode credit withdrawals: once a dispatch may have reached a node, every outcome short of the proved balance reports 42; only a withdrawal that never left the wallet keeps an ordinary, retryable code.
  • SDK unconfirmed outcome: dash_sdk::Error::StateTransitionResultUnconfirmed reaches the host as ErrorTransactionBroadcastUnconfirmed (20), through the shielded spend contract (map_spend_result) too, rather than as ErrorWalletOperation (6).
  • Persisters: PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE is bit 15. A persister that declares it, with ATOMIC_CHANGESETS (bit 0), must:
    • store and load ContactChangeSet::private_contact_requests_positions (read position, key fingerprint and the private_payments_unresolved marker), private_shielded_addresses and removed_private_shielded_addresses;
    • keep a marked position record through the removal of its wallet and any purge of the wallet's rows, until a record stored cleared for the same wallet and owner replaces it;
    • return such records for a wallet it no longer holds in the new ClientStartState::retained_private_contact_scan_states, keyed by wallet id and owner. A registration and a load read them and hold the identities they name.
  • ShieldedStore implementors:
    • must add:
      • arm_private_contact_payment, which takes &[PrivatePaymentEntry] and the sender's store_position;
      • private_contact_redrives_for_wallet and retire_undelivered_spend;
      • the journal methods (private_payment_journal_open, open_private_payment_journal, private_payment_journal, journal_private_payments, discharge_private_payment);
      • mark_pending_by, clear_pending_reserved_by and note_anchor_recorded;
    • may override legacy_anchor_tree_bound;
    • set_pending_spend takes anchor_height, StalePendingSpend gains a fourth element, and PendingRedrive gains anchor_height;
    • PrivatePaymentRecord carries entry_hash and store_position.
  • Journal records are written in format 4. Format 3 rows still read; they match an entry by its commitment and are read for from the store's start.
  • Hosts whose persister keeps the private scan state: contact requests, public and private, are refused with 72 until the shielded store is configured, and with 74 while the private store read has not finished.
  • Identity removal is refused with 71, 72 or 76 while the identity's channel is held.
  • FFI: PlatformWalletError::IdentityNotFound now reaches the host as ErrorIdentityNotFound (7), not the catch-all (99).
  • PlatformWalletError gains variants: PrivateContactRequestNeedsPrivateReply, PrivateContactRequestPending, PrivateContactStoreNotRead, PrivateContactPaymentsNotRestored, PrivateContactRecoveryUnsupported, ShieldedBroadcastMaybeDelivered. The enum is exhaustive, so a downstream match that names every variant needs an arm for each.
  • Public send and accept: both refuse a contact whose requests came or went privately (68). A host routes those to the in-kind accept or the private send. An endpoint's verdict on a public request's broadcast now reports 20 and holds the pair, where it reported the verdict; a later request to the pair is 76 until it is settled.
  • PlatformWalletError also gains PublicContactRequestUnconfirmed.
  • SDK: TransitionOutcome::ExecutedOrSuperseded is now a struct variant with consumed_by_height (unreleased API from feat(sdk): prove a state transition's outcome, and add getRecentStateTransitionStatus #5823); Sdk::sign_contact_request and SignedContactRequest are new.

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 · 54ae054

  • Bots — thepastaclaw not yet — /skip-bots proceeds without the ones not yet reported
  • Self-review — post /self-reviewed
  • Build running
  • Approvals
    • kotlin-sdk (packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/IdentityRegistrationController.kt, packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/RegistrationCoordinator.kt, packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt and 4 more) — HashEngineering
    • rs-platform-wallet-ffi (packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md, packages/rs-platform-wallet-ffi/src/contact_request.rs, packages/rs-platform-wallet-ffi/src/dashpay.rs and 4 more) — HashEngineering or ZocoLini or llbartekll or romchornyi
    • rs-platform-wallet (packages/rs-platform-wallet/Cargo.toml, packages/rs-platform-wallet/README.md, packages/rs-platform-wallet/src/broadcast_outcome.rs and 44 more) — HashEngineering or ZocoLini or llbartekll or romchornyi
    • rust-sdk-ffi (packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs) — lklimek or shumkov
    • rust-sdk (packages/rs-sdk/src/mock/sdk.rs, packages/rs-sdk/src/platform/dashpay/contact_request.rs, packages/rs-sdk/src/platform/dashpay/mod.rs and 5 more) — lklimek or shumkov
    • swift-sdk (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift and 6 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.

Summary by CodeRabbit

  • New Features
    • Added support for sending and accepting private contact requests with shielded payments.
    • Added private-contact readiness reporting and recovery of pending payments across wallet restarts.
    • Private contact scan progress and payment records can now be persisted by supported stores.
  • Bug Fixes
    • Improved handling of uncertain shielded-spend delivery, preserving pending payments until their outcome is confirmed.
    • Prevented contact requests or identity removal when private payments or requests are still pending.
  • Documentation
    • Added guidance on private-payment recovery, persistence requirements, and shielded-spend outcomes.

QuantumExplorer and others added 7 commits November 8, 2026 10:39
The prepared-keys test also opens an entry with the first key of the
set, so a scan skipping earlier keys would fail it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK can now append entries to a private document type's store, paid
from the shielded pool (type 29), and find a wallet's entries in it:

- `PrivateDocumentCreateFromShieldedPool` builds, structure-checks and
  broadcasts the transition from a proven bundle, and waits for its
  nullifiers to be proven spent.
- `PrivateDocumentScanKeys` and `Sdk::scan_private_document_page` sort a
  proved page of a store into what the wallet's identities received
  (their keys open it), what they sent (their outgoing viewing keys
  recover it) and what opened but is refused, with the cursor to scan
  from next.
- DashPay: `PrivateContactRequest` with the schema's bounds,
  `seal_private_contact_request` (the sender's encryption key, a deniable
  MAC, and the outgoing viewing key derived from that key), and
  `Sdk::scan_private_contact_requests`, which decodes each request,
  checks its sender against the identity key it names and keeps the
  first request per sender, recipient and account reference.

platform-encryption: prepared recipient keys check a sender with the key
that opened the entry, so a scan never copies secret keys out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wallet restoring its sent requests needs the encryption key each was
sealed with to rebuild its local record of the request.

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

The wallet sends, reads and accepts DashPay private contact requests
(protocol version 15), paying for each from the shielded pool:

- `PlatformWallet::send_private_contact_request` builds the request a
  `contactRequest` document would carry (receiving xpub, masked account
  reference, label, auto-accept proof) plus a shielded address, has the
  crypto provider seal it, and appends it with a type 29 transition paid
  from an account's notes (new `ShieldedFeeKind::PrivateDocumentCreate`,
  reserved for the fee alone).
- `DashPayView::sync_private_contact_requests` reads DashPay's private
  store from each identity's cursor: received requests with their
  senders checked, and sent ones recovered from the store alone.
- `accept_private_contact_request` replies privately. The public accept
  refuses a request that came privately
  (`PrivateContactRequestNeedsPrivateReply`), as a document reply would
  publish the relationship.

A private request is recorded as a `ContactRequest`, its xpub and label
encrypted under the pair's DIP-15 shared key with IVs from the entry's
commitment, so contacts establish, rotate and get their accounts as they
do from documents, and nothing persisted changes shape. Whether a request
came privately and the contact's shielded address are kept in memory
(`PrivateContact`), with the store cursor, and rebuilt by the scan a cold
start runs. Requests that came privately are not auto-accepted.

`ContactCryptoProvider` gains `scan_private_document_page`,
`read_private_contact_requests` and `seal_private_contact_request`, all
defaulted to unsupported, so the keys stay in the signer. The document
send and accept share their preparation and accept steps with the private
ones, and the document sync's stale-account teardown moved into
`tear_down_stale_external_accounts`, which both syncs call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A private contact request carried a deniable MAC keyed by the static ECDH
of the sender's encryption key and the recipient's key. Anyone holding the
recipient's key could compute it, so a stolen recipient key forged requests
from any sender, including rotations that redirect the recipient's payments.

The request is now signed by one of the sender's signing keys, as its public
request's transition is: an enabled AUTHENTICATION key at a level DashPay's
documents are signed at (CRITICAL or HIGH), ECDSA_SECP256K1 or ECDSA_HASH160.
The entry still names the encryption key, which the pair's shared key and the
outgoing viewing key come from; the reader recovers the signing key from the
signature. Entries with a MAC are refused.

Envelope (signature mode, nothing deployed): the signature now signs the
double SHA-256 of the signing data, as identity signers sign data, so a
wallet signs with the signer it signs transitions with. The caller passes
the signature; `private_document_signing_data`,
`sign_private_document_signing_data`, `recover_private_document_signer` and
`OpenedPrivateDocumentEntry::recover_signer` are new. Prepared recipient keys
are wiped on drop.

Reading also checks, at the entry's block time, the recipient key it is
encrypted to, and that a sent entry names the identity and key its outgoing
viewing key comes from and is signed by that identity. The sender is checked
before the payload is decoded. `PrivateDocumentScanKeys::verify_sender`
tries every key set of the identity.

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

- Private requests are signed by the identity signer, with a signing key of
  the sender at a level DashPay's documents are signed at, as public requests
  are. `send_private_contact_request` and `accept_private_contact_request`
  take the signer. The crypto provider seals with the signature and keeps
  the encryption key; `read_private_contact_requests` is gone, as checking an
  entry needs no secret key.
- A private request is recorded at core height 0, which no contactRequest
  document has (DashPay requires `$createdAtCoreBlockHeight`).
  `ContactRequest::came_privately` and `DashPayState::private_contact` derive
  from the records, so the privacy of a contact holds after a restart; only
  the shielded addresses stay in memory, noted from the tracked request.
- A public send to a contact whose requests came or went privately is
  refused, as is a public accept that would broadcast a reply.
- Ingest passes over a request older than the tracked one (by its
  reference's version, then its time) on both sides, so a cold start's
  rescan of the private store, or a replayed entry, does not roll a contact
  back to a retired xpub.
- Sending computes the shared key before paying, and nothing after paying
  fails the send; a record that does not reach disk is recovered by the next
  sync. Sent private records note core height 0 for the receiving account's
  scan checkpoint.
- A sync whose ingest did not reach disk for an identity is not complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer added this to the v5.1.0 milestone Nov 8, 2026
@coderabbitai

coderabbitai Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • Resume reviews
  • Trigger review
Walkthrough

The pull request adds private contact-request scanning, sending, acceptance, and shielded payment recovery. It tracks payment and scan state across persistence and wallet lifecycles, classifies shielded broadcast outcomes, and adds FFI readiness and error reporting.

Changes: Private Contact Request Lifecycle

Layer / File(s) Summary
Private contact state and persistence contracts
packages/rs-platform-wallet/src/changeset/*, packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/*, packages/rs-platform-wallet/src/wallet/persister.rs, packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs, packages/rs-platform-wallet/src/error.rs
Contact state now records private-request scan positions, key fingerprints, private shielded addresses, reply reservations, and unresolved-payment status. Changesets, persistence capabilities, wallet errors, and public exports include the new state. Identity removal checks for pending requests and unrestored payments.
Private contact request scanning and exchange
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs, packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs, packages/rs-platform-wallet/src/wallet/identity/network/mod.rs, packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs, packages/rs-platform-wallet-ffi/src/dashpay.rs, packages/rs-platform-wallet/Cargo.toml
The contact-request flow adds private-document scanning and sealing, private-reply checks, version-aware replay handling, and wallet-generation checks. Resolver-backed signer operations scan pages and seal requests.
Shielded payment and journal storage
packages/rs-platform-wallet/src/wallet/shielded/store.rs, packages/rs-platform-wallet/src/wallet/shielded/file_store.rs, packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs, packages/rs-platform-wallet/README.md
Shielded-store contracts and implementations add payment associations, journal records, reservation ownership, and anchor heights. The file store persists journal and recovery data, and note selection accounts for private-document creation fees. The README documents custom-store requirements and recovery behavior.
Private payment submission and restoration
packages/rs-platform-wallet/src/wallet/platform_wallet.rs, packages/rs-platform-wallet/src/manager/load.rs, packages/rs-platform-wallet/src/manager/mod.rs, packages/rs-platform-wallet/src/wallet/core/generation.rs
PlatformWallet adds private-request payment and restoration paths. Wallet loading and shielded configuration restore held payments, and payment operations retain the wallet-generation lifecycle guard.
Broadcast delivery and sync evidence
packages/rs-platform-wallet/src/broadcast_outcome.rs, packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs, packages/rs-platform-wallet/src/wallet/shielded/sync.rs, packages/rs-sdk/src/platform/private_document_scan.rs, packages/rs-sdk/src/mock/sdk.rs
Shielded broadcast handling tracks admitted, possibly delivered, and never-delivered outcomes. Sync results expose proved-height coverage. Coordinator recovery uses anchor heights, scan coverage, and private-document reads to retain or discharge payment records.
FFI readiness and error reporting
packages/rs-platform-wallet-ffi/src/contact_request.rs, packages/rs-platform-wallet-ffi/src/error.rs, packages/rs-platform-wallet-ffi/src/shielded_send.rs, packages/rs-platform-wallet-ffi/src/identity_manager.rs, packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
The FFI adds private-contact readiness output and result-code mappings for new wallet errors. Identity-removal errors for pending requests and unrestored payments are preserved.

Estimated code review effort: 5 (Critical) | ~90 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant PlatformWallet
  participant ShieldedStore
  participant NetworkShieldedCoordinator
  participant Sdk
  PlatformWallet->>ShieldedStore: arm redrive and journal payment
  PlatformWallet->>NetworkShieldedCoordinator: submit private-document transition
  NetworkShieldedCoordinator->>Sdk: read private contact request pages
  Sdk-->>NetworkShieldedCoordinator: return pages and proof heights
  NetworkShieldedCoordinator->>ShieldedStore: discharge or retain journal record
Loading

Possibly related issues

Suggested reviewers: shumkov, claudius-maginificent, hashengineering

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 79.05% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed

Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly describes the main change: support for sending, receiving, and accepting private DashPay contact requests.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 79.05% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 148 functions across 43 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches

Generate docstrings

  • Commit to this branch
  • Create a new PR
Generate unit tests (beta)

  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Review coverage is incomplete: 1 file could not be fully reviewed. Findings from completed review steps are included; see review info for details.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@thepastaclaw

thepastaclaw commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

⏹️ Not reviewed — PR is closed (merged) (commit 54ae054)

QuantumExplorer and others added 9 commits November 8, 2026 19:08
Review fixes:

- The envelope's authenticated data names the recipient key `ser(R)`, so
  a body re-encrypted to another key of the same identity (an old key's
  holder re-targeting a signed request) authenticates no sender.
  `private_document_signing_data` takes the recipient. The MAC and
  signature vectors are re-pinned; nothing is deployed.
- A signing key bound to a contract signs only inside its bounds:
  DashPay, or its `privateContactRequest` type. A contract group bound
  signs none, as a client cannot read the group's members as they were.
  The seal and the reader apply the same rule;
  `private_contact_request_signing_keys` lists the keys that qualify.
- `PrivateContactRequest::from_properties` drops a null only under an
  optional property's name, so an unknown property set to null is refused.
- Recovered sent requests are deduplicated per sender, recipient and
  account reference, apart from the received ones.
- Senders added with the same outgoing viewing key: an entry recovers as
  the one it names.
- Docs: the transition's proof shows the nullifiers spent, not that its
  entries were appended; fetching a sender right after its page tells the
  serving node which identity the wallet looked up.

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

The private send picks the first signing key the signer holds among
`private_contact_request_signing_keys`, which adds the contract bounds rule
of #5809 to the level and type rules: a key bound to another contract or
document type, or to a contract group, is never chosen, as the seal would
refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`accept_contact_request_replying_in_kind` (on `DashPayView`, and on
`PlatformWallet` with the `shielded` feature) is the one accept a host
calls: a request that came privately, with no reply sent yet, is accepted
with a private reply paid from the shielded pool; any other is accepted
with a document, or by adopting the reply already sent. The choice was the
hosts' (catch code 68, then call the private accept); it is the wallet's
now. `PlatformWallet` derives the spend authority only for a private reply.
The explicitly public and private accepts are unchanged.

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

Review fixes:

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

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

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

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

@thepastaclaw 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 of head 7bd302a confirms five blocking issues involving publication confirmation, duplicate private-reply payments, rotation selection, cursor rewinds, and stale recipient identities. Additional suggestions address persistence recovery, failure isolation, entropy errors, and the public-send privacy precondition. Current CI shows Kotlin build/tests and policy checks passing, with the automated review pending and no targeted Rust validation check visible; no builds or tests were run.

🔴 5 blocking | 🟡 4 suggestion(s)

Review provenance

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

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate change directly modifies shielded funds movement and note selection in shielded/operations.rs and shielded/note_selection.rs, as well as signing-key selection and encryption-key handling in private_contact_requests.rs.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); 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 — 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/src/wallet/shielded/operations.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1678-1686: Verify entry inclusion before recording a private request as sent
  This branch accepts an AffectedState result and returns success to send_private_contact_request, which records the outgoing request and can establish the contact. However, the SDK explicitly documents that type 29 proves only spent nullifiers, and Drive verifies only those nullifiers and classifies the result as non-execution-proving. With two real input notes, a competing spend by another device can consume the same nullifiers without appending this entry; an untrusted DAPI can then supply a genuine spent-nullifier snapshot for a request that was never published. Subsequent accepts adopt the nonexistent reciprocal instead of sending one. Keep consumed-note reconciliation separate from publication confirmation, and require a proof-verified private-store read locating the exact sealed entry before recording the request as delivered. If inclusion remains unknown, retain a typed unresolved outcome rather than returning publication success.

In `packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:315-320: Retain and serialize pending private replies before paying
  When pay returns ShieldedSpendUnconfirmed, this function exits without associating an outgoing request with the contact, while the shielded operation retains reserved notes and a byte-identical redrive that may still execute. An accept retry before private sync therefore sees no reciprocal, seals another entry, and can pay again from other available notes. Concurrent accepts have the same gap because accept_contact_request_replying_with releases its state lock before invoking the reply closure. The private store is an opaque append-only log with no cross-transition uniqueness constraint on the contact pair or account reference, so both payments can execute. The existing unstored_sent handling protects only failures after payment has returned success; later sync recovery does not prevent these duplicate fees. Reserve a contact-scoped pending reply before payment, serialize the decision for that pair, and associate the sealed entry with its shielded operation so retries resume or report the existing attempt instead of funding another one.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:728-740: Apply version-first ordering before collapsing scan candidates
  Both collapse helpers select by (created_at, account_reference), but the new predates guard selects freshness by (account_reference_version, created_at). The guard cannot recover a rotation already discarded during collapse. For example, after an earlier page established version 0, a later page can contain a legitimate version 1 rotation followed by a replay of the version 0 ciphertext. Both survive SDK filtering because their account references differ, and the replay receives the later append timestamp. Collapse keeps version 0, ingest skips its already-tracked reference, and the cursor advances past version 1. This affects both received requests and recovered sent requests, leaving retired payment material or an obsolete outgoing rotation version tracked. Use one version-first ordering for collapse and supersession, and test an earlier-page original followed by a same-page rotation and replay in both directions.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:747-752: Preserve a concurrent cursor rewind across the entire scan pass
  The compare-and-advance helper correctly refuses to overwrite a concurrent unignore rewind, but the assignment immediately afterward adopts the rewound cursor as this pass's new baseline. If a pass starts at position 100 and unignore_sender resets the live cursor to 0 while the first page is fetched, the first advance is refused and identity.position becomes 0. The next page is still fetched beyond 100 using read.next_position, yet its advancement now compares successfully against 0 and overwrites the rewind. The earlier ignored entries were never reread, so the newly unignored sender can remain missing for the session. Once a cursor mismatch is detected, stop advancing that identity for the rest of the pass or restart its scan at the rewind. Add a two-page regression with unignore occurring during the first fetch.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:599-602: Height-pin recipient identities used for outgoing recovery
  Unlike proved_identities, this recipient cache discards proof metadata and never refreshes an identity during the pass. A later page can contain a request sealed to a key added after the cached recipient snapshot; even the initial fetch can return a proof older than sent.block_height within the SDK's tolerance. The lookup below then treats the stale snapshot as evidence that the recipient does not hold the key, skips the valid outgoing request, and advances the sender's cursor past it. A restored request or rotation is consequently lost for the running session. Fetch these recipients with the existing height-aware SDK mechanism, requiring proof height at least sent.block_height and refreshing when later pages require it. Failure to obtain a sufficiently recent snapshot must preserve the affected sender's cursor rather than become a permanent skip.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:599-602: Isolate recipient-fetch failures without advancing affected senders
  An error fetching any recovered outgoing recipient returns from the whole sync before the current page's unrelated incoming or outgoing requests can be ingested. Earlier pages remain committed, but one failing recovery lookup prevents progress for every identity on this page. The public sweep already supports per-identity degradation. Apply equivalent isolation here: report the failure, hold back cursors for identities whose outgoing records could not be reconstructed, and continue ingesting unaffected identities. Do not simply convert fetch errors to None and advance normally, because that would permanently skip recoverable outgoing requests for the session.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:286-287: Return entropy failures through the send result
  StdRng::from_os_rng panics when the operating system cannot supply entropy; rand_core explicitly provides try_from_os_rng for callers that must handle this failure. This introduces a panic into a fallible wallet-library send operation and can terminate an embedding application rather than returning PlatformWalletError. Seed through try_from_os_rng and map the error before filling esk_seed, retaining the existing zeroization after sealing.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1305-1311: Let same-reference recovery retry an unstored paid request
  A freshly paid request uses the confirmation proof's metadata.time_ms, whereas scan recovery uses the entry's block_time_ms. GetProofs proves current state, so confirmation can occur in a later block than the append. If initial persistence failed, add_paid_sent_contact_request keeps that later-timestamped request in memory and marks it unstored. This guard then rejects recovery of the identical account reference as older, preventing add_sent_contact_request from reaching its same-reference persistence retry. Sync can advance and report completion while the request remains unstored until a cold rescan. Exempt the same account reference from the freshness rejection so the existing retry persists the tracked record without replacing metadata. Cover a later confirmation timestamp and a failing-then-successful persister.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:583-594: Document or enforce the privacy gate's synchronization precondition
  This gate checks only locally tracked requests. A restored or second device that has not completed private sync has no private records for the pair, so it can publish a public contactRequest even though the relationship was previously private. There is no private-sync readiness check in this send path, and its API documentation does not state that the privacy guarantee depends on synchronization order. Document that limitation and the required completed scan before public sends on restored state, or enforce an explicit readiness policy in Rust. The local gate alone does not establish the PR's unconditional claim that contacts exchanged privately are never sent public requests.

QuantumExplorer and others added 3 commits November 9, 2026 00:27
…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>

@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 exact head d0db59f against the source: eight prior findings are fixed, but pending-operation lifecycle gaps still permit duplicate payments or stranded reservations. The public-send gate also overlooks unresolved private attempts, and exhausted rotation versions can consume fees without producing a usable request. This was a static review with no local builds or tests; current head-specific CI shows the Kotlin build/tests passing and no failed code checks, with the review check still pending.

🔴 3 blocking | 🟡 1 suggestion(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

🔴 Blocking: Reject exhausted rotation versions before funding a private send
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:926-935

This helper now prepares private sends as well as public documents, but saturation still relies on the public document's unique index to reject a repeated reference. With a tracked outgoing version of 15 and unchanged encryption key and receiving xpub, another private send derives the same account reference and pays for a fresh sealed entry. The append-only private store accepts it, while received ingest and add_sent_contact_request treat the reference as already tracked and ignore the new request. The sender's readback check then fails because the fresh commitment produces different encrypted-xpub bytes, so the operation spends its fee but returns pending without applying the request. Sync can clear the reservation, allowing further charged retries. Reject version exhaustion before sealing/payment, or implement a supported supersession scheme that does not depend on the public index.

source: gpt-6.1-sol (phase2-reviewer: general)

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

Review provenance

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

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate change directly modifies shielded funds movement and note selection in shielded/operations.rs and shielded/note_selection.rs, and signing and encryption-key handling in network/private_contact_requests.rs.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); 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 — 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/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:591-601: Include unresolved private sends in the public-send privacy gate
  This gate checks only recorded incoming/outgoing requests through `private_contact`; it does not check `private_reply_pending`. On a device whose initial private sync is already complete, a first private send can return `ShieldedSpendUnconfirmed` or `PrivateContactRequestPending` while retaining its commitment but creating no outgoing record. A subsequent public send passes this gate and publishes both identities even though the private transition may already have executed or may still be published by redrive. This is separate from the documented restored-device synchronization precondition: scanning cannot recover an entry that has not executed yet, and this wallet already knows about the attempt. Make public-channel eligibility include unresolved private operations for the pair, retaining the refusal until definitive failure or reconciliation.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:926-935: Reject exhausted rotation versions before funding a private send
  This helper now prepares private sends as well as public documents, but saturation still relies on the public document's unique index to reject a repeated reference. With a tracked outgoing version of 15 and unchanged encryption key and receiving xpub, another private send derives the same account reference and pays for a fresh sealed entry. The append-only private store accepts it, while received ingest and `add_sent_contact_request` treat the reference as already tracked and ignore the new request. The sender's readback check then fails because the fresh commitment produces different encrypted-xpub bytes, so the operation spends its fee but returns pending without applying the request. Sync can clear the reservation, allowing further charged retries. Reject version exhaustion before sealing/payment, or implement a supported supersession scheme that does not depend on the public index.

In `packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:888-889: Report committed requests rather than pre-ingest candidates
  `PrivateContactSyncReport::requests` is documented as requests ingested, but these extensions run before collapsing candidates, filtering ignored senders, rejecting older requests, or attempting persistence. An ignored request or a request whose persistence failed is therefore returned as ingested even though it never became wallet state. The received helper already returns successful ingestions through `ingested`, but that vector is discarded here. Populate the report from successful ingestions and add equivalent sent-side tracking; cover ignored senders and a failing persister so callers can distinguish fetched candidates from committed state.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:260-264: Retain and serialize pending private replies before paying
  (existing thread: https://github.com/dashpay/platform/pull/5810#discussion_r4506856812)
  The pre-seal reservation closes the straightforward overlapping-call case, but it is not owned and reconciled with the payment throughout its lifecycle. Four verified paths remain:

  - Restart loses `pending_private_replies`, while `FileBackedShieldedStore::rehydrate_pending_spends` restores the original signed transition and its note reservations. If the original request has not landed, even a completed private scan finds no reciprocal. Retrying accept can fund another entry from other notes while the original remains executable or is redriven. GroveDB's private store appends opaque entries without contact-pair/reference uniqueness, so both fees can execute.
  - Lines 912–920 settle every matching item in `read.sent`, including entries omitted from `records` because the recipient fetch failed at lines 799–801. Both ingest helpers can succeed on empty inputs, removing the reservation without storing an outgoing request. The send returns pending, but an accept retry can pay again.
  - After sync settles attempt A, it awaits account building, and A subsequently awaits account registration. Attempt B can reserve the same pair during that interval. A's recipient-only cleanup at lines 288–292 then removes B's reservation, admitting another payment while B is unresolved.
  - Dropping the send future during preparation bypasses cleanup. Cancellation before `note_private_reply_commitment` leaves a `None` reservation that no store scan can settle, blocking further sends and accepts for that pair until the wallet state is recreated.

  Use an attempt-owned lifecycle in the Rust wallet: retain the pair-to-attempt association durably before submission can occur, compare attempt identities when updating or releasing reservations, release abandoned pre-payment preparation safely, and settle only after the corresponding request has been durably reconciled or the payment is proven unable to execute. Add regressions for reopening with an unresolved redrive, recipient-fetch failure after payment, A-settles/B-reserves/A-cleans-up, and cancellation during preparation.

QuantumExplorer and others added 4 commits November 9, 2026 01:08
… 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>
…, and restore it after a restart

The reservation that keeps a second private request to a recipient from
being paid for, and a public one from publishing the pair, had four gaps.

- Attempt tokens: a reservation is a `PendingPrivateReply` owned by the
  attempt that made it. Only that attempt notes its commitment or releases
  it, so an attempt whose request was settled can no longer release the
  reservation of the next attempt to the same recipient.
- Cancellation: the send holds the reservation in a guard that releases it
  when the send is dropped before it pays (synchronously when the wallet
  lock is free, otherwise on a spawned task). It is kept once payment may
  have started.
- Settle only what was reconciled: the private sync settles a pending
  request only for a sent entry whose record it built and that the tracked
  request to the recipient now matches (by account reference, or by being
  newer), after both ingests succeeded. An entry whose recipient could not
  be read no longer settles it with nothing recorded.
- Restart: reservations are in memory, but an unconfirmed payment survives
  a restart as the shielded store's redrive record.
  `PlatformWallet::restore_pending_private_contact_requests` reads the
  type-29 transitions to DashPay's store from those records and reserves
  each recipient again under its entry's commitment, so a sync settles it.
  The private payment runs the same check right before it spends, and
  refuses with `PrivateContactRequestPending` when an unresolved request
  between the same pair is found, leaving the recipient reserved for that
  one. A rotation still in flight is not restored.

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

Copy link
Copy Markdown
Contributor

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

QuantumExplorer and others added 3 commits November 10, 2026 19:37
…uests

Brings in #5556 (shared broadcast keeps uncertain submission outcomes),
#5823 (proven state transition outcome, getRecentStateTransitionStatus),
#5815, #5834, #5825, #5835, #5838 and #5839.

One textual conflict, in packages/rs-sdk/src/platform/transition/broadcast.rs
at the end of broadcast_with_retries. This branch (#5837) had the shared
broadcast fail over on a nonce-ahead answer and on a missing DPNS owner
(retry_with_additional_error, from #5562), and made is_address_nonce_ahead
public for the wallet's single-dispatch broadcast. #5556 replaced both
failovers with the plain shared retry, so that a concrete refusal ends
sending, with tests saying so (should_stop_on_nonce_rejection_without_
rebuilding_or_resending, should_stop_on_concrete_rejection_regardless_of_
node_failover_settings), and removed is_address_nonce_ahead and
is_missing_transition_owner. The file is taken as v5.1-dev has it.

The wallet's single-dispatch broadcast (broadcast_outcome::track_delivery)
still goes on to the next dispatch after a nonce-ahead answer, as #5837
meant: the classifier moves into broadcast_outcome.rs, unchanged, and its
doc no longer says the SDK's own broadcast fails over too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the single-dispatch broadcast and the spend contract

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

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

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

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

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

@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 exact review head still has two blocking wallet-state defects. Private reservation and cleanup mutations are not consistently fenced to the originating wallet generation, and restoration can discharge a journal after a transient persistence flush failure merely because the request is present in memory. The module-size concern remains a valid maintainability suggestion; Rust workspace checks passed, while Swift and JS Evo checks were still in progress in the supplied CI snapshot.

🔴 2 blocking | 🟡 1 suggestion(s)

Review provenance

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

  • Triage: critical by gpt-6.1-sol (effort low) — This large, intricate diff changes critical funds-movement and coin-selection paths in shielded operations, cryptographic key handling and signing for private contact requests, durable wallet persistence, and FFI error/result boundaries, including functions such as PlatformWallet::send_private_contact_request and shielded broadcast handling.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); 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, gpt-6-astra — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — rust-quality (completed, effort xhigh); agent phase2-reviewer
  • Model comparison: every Phase-2 reviewer also ran on gpt-6-astra; the verifier weighed both sets without knowing which model wrote which
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review (PastaClaw). How to work through them:
- Verify each finding against the code; fix only what is real, as narrowly as the finding.
- If a finding is wrong, decline it on its thread in one or two sentences; the bot answers once and stops.
- To defer one, link a tracking issue on its thread (or reply asking the bot to file one).
- Suggestions are optional: fix them forward in a later PR rather than pushing another round just for them.
- Address every current finding in one push: self-review the full diff first, and don't push piecemeal.
- Keep the PR's scope; don't grow it to satisfy hardening suggestions.
- Carried items were raised in an earlier round: read the thread first, and don't re-fix what you already fixed, declined or deferred there. Disputed and deferred items are already handled and are not listed.

In `packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:1007-1020: Fence private contact reservations to the wallet generation
  `send_private_request` reserves the sender/recipient pair through `with_managed`, but `with_managed` looks up `PlatformWalletInfo` by `wallet_id` only and does not compare the handle's `WalletGeneration`. A stale private-send handle can finish its preflight read, then have its wallet removed and recreated under the same ID before this reservation runs; the reservation is consequently installed on the replacement generation. The later payment-generation guard does not prevent this because it occurs after reservation and preparation, and both `PrivateReplyReservation::release` and its detached `Drop` cleanup call `release_reservation` and `store_private_payment_marker` with the wallet ID alone, so stale cleanup can also mutate the replacement. Use the generation-aware `current_wallet_info_mut`/`WalletGeneration` checks for reservation, marker, and cleanup mutations, making stale handles no-ops against replacements while retaining attempt ownership.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs:1-60: Split the 12k-line private contact requests module
  `private_contact_requests.rs` is approximately 12,609 lines at this head, alongside other similarly large wallet orchestration modules. Keeping scan progress, journal restoration, send/accept orchestration, and extensive test helpers in one module makes the ownership and error paths difficult to navigate and review and increases incremental compilation cost. Extracting these responsibilities into focused submodules under the private-contact network area would make the lifecycle boundaries clearer without changing behavior.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:3171-3212: Flush settled contact records before restoration discharges their journal
  `restore_journaled_private_payments` treats a journal record as settled when the current in-memory outgoing request records or supersedes its account reference. `restore` then discharges that journal whenever `buffered_writes_lost()` is false, but that flag only identifies a later permanent flush failure; it does not prove that the current contact record and cursor changes are durable. A private sync can record the request in memory, encounter a transient flush failure, retain the read-back notice, and then run restoration before the retry succeeds. The restoration can discharge the journal and publish the discharge while the contact row is still only buffered; if a later flush permanently drops that buffer, restart can find neither the request nor its journal protection. Restoration must require successful durability of the settled contact changeset, or defer journal discharge until the pending read-back notification has been durably acknowledged.

@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Your move: thepastaclaw requested changes on this head; dismiss the review or push a fix; thepastaclaw left review threads unresolved; resolve them.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed and removed waiting-bots Waiting for the review bots to report on this head labels Nov 10, 2026
QuantumExplorer and others added 7 commits November 10, 2026 21:47
…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>
@github-actions

Copy link
Copy Markdown
Contributor

Your move: thepastaclaw left review threads unresolved; resolve them.
Full checklist in the description.

@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.

@github-actions github-actions Bot added waiting-bots Waiting for the review bots to report on this head and removed waiting-self-review Waiting for the author to post /self-reviewed labels Nov 10, 2026
@QuantumExplorer

Copy link
Copy Markdown
Member Author

Why this PR is large, what in it is interim, and how it gets finished

In short: most of this PR is the wallet making up, on the client, for something Platform cannot give it yet: a proof that a state transition executed, or that it can no longer execute. That gap is tracked in #4270. The outcome handling here is interim and is meant to be replaced once that proof exists.

The gap underneath

Because of that, after a broadcast that may have reached the network the wallet keeps the payment's notes reserved and the contact pair held, and releases them only on evidence it can verify itself: the entry read back from the store, the request proved on Platform, or the spend's anchor proved pruned.

What the wallet is compensating for

Missing What this PR carries instead
A proved outcome for a transition The payment journal, the expiry evidence, and holds that can last up to about 1100 blocks
An expiry height on a signed transition "Unconfirmed" has no deadline; a shielded spend is bounded only by anchor pruning
Idempotent private entries (a public request has a unique index; a private entry is an opaque blob the store does not deduplicate) The wallet alone guarantees "never pay twice" across crashes, restarts, wallet removal and lost saves
Reads pinned to a height The read floors, the largest proved store size, and the handling of nodes that lag
One pending-operation record in the wallet Four mechanisms doing the same job: shielded redrive records, the private payment journal, the identity-shield guard, and the in-memory public request hold

Size

At 54ae054b78: 31,189 lines added, 58% of them tests. Non-test code by area:

Area Lines Share
Private requests: send, scan, accept, holds 2,718 21%
Public requests and identity state 2,641 20%
Wallet lifecycle and persistence 2,345 18%
Broadcast outcomes for all shielded spends, shields and withdrawals 2,216 17%
Shielded store: journal, recovery records, expiry evidence 2,163 16%
FFI, signer, SDK, Swift and Kotlin 1,044 8%

The feature itself was about 1,900 non-test lines when it was first reviewed. The rest was written in response to review.

How it grew under review

The PR was 2,800 lines at its first review. Up to 207bd72 there were 26 thepastaclaw reviews, which listed 84 blocking findings in total:

Origin of the finding Count
Code that was in the PR at the first review 17
Fix code that an earlier review had already seen 24
Fix code changed since the previous review 16
Carried over from an earlier review, still open 27

So 40 of the 57 newly posted findings were in code written as fixes, and every review listed between 1 and 6. The count has not trended down. Each posting is classified by the age of the lines it points at, which is an approximation: some findings were posted again at new lines after a fix.

What a proof of execution or non-execution would replace

  • The expiry evidence (pruned anchors, the bound for records without a height, the store read that proves an entry absent).
  • The read-back after paying and what surrounds it (the notices, their save handling, the release on restore). A proof of execution is enough to record the request as sent.
  • The settling logic of the public request hold, and feat(sdk): prove a state transition's outcome, and add getRecentStateTransitionStatus #5823's input-based inference.

In their place: one saved record per transition, written before broadcast and resolved by that proof, for every kind of transition. For that to work the proof has to make non-execution final (an expiry height, or "can no longer be admitted"), not only "not executed as of height H", and for the private payment it has to cover the appended entry, not only the spent notes.

It would not replace the read of the private store before sending (that discovers other parties' and other devices' requests), protection against paying twice from two devices, or saving the record before broadcasting.

Limits accepted here

They are listed under "Costs" in the description and in the wallet README. The main ones: a payment that never lands holds its pair until its anchor is pruned, longer on an idle pool; the hold on an unconfirmed public request is in memory, so after a restart a private request to that pair is not held back while the public one may still land (a second public request is still prevented by DashPay's unique index and a proved lookup before signing); a build without shielded has no journal to read.

Proposed way to finish

  • Freeze the scope. Fix a finding when it can lose funds.
  • Where a finding is a hole in an interim release path, remove or narrow that release so the wallet holds, rather than adding more evidence logic. Holding is always fund-safe.
  • Defer findings that come down to "the wallet cannot know the outcome" to Bind affected-state proofs to transition execution #4270.
  • The broadcast outcome changes affect every shielded spend, shield and withdrawal for existing users, not only the new feature. That area deserves focused review and a testnet run before release: nothing here has been run against a live network.
  • After merge: move the test module out of private_contact_requests.rs (about 10,900 of its 13,640 lines) as a move-only change, and replace the inference code in a follow-up once the proof exists.

🤖 Written with Claude Code

@QuantumExplorer
QuantumExplorer merged commit 623530c into v5.1-dev Nov 10, 2026
34 of 36 checks passed
@QuantumExplorer
QuantumExplorer deleted the feat/platform-wallet-private-contact-requests branch November 10, 2026 17:00
QuantumExplorer added a commit that referenced this pull request Nov 10, 2026
#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>
QuantumExplorer added a commit that referenced this pull request Nov 10, 2026
…in-private-contact-requests

Brings in v5.1-dev through the FFI branch, now that #5810 is merged
there, and the registry's corrected next allocatable result code (77).
No conflicts; nothing in the Kotlin SDK, the JNI or the app changes.

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