Skip to content

fix(platform-wallet): keep contact request holds consistent after an unconfirmed send - #5908

Open
QuantumExplorer wants to merge 3 commits into
v5.1-devfrom
fix/platform-wallet-contact-request-holds
Open

QuantumExplorer wants to merge 3 commits into
v5.1-devfrom
fix/platform-wallet-contact-request-holds

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Basic explanation

What this does: When the wallet sends a public DashPay contact request and cannot tell whether it reached the network, it keeps the request "held" and may send the same signed bytes again later. This PR tightens what happens while a request is held or being sent. The wallet no longer sends those bytes again once it knows the two identities exchange requests privately. Only one public request to a recipient can be in progress at a time. A retry that finds the first request already on Platform returns that request instead of signing a second one. It also closes three smaller gaps in the same code.

Value: A pair the wallet knows to be private is not published by a background retry. A user who retries after an unclear outcome does not pay for a second request. A host gets "pending" instead of an unknown error for a private request that was paid for.

Risks: Low. Wallet code only, no consensus change. Two behaviours a host can notice: a second public send to a recipient while one is in progress is now refused with code 76 where it used to sign a second request, and a held request is broadcast again in fewer situations, so in those it stays held until a proved read settles it or the process ends. Nothing here was run against a live network.

Issue being fixed or feature implemented

Follow-up to #5810, #5812 and #5813, from a review of their last pushes. The outcome handling this touches is still interim until Platform can prove that a transition executed or cannot execute (#4270).

Situation Before After
A public request is held as unconfirmed, and the pair has since become private (the recipient's request was read from the private store, or a private payment holds the pair) Every public sync, and every send or accept to the pair, broadcast the held bytes again The bytes go out again only where a public request to the recipient would still go out. A private send never broadcasts them
Two public sends to one recipient at the same time Each claimed the pair and signed its own request The second is refused with PublicContactRequestUnconfirmed (FFI 76), nothing signed
A send retried after code 20, once the first request landed Recorded the first request, then signed a second one under the next version Records the first request and returns it as the request sent
The same retry, when the receiving account for the pair cannot be registered The registration error sent the retry on to sign a second request The recorded request is returned; the account is left to the next sync
Two reads settle the same held request, and a new request is sent between them The later read recorded the older request over the newer one A read records the request it found only while its own hold still stands; otherwise it records nothing
A held request is broadcast again while its wallet is being removed Nothing kept the removal from running between the check and the dispatch Both run under the wallet's lifecycle gate
A flush fails for good on another thread while a read-back or a restoration is about to flush The flush that followed succeeded over an empty buffer and its result was trusted A flush is trusted only when, after it, no other flush is in progress and none has failed for good
A flush of the same wallet id's records fails for good outside the wallet's own persister: the manager's, before it publishes a wallet, or one through a handle of a wallet removed under that id The wallet's persister did not know, and trusted a later flush of its own Every flush of a wallet id is counted in one ledger per id, so the wallet knows
A handle whose wallet was removed and created again under the same id registers a contact account, completes an external account (its marker and label), or adopts a request while accepting Written to the new wallet Refused as not found, or left alone
A private request is paid for, and its wallet is replaced before the send reads it back WalletNotFound (unknown error over FFI) PrivateContactRequestPending (FFI 71)

The retry case, step by step:

send(alice -> bob)            => code 20, request A held as unconfirmed
... A executes on Platform ...
send(alice -> bob) again
  before: records A, then builds version 1 past it, signs and pays for request B
  after:  records A and returns A; nothing signed, nothing broadcast

The concurrent case:

send(alice -> bob)  in progress (claimed, not yet recorded)
send(alice -> bob)  a second call
  before: claims the pair too, signs a second request
  after:  Err(PublicContactRequestUnconfirmed(bob)), nothing signed

What was done?

  • network/contact_requests.rs
    • settle_unconfirmed_public_request takes broadcast_again from its caller and returns the request when it recorded one. A public send passes whether its own read of the private store succeeded, the public sync passes true, a private send passes false.
    • New may_broadcast_held_public_request: before the held bytes go out again it checks, under the wallet lock and on the handle's wallet generation, that the hold is still the same attempt, that private payments are restored, that the sender's read of the private store is not rewound, and that no private request holds the pair (private_pair_refusal, now shared with claim_public_pair).
    • send_public_contact_request returns the request its settle step just recorded.
    • New record_settled_public_request: a settle records the request it found, and releases the hold, only while its own attempt still holds the pair, checked in the lock it records in. When the hold is gone it records nothing, and answers the request only if that is still the one tracked.
    • record_sent_contact_request logs a receiving account registration that fails and still returns the recorded request; the sync registers the account later.
    • The repeat broadcast takes the wallet generation's lifecycle gate before its check and keeps it through the dispatch.
    • note_external_account_registered and store_contact_account_label act only on the handle's wallet generation.
    • claim_public_pair refuses a second claim on a pair.
    • The accept path's adopted incoming request is written through current_wallet_info_mut.
  • state/managed_identity: one public claim per recipient (claim_public_contact_request returns None when one is held); DashPayState::private_store_read_rewound.
  • network/contacts.rs: register_contact_account and register_external_contact_account look their wallet up through the generation-fenced helpers, the external registration's early already-exists check included (current_wallet_info).
  • network/private_contact_requests.rs: after the payment, a failed read-back on the handle reports the request pending.
  • persister.rs, platform_wallet.rs: note_read_back_once_durable and settled_requests_durable both go through the new WalletPersister::flush_durably. A flush is counted as in progress from before the backend is called until its outcome is recorded, and flush_durably trusts its own flush only when, afterwards, none is in progress and none has failed for good. A notice made while another flush is in progress is kept for a later call.
  • changeset/flush_ledger.rs (new), changeset/core_bridge.rs, manager/mod.rs: the count of flushes in progress and the count of flushes that failed for good live in a FlushLedger per wallet id, kept in the manager's DurableCursorState beside the per-wallet cursors, as a backend buffers a wallet's records under its id whoever stored them. PlatformWallet::new gives the wallet's persister the ledger of its id, and the manager's own flush of a replaced backfill record (settle_published_backfill) goes through the same one. A persister remembers how many failures the ledger held when it was made, so a wallet loaded or created after a failure starts clean.
  • README.md, ERROR_CODE_REGISTRY.md, error.rs: what 76 now also covers, when held bytes are broadcast again, and that a request which never lands and whose nonce is never used stays held for the life of the process.

Left as it is, on purpose:

  • DashPayView::public_contact_request_unconfirmed still reports only a request held as unconfirmed, not one being sent. Both example apps poll it during their own sends, so widening it would show their "being checked" text during a normal send; that belongs with an app change.
  • The repeat broadcast is decided from what the wallet has read, not from a fresh read of the private store: a private request no private sync has read in yet does not stop it. The same holds for a first send between its store read and its broadcast. A lock shared with the private sync would not change it: what matters is when a request reached the store, not when this wallet read it. Reading a request in cannot be refused, only delayed, so the lock would turn "check, read in, dispatch" into "check, dispatch, read in" and the same bytes would go out. The wallet's own private send cannot come in between: it is refused, in the lock it reserves in, while the hold stands.
  • The hold is still placed after the broadcast and the wait return.
  • Contact account registration does not take the lifecycle gate. It checks the generation, stores and inserts under one manager write lock, so it is ordered wholly before a removal's manager step, the same as a registration that finished just before the removal began, and the removal deletes no stored rows. Taking the gate there would also re-enter it from record_sent_contact_request, whose callers hold it.
  • After a private payment, any failure to read the request back reports it pending, not only the two stale-handle errors: any other report invites a retry that pays again. The error is logged.

How Has This Been Tested?

New tests, each checked to fail with its fix taken out:

  • should_broadcast_an_unconfirmed_public_request_again_only_while_its_pair_is_clear
  • should_refuse_a_second_public_send_while_one_is_being_sent
  • should_return_the_unconfirmed_request_a_retry_finds_on_platform
  • should_register_no_contact_account_on_a_replacement_from_a_stale_handle
  • should_report_a_paid_private_request_pending_once_its_wallet_is_replaced
  • should_note_no_read_back_when_a_flush_fails_for_good_during_its_own
  • should_note_no_read_back_while_another_flush_has_not_returned
  • should_keep_a_settled_record_when_a_flush_fails_for_good_during_the_restoration
  • should_return_the_recorded_request_when_its_receiving_account_is_not_registered
  • should_not_broadcast_a_held_request_again_while_its_wallet_is_being_removed
  • should_complete_no_external_account_on_a_replacement_from_a_stale_handle
  • should_record_nothing_from_a_settle_whose_hold_was_settled_meanwhile
  • should_count_its_backfill_flush_in_the_wallet_ids_flush_ledger
  • should_count_a_removed_wallets_flush_against_its_successor

Run locally:

  • cargo test -p platform-wallet --all-features --lib -- wallet::identity:: wallet::persister:: wallet::platform_wallet:: manager:: changeset::: 1070 passed.
  • cargo fmt --all -- --check: clean.
  • cargo clippy --package platform-wallet --package platform-wallet-ffi --all-targets --all-features --locked -- --no-deps -D warnings: clean.
  • cargo check --package platform-wallet --all-targets --locked (without the shielded feature): compiles. Its two dead-code warnings are in asset_lock, which this PR does not touch.

Not run locally: the rest of the wallet suite and the Swift and Kotlin builds, which CI runs, and anything against a live network.

Breaking Changes

None. No consensus or protocol version change.

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 · 974c9da

  • Bots — thepastaclaw 3 threads unresolved — resolve them
  • Self-review — post /self-reviewed
  • Build running
  • Approvals
    • rs-platform-wallet-ffi (packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md) — HashEngineering or ZocoLini or llbartekll or romchornyi
    • rs-platform-wallet (packages/rs-platform-wallet/README.md, packages/rs-platform-wallet/src/changeset/core_bridge.rs, packages/rs-platform-wallet/src/changeset/flush_ledger.rs and 11 more) — HashEngineering or ZocoLini or llbartekll or romchornyi

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

…unconfirmed send

A public contact request whose broadcast may have been delivered is held
until a proved read settles it. While it is held or being sent:

- Its signed bytes are broadcast again only where a public request to the
  recipient would still go out by what the wallet tracks: not once a
  private request holds the pair or their requests came or went privately,
  not while the sender's read of the private store is rewound, not while
  private payments may be unrestored, and never from a private send. A
  public send does it only after its own store read succeeded.
- One public send holds a pair at a time: a second to the same recipient
  is refused with PublicContactRequestUnconfirmed, with nothing signed.
- A send that finds its unconfirmed request on Platform records it and
  returns it as the request sent, instead of building another past it.

Also:

- The lost-writes flag is read again after the flush, in
  note_read_back_once_durable and settled_requests_durable.
- register_contact_account, register_external_contact_account and the
  accept path's adopted request act only on the handle's wallet generation.
- A private request paid for whose read-back fails on the handle is
  reported pending.

The README, the error's documentation and the FFI code registry describe
what code 76 now also covers, when held bytes are broadcast again, and
that a request which never lands and whose nonce is never used stays held
for the life of the process.

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

coderabbitai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next included review available in 36 minutes.

Check out review usage here.

View limit details

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

Learn how review limits work.

Review configuration:

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 0fc32ab and 974c9da.

Files selected for processing: 15

  • packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
  • packages/rs-platform-wallet/README.md
  • packages/rs-platform-wallet/src/changeset/core_bridge.rs
  • packages/rs-platform-wallet/src/changeset/flush_ledger.rs
  • packages/rs-platform-wallet/src/changeset/mod.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/manager/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs
  • packages/rs-platform-wallet/src/wallet/persister.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

Share

@github-actions

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

thepastaclaw commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

🔍 Review in progress — actively reviewing now (commit 974c9da) · triage: critical
█████░░░░░░░░░░░░░░░ 23% · about 1 h 20 min left · running for 23 min
✅ triage → ⏳ Phase 2 (0/4 lanes) → ✅ Phase 1 → ▫️ verify 2 → ▫️ publish
Estimated from recent reviews of this tier · updated 15:46 UTC · live progress

@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

The PR improves duplicate public-request handling, but several lifecycle and durability races still undermine its stated guarantees. In particular, a recorded request can be followed by a second send, stale generations can mutate or persist state into replacements, held broadcasts are not serialized with private-state changes, and the lost-write check is not atomic with journal discharge. Wallet tests passed; the Swift build remains queued.

🔴 5 blocking | 🟡 3 suggestion(s)

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

🔴 Blocking: Do not retry with a new reference after recording the settled request
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1286-1315

record_sent_contact_request persists the discovered request and releases the unconfirmed hold before registering the receiving account. If register_contact_account then fails, settle_unconfirmed_public_request_logged converts that error to None, so the public send continues to claim the pair and prepare another request. Because the first request is already tracked, preparation advances to a new account-reference version and signs/broadcasts a second request, charging the user again. Return or adopt the recorded request independently of receiving-account registration, and retry that registration separately.

source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, rust-quality, security-auditor)

🔴 Blocking: Fence external-account completion callbacks to the wallet generation
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:4470-4536

note_external_account_registered and store_contact_account_label reacquire the wallet by wallet_id only. They do not verify self.generation, even though they are called after external-account registration returns and may run after the original wallet was removed and recreated. A stale drain or accept operation can therefore stamp external_account_reference, clear payment_channel_broken, or persist a label into the replacement generation. In particular, stamping the replacement's live marker can suppress the rebuild that should restore its actual xpub. Thread the generation through these completion helpers and validate it under the same mutation lock before changing memory or persistence.

source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, rust-quality, security-auditor)

🟡 Suggestion: Apply generation fencing to the external-account fast path
packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs:485-505

The early AlreadyExisted check looks up the wallet by ID alone, unlike the later insertion path. A stale handle can therefore return AlreadyExisted against a same-ID replacement and cause callers such as the deferred-registration drain to clear the queued operation without reaching the generation check. This can suppress a needed rebuild when the replacement's existing account is stale or was built from a different request payload. Make the fast-path lookup generation-aware as well.

source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, rust-quality, security-auditor)

Review provenance

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

  • Triage: critical by gpt-6.1-sol (effort low) — This is a large, intricate wallet change that alters paid contact-request sending, signing, broadcasting, retry, and persistence behavior, directly affecting funds movement and signature handling in contact_requests.rs and 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 — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review (PastaClaw). How to work through them:
- Verify each finding against the code; fix only what is real, as narrowly as the finding.
- If a finding is wrong, decline it on its thread in one or two sentences; the bot answers once and stops.
- To defer one, link a tracking issue on its thread (or reply asking the bot to file one).
- Suggestions don't block the merge, but most are worth fixing in this PR; judge each on its merits.
- 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/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1286-1315: Do not retry with a new reference after recording the settled request
  `record_sent_contact_request` persists the discovered request and releases the unconfirmed hold before registering the receiving account. If `register_contact_account` then fails, `settle_unconfirmed_public_request_logged` converts that error to `None`, so the public send continues to claim the pair and prepare another request. Because the first request is already tracked, preparation advances to a new account-reference version and signs/broadcasts a second request, charging the user again. Return or adopt the recorded request independently of receiving-account registration, and retry that registration separately.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:4470-4536: Fence external-account completion callbacks to the wallet generation
  `note_external_account_registered` and `store_contact_account_label` reacquire the wallet by `wallet_id` only. They do not verify `self.generation`, even though they are called after external-account registration returns and may run after the original wallet was removed and recreated. A stale drain or accept operation can therefore stamp `external_account_reference`, clear `payment_channel_broken`, or persist a label into the replacement generation. In particular, stamping the replacement's live marker can suppress the rebuild that should restore its actual xpub. Thread the generation through these completion helpers and validate it under the same mutation lock before changing memory or persistence.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1473-1481: Serialize held-request eligibility with rebroadcast
  `may_broadcast_held_public_request` checks the generation and private-pair state under a read lock, returns a boolean, and releases the lock before `broadcast_tracking_delivery` runs. A concurrent private-store sync can ingest a private request in that interval, causing the held public bytes to be broadcast after the pair became private. Teardown can also replace the wallet after the check and before submission. The lifecycle gate must cover both the liveness check and dispatch, and private-state ingestion must share a per-pair reservation or equivalent gate so the privacy decision cannot be invalidated before the broadcast.

In `packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs:218-279: Hold the generation lifecycle gate across contact-account persistence
  `register_contact_account` and the external-account path acquire the wallet-manager lock and perform `persister.store` without first acquiring `self.generation.payment_guard()`. The generation check therefore only protects the in-memory lookup. Teardown acquires the generation's exclusive gate before waiting for the manager lock, so registration can persist an account for the old generation while teardown is waiting; a same-ID replacement can then load that stale account and address-pool state. Acquire the generation guard before the manager lock and retain it through the persistence and in-memory mutations, or use an equivalent generation-bound operation guard for both registration paths.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs:485-505: Apply generation fencing to the external-account fast path
  The early `AlreadyExisted` check looks up the wallet by ID alone, unlike the later insertion path. A stale handle can therefore return `AlreadyExisted` against a same-ID replacement and cause callers such as the deferred-registration drain to clear the queued operation without reaching the generation check. This can suppress a needed rebuild when the replacement's existing account is stale or was built from a different request payload. Make the fast-path lookup generation-aware as well.

In `packages/rs-platform-wallet/src/wallet/persister.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/persister.rs:337-348: Linearize durability certification with flush failure
  The post-flush `buffered_writes_lost()` load is not atomic with the subsequent `note_read_back_private_entries(pending)`. Another concurrent flush can fail permanently after line 340 and set the latch before line 348 records the commitments, so read-back is certified as durable even though the buffer was discarded. `PrivatePaymentRestore::settled_requests_durable` has the same check/flush/check gap before its caller discharges journal records. A restart can then lose the local request record and release the hold that prevents a repeated private payment. Use a shared durability/flush lock or an epoch protocol that covers the flush result, certification, and journal discharge as one linearized operation.

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:1796-1817: Do not map every post-payment read error to pending
  After payment, the read-back uses `unwrap_or_else` to map every `with_managed` error to `None` and then `PrivateContactRequestPending`. Today the closure is infallible and the expected errors are stale-handle or removed-identity cases, but a future fallible closure or persistence-related error would be silently reported as pending instead of surfacing the real failure. Match the intended stale-handle and identity-removal variants explicitly and propagate unrelated errors.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:3136-3152: Add a direct test for settled-request durability certification
  `settled_requests_durable` gained the same post-flush lost-write check as `note_read_back_once_durable`, but the PR only tests the persister-side helper. The restore path has a different caller and discharges journal records based on this result, so a regression that removes the second read or changes its flush branch would not be caught. Add a race test covering a permanent flush failure overtaking the restore path's own flush before settled records are discharged.

@github-actions

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 13, 2026
…holds

- A request recorded as sent is returned whether or not its receiving
  account could be registered: a registration that fails is logged and
  left to the next sync. Its error used to send a retry on to sign a
  second request past the one just recorded.
- The repeat broadcast of a held public request takes the wallet
  generation's lifecycle gate before its check and keeps it through the
  dispatch, so nothing goes out for a wallet that is being removed.
- note_external_account_registered, store_contact_account_label and the
  external registration's early already-exists check act only on the
  handle's wallet generation.
- Durability is certified in one place, WalletPersister::flush_durably.
  A flush is counted as in progress from before the backend is called
  until its outcome is recorded, and a flush is trusted only when, after
  it, none is in progress and none has failed for good. A flush that had
  failed in the backend but not yet recorded it could otherwise be missed.
  A read-back noted while another flush is in progress is kept for a
  later call.

Tests cover each, and the restoration path's use of the durability check.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 13, 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.

@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 PR fixes several previously identified wallet-generation, duplicate-send, and durability paths, and the available Rust workspace and Swift CI checks pass; wallet-specific Rust tests were skipped. Two blocking races remain in the changed contact-request and persistence coordination: private state can become tracked between the held-request eligibility check and rebroadcast, and a stale settlement can overwrite a newer outgoing request; a separate direct backend flush also bypasses the new durability certification state.

🔴 3 blocking

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

🔴 Blocking: Linearize durability certification with flush failure
packages/rs-platform-wallet/src/manager/mod.rs:839-847

WalletPersister::flush_durably tracks in-flight flushes and fatal loss only in state owned by that WalletPersister handle. However, PlatformWalletManager::settle_published_backfill still calls the shared backend directly through self.persister.flush(wallet_id). A fatal direct flush can drop the backend buffer without incrementing this handle's flushes_in_flight or setting its buffered_writes_lost latch; a later flush_durably can then successfully flush an empty buffer and certify records that were lost. Move the durability epoch/loss state to the shared backend or manager, or route every flush for a wallet through the same shared durability coordinator.

source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, rust-quality, security-auditor)

Still open from earlier rounds (1)

  • 🔴 Blocking: Serialize held-request eligibility with rebroadcast — packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1493-1500 (thread)
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: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — This is a large, intricate wallet change that alters paid contact-request signing, retry, persistence, and concurrent lifecycle behavior, directly affecting funds movement and key-handling paths.
  • 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 (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 don't block the merge, but most are worth fixing in this PR; judge each on its merits.
- 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/manager/mod.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/mod.rs:839-847: Linearize durability certification with flush failure
  `WalletPersister::flush_durably` tracks in-flight flushes and fatal loss only in state owned by that `WalletPersister` handle. However, `PlatformWalletManager::settle_published_backfill` still calls the shared backend directly through `self.persister.flush(wallet_id)`. A fatal direct flush can drop the backend buffer without incrementing this handle's `flushes_in_flight` or setting its `buffered_writes_lost` latch; a later `flush_durably` can then successfully flush an empty buffer and certify records that were lost. Move the durability epoch/loss state to the shared backend or manager, or route every flush for a wallet through the same shared durability coordinator.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1515-1525: Guard settled-request recording against a newer attempt
  Settlement snapshots `held` before performing its SDK and document reads, but it does not revalidate that attempt before recording a found request. Two settlement calls can both find request A; the first records A and releases the hold, then a new send records newer request B. The second stale settlement subsequently enters this branch and calls `record_sent_contact_request` for A. `add_sent_contact_request` treats the differing reference as a rotation and overwrites B in persisted and in-memory contact state, so later reference rotation can reuse or derive from stale state. Revalidate the held attempt and current outgoing request under the same manager critical section before applying the settlement result, or serialize settlements per pair.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1493-1500: Serialize held-request eligibility with rebroadcast
  (carried; thread: https://github.com/dashpay/platform/pull/5908#discussion_r4562023470)
Out-of-scope follow-up suggestions (1)

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

  • Coordinate first public send with private-store ingestion — The same tracked-state window exists for a fresh public send: after read_private_store_before_deciding returns, private-store ingestion can record the pair before claim_public_pair and signing. This is the pre-existing counterpart of the held-retry race and is explicitly left unchanged by this PR, but it can publish a relationship that became private during the send.
    • Follow-up: Open a follow-up issue or PR to coordinate private-store ingestion with public contact-request claiming and signing.

…sh of a wallet id together

- A settle of an unconfirmed public request records the request it found
  on Platform only while its own hold still stands, checked in the lock
  it records in. Its reads run with no lock held, so another settle of
  the same hold can record the request and release the hold first, and a
  request sent after that is the one tracked: the later settle used to
  record the older request over it. It now records nothing, and answers
  the request only when that is still the one tracked.
- The flushes of the records under a wallet id are counted in one
  ledger per id, kept with the manager's per-wallet state: how many are
  in progress and how many failed for good. A wallet's persister, the
  persister of a wallet removed or created under the same id, and the
  manager's own flush of a replaced backfill record all go through it.
  The count used to live in each wallet's persister, so a flush made
  anywhere else could drop the buffer without that wallet knowing, and a
  later flush of its own would vouch for records that were gone. A
  wallet made after such a failure starts clean, as it tracks what it
  read from disk.

Tests cover each: a settle parked between its reads and its recording,
the manager's flush, and a removed wallet's flush against its successor.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Client Only waiting-self-review Waiting for the author to post /self-reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants