Skip to content

fix(platform-wallet): age-guard the finalized-transaction handle broadcast - #4309

Merged
shumkov merged 32 commits into
v4.2-devfrom
followup/v4.1/v2-handle-age-guard
Aug 30, 2026
Merged

fix(platform-wallet): age-guard the finalized-transaction handle broadcast#4309
shumkov merged 32 commits into
v4.2-devfrom
followup/v4.1/v2-handle-age-guard

Conversation

@bfoss765

@bfoss765 bfoss765 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Continues #4196 — moved from a fork branch to an in-repo branch so maintainers can push changes directly, per review request. Full review history on #4196.

What this does

Two related fixes to the same hazard: a funding reservation can be swept out from under a transaction that is already signed, or already on the wire, and re-selected by an unrelated build.

key-wallet's ReservationSet sweeps any reservation older than RESERVATION_TTL_BLOCKS (24) and returns the outpoints to the selectable pool with no ownership or generation check. Two windows were unguarded against that.

1. Age-guard the finalized-transaction handle

A core_wallet_tx_builder_finalize handle can be pinned by the host for an arbitrary time before broadcast_finalized_transaction. Held past the sweep, it would broadcast against inputs another build had since re-reserved.

  • Shared bound. RESERVATION_MAX_AGE_BLOCKS (20) and reservation_expired() move to wallet::reservations, so the deferred registry and the finalized-handle path measure age against the same number. Kept strictly below key-wallet's TTL of 24 on the same last_processed_height clock, so the guard always trips before a sweep could have happened.
  • Guarded op. The check runs inside dispatch_unexpired, atomically with dispatch under the wallet-manager read guard — not by the caller beforehand, where it would go stale before the send. It runs after the existing generation-identity check, matching the registry's order.
  • Refusal reconciles. The FFI wrapper has already consumed the opaque handle by then, so no follow-up abandon is possible. The refusal releases owner-guarded (release_reservation_if_owner, safe at any age — a no-op once ownership transferred), freeing the still-owned inputs for the instructed immediate rebuild.
  • Error code. A token-less PlatformWalletError::StaleReservation reuses the existing FFI ErrorStaleReservationToken (34); no new code is allocated. Reuse is documented on both sides.

2. Fence in-broadcast inputs until the spend is observed

The age check alone is not an ordering invariant. dispatch_unexpired must drop the manager guard before the broadcaster await — holding it starves the dash-spv mempool pipeline that the wait itself depends on — and the broadcaster can suspend before submission. Catch-up can advance the clock in that gap, the TTL can sweep, and a concurrent build can re-reserve the same inputs while the signed transaction is still in flight.

  • The pin. WalletGeneration::pin_in_broadcast records the dispatched transaction's outpoints on the manager-registered generation, installed before the guard drops so check-and-pin is one atomic step. It has no TTL while the dispatch is in flight and is released by Drop, so cancellation and unwind are covered without a special case.
  • The check. Every coin-selection choke point — CoreWallet::finalize_transaction, the DashPay contact-payment build, the asset-lock build — calls in_broadcast_conflict immediately after it reserves its selection, still under the manager write guard the sweep runs under, and refuses with PlatformWalletError::InputMidBroadcast after releasing its own fresh reservation.
  • The release is evidence, not elapsed chain. New SpendObservationHandler (wired into the manager's event fan-out) releases a fence when the wallet actually observes the outpoints spent, off WalletEvent::TransactionDetected and BlockProcessed. It is built on spent_outpoints, the same per-record input walk that produces CoreChangeSet::spent_utxos, so the fence and the persisted spent set cannot diverge. Either spend shape releases — the dispatch's own transaction or a competing one — because after either the outpoint is out of the selectable set.
  • Orphan backstop only. IN_BROADCAST_FENCE_ORPHAN_TIMEOUT (1 h, a monotonic Instant) stops a transaction that is never observed from stranding its inputs for the life of the process. It is a liveness valve, not the safety argument.
  • Settle policy. A definitive pre-send rejection (BroadcastError::Rejected, contractually pre-send) frees the outpoints outright. Every other outcome — accepted, ambiguous MaybeSent, cancellation, unwind — opens the pending-spend phase.

Why the fence grew through review

Rounds 2–4 bounded the pending-spend phase at last_processed_height + N and disagreed only about where the height was sampled. All three are unsound for the same reason: during catch-up the wallet advances that height by thousands of blocks in seconds, over blocks mined before the transaction was submitted, so elapsed height is evidence about the chain's past and never about this transaction. A routine historical sync could retire a fence protecting a transaction that had just gone to the network. Hence the observation-based release and the monotonic backstop — and pin_in_broadcast deliberately accepts no height at either end, so the mis-anchoring is unrepresentable rather than merely corrected.

Error surfaces

Rust FFI code Note
PlatformWalletError::StaleReservation ErrorStaleReservationToken (34) Reuses the deferred-token code; both mean "rebuild", neither touched the network
PlatformWalletError::InputMidBroadcast { outpoint } ErrorUnknown (99) Deliberate, and an explicit arm rather than the catch-all. The numeric space is a cross-PR registry and a new value must be mirrored into the Swift and Kotlin enums; claiming one is a separate coordinated change and a one-line edit here

Host bindings (Kotlin DashSdkError/ManagedCoreWallet, Swift ManagedCoreWallet) document that code 34 now covers both deferred-payment surfaces, and that broadcastTransaction consumes the handle on every outcome including the stale refusal.

Tests

Age guard: fresh handle broadcasts; aged handle refuses with StaleReservation and the refusal itself releases for an immediate rebuild; the age is re-checked at dispatch rather than by the caller; exact threshold boundary (BIP44/BIP32); FFI mapping to the shared code; terminal FFI stale-broadcast, aged free, and aged failure-path abandon.

Fence: a pin blocks re-selection until dispatch returns; the fence survives a full historical catch-up advance; an observed spend of the dispatched transaction releases it; a competing spend also releases it; observation reaches only the matching registered generation; the orphan backstop is the only timeout and catch-up cannot move it; a cancelled dispatch keeps its fence across catch-up; a definitive rejection installs no fence; the dispatching→pending handoff is never observable half-done (driven deterministically by a cfg(test) settle-boundary hook, not by scheduler luck); the manager constructor really wires SpendObservationHandler into the event fan-out.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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

Walkthrough

The wallet rejects finalized transaction handles whose reservations reach 20 blocks of age. Abandonment avoids unsafe aged outpoint release. FFI mappings, Kotlin documentation, and cleanup tests cover the stale-reservation behavior.

Changes

Finalized transaction reservation expiry

Layer / File(s) Summary
Shared reservation age policy
packages/rs-platform-wallet/src/wallet/reservations.rs, packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs
Defines the shared 20-block expiration rule and applies it to reservation lifecycle checks.
Core wallet stale-handle behavior
packages/rs-platform-wallet/src/error.rs, packages/rs-platform-wallet/src/test_support.rs, packages/rs-platform-wallet/src/wallet/core/broadcast.rs, packages/rs-platform-wallet/src/wallet/core/transaction.rs
Rejects aged finalized transactions before broadcasting. Applies age-aware abandonment rules. Tests fresh, stale, boundary, and rebuild cases.
Error mapping and SDK contracts
packages/rs-platform-wallet-ffi/src/error.rs, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt
Maps PlatformWalletError::StaleReservation to ErrorStaleReservationToken. Documents stale-handle recovery and cleanup behavior.
FFI cleanup validation
packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
Tests aged-handle release, invalid-wallet abandonment, rebuilding, stale broadcast rejection, and repeated freeing.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CoreWallet
  participant reservation_expired
  participant TransactionBroadcaster
  participant abandon_transaction
  CoreWallet->>reservation_expired: Check finalized transaction age
  reservation_expired-->>CoreWallet: Return stale or usable status
  CoreWallet->>TransactionBroadcaster: Broadcast usable finalized transaction
  CoreWallet->>abandon_transaction: Abandon stale finalized transaction
  abandon_transaction-->>CoreWallet: Apply age-aware reservation cleanup
Loading

Possibly related PRs

Suggested reviewers: lklimek, quantumexplorer

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: age-guarding finalized-transaction handle broadcasts in the platform wallet.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch followup/v4.1/v2-handle-age-guard

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

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit e74cbe4)
Canonical validated blockers: 1

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- Around line 50-55: Update the broadcast method around the reservation
validation to acquire generation_payment_guard, verify is_current_generation,
and return the appropriate stale-generation error when the wallet is no longer
current. Hold the guard through the broadcaster call so teardown cannot occur
between validation and network submission, while preserving the existing
reservation_expired check.

In `@packages/rs-platform-wallet/src/wallet/reservations.rs`:
- Around line 57-68: Correct the aged-cleanup documentation to distinguish
token-less reservations from owner-guarded reservations: in
packages/rs-platform-wallet/src/wallet/reservations.rs lines 57-68, state that
only token-less cleanup skips unguarded release while abandon_transaction can
release with an owner token; update the corresponding stale-broadcast and
release descriptions in
packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs lines 163-168,
packages/rs-platform-wallet/src/error.rs lines 103-108,
packages/rs-platform-wallet/src/test_support.rs lines 364-366,
packages/rs-platform-wallet/src/wallet/core/broadcast.rs lines 403-405,
packages/rs-platform-wallet-ffi/src/error.rs lines 276-281,
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt
lines 65-71, and packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
lines 389-395 so normal aged finalized handles are documented as owner-guarded
releases and only the token-less branch skips release.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e486da5f-6817-4ca9-a83f-f928619636b5

📥 Commits

Reviewing files that changed from the base of the PR and between 438153d and 224704f.

📒 Files selected for processing (10)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt
  • packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/test_support.rs
  • packages/rs-platform-wallet/src/wallet/core/broadcast.rs
  • packages/rs-platform-wallet/src/wallet/core/transaction.rs
  • packages/rs-platform-wallet/src/wallet/reservations.rs
  • packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/reservations.rs Outdated
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.50%. Comparing base (4ca678f) to head (e74cbe4).
⚠️ Report is 1 commits behind head on v4.2-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v4.2-dev    #4309      +/-   ##
============================================
+ Coverage     83.00%   83.50%   +0.50%     
============================================
  Files          2744     2773      +29     
  Lines        367956   372429    +4473     
============================================
+ Hits         305411   311003    +5592     
+ Misses        62545    61426    -1119     
Components Coverage Δ
dpp 83.27% <ø> (-0.49%) ⬇️
drive 82.16% <ø> (+1.07%) ⬆️
drive-abci 86.98% <ø> (+1.09%) ⬆️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.92% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 48.41% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Preliminary review — Codex only

The age check correctly prevents stale V2 transactions from reaching the broadcaster, and the new owner-guarded abandon/free behavior safely releases still-owned reservations at any age. However, the terminal FFI stale-broadcast path consumes the only transaction handle without invoking that cleanup, so an immediate rebuild can remain blocked until the reservation TTL expires. Several public comments also still describe the superseded age-based cleanup policy or omit the stale terminal outcome.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Opus: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 2 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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/core/broadcast.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/broadcast.rs:50-55: Use the reservation owner token when stale handles are consumed
  The stale branch returns without reconciling the reservation. At the FFI boundary, `core_wallet_broadcast_signed_transaction_v2` has already removed the opaque handle, while Swift and Kotlin also clear their local handles before entering the ABI, so the caller cannot abandon it afterward. Between the 20-block guard and key-wallet's 24-block TTL, the reservation is normally still owned by this finalized build; consequently, the instructed immediate rebuild can fail because the only available input remains reserved. `abandon_transaction` now uses `release_reservation_if_owner` whenever the finalized transaction carries its owner token, safely releasing a still-owned reservation and doing nothing if a sweep or re-reservation transferred ownership. Invoke that cleanup before returning `StaleReservation`. The existing Rust test does not cover the terminal FFI behavior because it explicitly calls `abandon_transaction` after receiving the stale error.

In `packages/rs-platform-wallet/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/error.rs:101-108: StaleReservation docs describe the old abandon behavior
  These comments say aged abandon/free always skips reservation release, but `CoreWallet::abandon_transaction` now skips only for token-less transactions. A normal funded finalized handle carries an owner token and attempts `release_reservation_if_owner` at every age, releasing inputs only while this build still owns them and safely doing nothing after ownership transfers. The same obsolete policy appears in `wallet/reservations.rs:57-68`, `wallet/signed_payment_registry.rs:163-168`, `test_support.rs:364-366`, `wallet/core/broadcast.rs:403-406`, `rs-platform-wallet-ffi/src/error.rs:269-281`, the FFI test comment at `rs-platform-wallet-ffi/src/core_wallet/broadcast.rs:389-396`, and Kotlin's `ManagedCoreWallet.kt:64-71`. Update these mirrors to distinguish owner-guarded cleanup from the token-less by-outpoint fallback.

In `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs:25-34: Broadcast FFI docs omit the stale terminal outcome
  `core_wallet_broadcast_signed_transaction_v2` can return `ErrorStaleReservationToken` code 34 after permanently consuming the opaque handle. This outcome does not touch the broadcaster, does not allocate an output txid string, and cannot be recovered by subsequently calling abandon/free with the consumed handle. The exported C-boundary documentation currently describes success, ambiguous submission, definitive rejection, and removed-wallet failure only. Document code 34 and its handle, network, txid, rebuild, and owner-guarded reservation-cleanup contract consistently with the stale-consumption fix.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
Comment thread packages/rs-platform-wallet/src/error.rs Outdated
…dcast

Rebased down to the age-guard onto current v4.2-dev: the #4185/#4308
stack it was riding merged, and #4323/#4325 renamed the finalized-
transaction surface (the v2 suffix is gone), so the guard now lands on
core_wallet_broadcast_signed_transaction and the slice-based
finalize_transaction signature.

Mirrors the deferred registry-token age policy on the finalized-handle
path: RESERVATION_MAX_AGE_BLOCKS (20; key-wallet TTL 24) and
reservation_expired() live in wallet::reservations, shared by both
surfaces. broadcast_finalized_transaction refuses with StaleReservation
(FFI ErrorStaleReservationToken, 34) before touching the broadcaster
once the reservation's stamp height has aged past the bound — and the
refusal reconciles the reservation on the way out, exactly like the
registry's stale-token branch: the FFI wrapper has already consumed the
opaque handle, so no follow-up abandon is possible, and the owner-
guarded release (safe at any age; a no-op once ownership transferred)
frees the still-owned inputs for the instructed immediate rebuild.
Abandon/free likewise release owner-guarded at any age, with the
by-outpoint skip retained only for token-less builds. Boundary tests
cover both account types on the platform and FFI layers, including the
terminal FFI stale-broadcast path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bfoss765
bfoss765 force-pushed the followup/v4.1/v2-handle-age-guard branch from 224704f to 61f871e Compare August 10, 2026 18:41
@bfoss765 bfoss765 changed the title fix(platform-wallet): age-guard the V2 finalized-transaction handle broadcast fix(platform-wallet): age-guard the finalized-transaction handle broadcast Aug 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet/src/error.rs`:
- Around line 121-147: Fix the rustdoc link in
PlatformWalletError::StaleReservation so it does not reference the private
crate::wallet::reservations::RESERVATION_MAX_AGE_BLOCKS item. Replace that link
with a publicly reachable target, while retaining the existing public
SignedCoreTransaction::reservation_height link and the documented behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 133f9314-a352-40da-9641-f276b3b3b5e3

📥 Commits

Reviewing files that changed from the base of the PR and between 224704f and 61f871e.

📒 Files selected for processing (10)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt
  • packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/test_support.rs
  • packages/rs-platform-wallet/src/wallet/core/broadcast.rs
  • packages/rs-platform-wallet/src/wallet/core/transaction.rs
  • packages/rs-platform-wallet/src/wallet/reservations.rs
  • packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt
  • packages/rs-platform-wallet/src/wallet/reservations.rs
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet/src/wallet/core/transaction.rs
  • packages/rs-platform-wallet/src/test_support.rs
  • packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs
  • packages/rs-platform-wallet/src/wallet/core/broadcast.rs

Comment thread packages/rs-platform-wallet/src/error.rs
…a public doc

Co-Authored-By: Claude Fable 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.

Preliminary review — Codex only

The stale-handle path now performs owner-guarded cleanup and has strong terminal-path coverage, but the freshness check can still race a multi-block height advance and reservation reassignment before network dispatch. The exported C documentation omits the stale terminal outcome, and Kotlin promises a typed stale error without translating the JNI exception on its public direct broadcast method.
Source: reviewers gpt-5.6-sol (general, security-auditor, rust-quality, ffi-engineer); final verifier gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 2 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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/core/broadcast.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/broadcast.rs:55-62: Keep the reservation valid until broadcast dispatch
  `last_processed_height()` releases the wallet-manager read lock before the broadcaster reaches network dispatch. The FFI lifecycle guard excludes wallet teardown, but it does not exclude sync updates or concurrent finalization because payment guards are shared. A call can therefore sample the reservation at age 19, yield in the broadcaster while catch-up advances the wallet to age 24, and then race a new finalization that triggers key-wallet's TTL sweep and reserves the same input under a new token. The old signed transaction can subsequently be submitted against that reassigned UTXO. The four-block margin reduces ordinary likelihood but does not establish an ordering invariant because catch-up can advance multiple blocks. Atomically validate ownership and pin or mark the reservation as in-broadcast under the same synchronization used by height advancement and coin selection, keeping that state until dispatch has definitively begun. The registry-token broadcast uses the same check-then-dispatch pattern and should use the same primitive.

In `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs:24-33: Broadcast FFI docs omit the stale terminal outcome
  `core_wallet_broadcast_signed_transaction` can return `ErrorStaleReservationToken` (34), but its exported contract still lists only ordinary broadcast and removed-wallet outcomes. On the stale branch, Rust has already consumed the opaque handle, leaves `out_txid` null, never invokes the broadcaster, and performs owner-guarded reservation cleanup so the caller can rebuild immediately. Native callers need these terminal ownership and recovery semantics explicitly documented; retrying, abandoning, or freeing the consumed handle is not valid.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt:45-49: Translate the stale JNI error promised by the Kotlin API
  The public method documents that stale broadcast throws `DashSdkError.PlatformWallet.StaleReservationToken`, but it invokes the external JNI method directly. JNI turns native code 34 into the internal `DashSDKException`; without `mapNativeErrors`, direct callers of `coreWallet().broadcastTransaction(...)` receive that internal exception rather than the documented public type. `sendToAddresses` happens to wrap this call from outside, but `coreWallet()` and `broadcastTransaction` are themselves public, so that outer wrapper is not an API-wide invariant.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
bfoss765 and others added 2 commits August 10, 2026 15:41
…roadcastTransaction

The method documents DashSdkError.PlatformWallet.StaleReservationToken but
called the JNI native directly, so direct callers received the internal
DashSDKException instead of the documented public type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pre-checked age is not an ordering invariant: between the check and the
broadcaster await, sync catch-up can advance last_processed_height past
the bound and a concurrent finalization can trigger key-wallet's TTL
sweep, re-reserving the same inputs under a new token — the old signed
transaction then hits the wire against reassigned UTXOs.

New shared primitive dispatch_unexpired performs the age check and
reaches the broadcaster under ONE wallet-manager READ guard. Both
writers this orders against — the ReservationSet TTL sweep (inside coin
selection) and height advancement — mutate under the manager WRITE lock,
so 'the reservation is unexpired' and 'dispatch has begun' become a
single atomic observation. Ownership needs no separate probe: the
key-wallet TTL exceeds RESERVATION_MAX_AGE_BLOCKS on the same clock, so
an unexpired reservation cannot already have been swept.

Both check-then-dispatch sites now route through it: the finalized-
handle broadcast and the registry-token broadcast (whose composite gains
the reservation height and returns the stale verdict for the registry's
existing owner-guarded reconciliation). Reconciliation runs OUTSIDE the
guard — those paths retake manager locks.

Deliberate cost: writers queue behind the network await, bounded by the
broadcaster's own timeout — the price of the invariant without a
key-wallet-side in-broadcast pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- Around line 47-60: The dispatch_unexpired method currently holds the
wallet_manager read guard across the asynchronous broadcast, risking blocked
writes and re-entrant deadlocks. Add the required key-wallet in-broadcast pin
while the manager guard is held, then release the guard before awaiting
broadcaster.broadcast; also configure an explicit timeout for the
DapiBroadcaster request instead of relying on RequestSettings::default().
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e758b3e3-a12c-46d9-93b3-027dcab04e8a

📥 Commits

Reviewing files that changed from the base of the PR and between ffc05fc and e4e6784.

📒 Files selected for processing (2)
  • packages/rs-platform-wallet/src/wallet/core/broadcast.rs
  • packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs

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

Preliminary review — Codex only

The previous freshness race is closed, but the replacement holds the shared wallet-manager read lock while the production SPV broadcaster waits for acceptance. Dash-SPV must acquire the same manager's write lock before its serialized mempool task can process the acceptance signals, so fresh transactions can reach peers yet consistently time out as MaybeSent; the exported FFI documentation also still omits the terminal stale outcome.
Source: reviewers gpt-5.6-sol (general, security-auditor, rust-quality, ffi-engineer); final verifier gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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/core/broadcast.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/broadcast.rs:52-59: Release the manager lock before awaiting SPV acceptance
  `dispatch_unexpired` retains the shared wallet-manager read guard throughout `TransactionBroadcaster::broadcast`. The production `SpvBroadcaster` does not return when initial dispatch begins: it calls dash-spv's `broadcast_transaction_and_wait` and waits up to 30 seconds for a peer echo, InstantSend lock, or confirmation. `SpvRuntime` was constructed with this same wallet manager. Dash-SPV's local transaction handler first sends the transaction to selected peers and then calls `wallet.write().await` before `process_mempool_transaction`; that write cannot proceed while this read guard is held. Because the mempool manager handles its local transaction, peer messages, and sync events serially, it also cannot process the later echo, InstantSend, or confirmation that would resolve the waiting broadcast. A fresh transaction can therefore reach peers but time out as `MaybeSent`, retaining its reservation and reporting an ambiguous failure instead of success. The same guard also delays all manager writers during DAPI or SPV network I/O. Preserve freshness and ownership with a reservation-level in-broadcast pin installed under the manager lock, or split initial dispatch from acceptance waiting, then release the manager guard as soon as network dispatch has definitively begun.

In `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs:24-33: Broadcast FFI docs omit the stale terminal outcome
  `core_wallet_broadcast_signed_transaction` can return `ErrorStaleReservationToken` (34), but its exported contract documents only ordinary broadcast and removed-wallet outcomes. The function removes the opaque transaction handle before validation. On the stale branch, the broadcaster is never invoked, `out_txid` remains null, and owner-guarded cleanup releases any reservation still owned by this transaction so the caller can rebuild immediately. Raw native callers need to know that this outcome is terminal: retrying, abandoning, or freeing the consumed handle is invalid.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
Held across the broadcaster await, the read guard starved the very
pipeline the await depends on: the production SpvBroadcaster waits on
dash-spv's mempool manager, whose local-transaction handler takes
wallet.write() on this same manager lock before it can process the
echo/IS-lock/confirmation events that complete the wait. Every dispatch
therefore rode the full 30s acceptance timeout to an ambiguous MaybeSent
— reservation kept while the transaction was actually on-chain, rebuild
selection left with no spendable UTXOs — and tokio's write-preferring
queue stalled the whole manager for the window. The mock broadcasters in
the test suite never touch the wallet lock, which is why no test caught
it.

The age check stays at dispatch time under the read guard; the guard now
drops before the await (the same lock-free shape as
broadcast_releasing_on_rejection). The residual check-to-wire gap is
covered by key-wallet's TTL margin — the same margin that already
covers the propagation phase, which the guard never spanned — and
releasing early is strictly stronger afterwards: the mempool pipeline
marks the inputs spent in the wallet's own view within milliseconds
instead of after the timeout. All atomicity claims in docs, comments,
and the test narrative are rewritten to the actual contract.

Co-Authored-By: Claude Fable 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.

Preliminary review — Codex only

The prior manager-lock deadlock is fixed, but releasing that lock without installing a reservation-level dispatch pin leaves a check-to-send race that can broadcast an old transaction after its inputs have been swept and reassigned. The exported C contract still omits the terminal stale-reservation outcome, and Kotlin's documentation misstates how a second operation on the consumed handle fails.
Source: reviewers gpt-5.6-sol (general, security-auditor, rust-quality, ffi-engineer); final verifier gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 1 suggestion(s) | 💬 1 nitpick(s)

1 additional finding(s) omitted (not in diff).

🤖 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/core/broadcast.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/broadcast.rs:56-67: Pin the reservation until initial network dispatch
  The manager read guard establishes freshness only at line 61 and is dropped before the broadcaster has dispatched anything. Both production broadcasters can suspend before submission; the SPV path awaits configuration, event subscription, and the network lock before `dispatch_local`. During that gap, catch-up can acquire the manager write lock and advance `last_processed_height` from reservation age 19 to at least 24, after which a concurrent finalization causes key-wallet's `ReservationSet` to sweep the old reservation and reserve the same input under a new owner token. The original future can then resume and submit its already-signed transaction against an input now assigned to another payment. The four-block difference between the age guard and key-wallet's TTL is not an ordering guarantee because catch-up can process multiple blocks and async scheduling places no bound on the pre-dispatch interval. Install an owner-checked, non-expiring in-broadcast pin while the manager guard is held, and retain it until initial dispatch is definitively established; holding the global manager guard through the later acceptance wait is not safe because the SPV mempool path needs its write side.

In `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs:24-33: Broadcast FFI docs omit the stale terminal outcome
  `core_wallet_broadcast_signed_transaction` can return `ErrorStaleReservationToken` (34), but its exported contract documents only ordinary broadcast and removed-wallet outcomes. The function removes the opaque transaction handle before checking reservation freshness. On the stale branch, the broadcaster is never invoked, `out_txid` remains null, and owner-guarded cleanup releases any reservation still owned by this transaction so the caller can rebuild immediately. Raw native callers need to know that this outcome is terminal: retrying, abandoning, or freeing the consumed handle is invalid.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt`:
- [NITPICK] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt:37-43: Document the local error after Kotlin consumes the handle
  `broadcastTransaction` calls `tx.takeForBroadcast()`, which atomically clears the Kotlin handle before JNI runs. A subsequent `abandonTransaction(tx)` therefore does not produce a native invalid-handle error: `takeForAbandon()` delegates to `takeForBroadcast()`, whose `check` throws `IllegalStateException("FinalizedCoreTransaction has already been consumed")` locally. Document the actual exception so callers do not expect a native or typed SDK error from the repeated operation.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
The guarded dispatch proves reservation freshness under the manager read
guard but must drop that guard before the broadcaster await (holding it
starves the SPV mempool pipeline). Both production broadcasters can
suspend before submission, and in that unbounded gap sync catch-up can
advance last_processed_height past key-wallet's reservation TTL, letting
a concurrent build's selection sweep the dispatched build's reservation
and re-reserve the same inputs — the already-signed transaction would
then hit the wire against inputs reassigned to another payment.

Close the window with a non-expiring in-broadcast pin on
WalletGeneration, installed atomically with the freshness check while
the read guard is still held (freshness below the TTL on the same clock
IS the ownership proof — sweeps and height advances run under the write
lock) and released by RAII only after the broadcaster returns, cancelled
dispatches included. Pins are counted per outpoint so a duplicate
dispatch of the same transaction keeps the fence until its last send
returns. Every coin-selection choke point — finalize_transaction, the
contact-payment build, the asset-lock build — now refuses a build whose
selection picked a pinned input, releasing its fresh reservation exactly
under the still-held write guard. The registry-token broadcast shares
dispatch_unexpired and therefore the same primitive.

Also document the Kotlin-side consume semantics: after
broadcastTransaction consumes the handle, a follow-up abandonTransaction
fails locally with IllegalStateException before any native code runs —
not with a native invalid-handle error.

Co-Authored-By: Claude Opus 4.8 <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 — Codex/Sol only (Phase 2 disabled)

The new counted RAII pin closes the reservation check-to-dispatch race while allowing the wallet-manager guard to be released before the broadcaster await, and the Kotlin consumed-handle contract now matches the implementation. Two public broadcast surfaces still omit the new terminal stale-reservation outcome and its required rebuild semantics; these are documentation suggestions, not blocking defects.
Source: Codex reviewers gpt-5.6-sol (general, security-auditor, rust-quality, ffi-engineer); final verifier gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

🟡 2 suggestion(s)

2 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs:24-33: Broadcast FFI docs omit the stale terminal outcome
  `core_wallet_broadcast_signed_transaction` can return `ErrorStaleReservationToken` (34), but the exported C contract documents only ordinary broadcast and removed-wallet outcomes. The function removes the opaque transaction handle before freshness validation. On the stale branch, `out_txid` remains null, the broadcaster is never invoked, and `broadcast_finalized_transaction` performs owner-guarded cleanup so the caller can rebuild immediately. Raw native callers need to know that this outcome is terminal: retrying, abandoning, or freeing the consumed handle is invalid.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift:243-244: Document the stale terminal error on the Swift broadcast API
  `broadcastTransactionWithOutcome` calls `takeForBroadcast()` before entering the FFI, but its public documentation describes only accepted, rejected, and unknown network outcomes. The new age guard can instead throw `.staleReservationToken` (34) before touching the network. The native and Swift handles have nevertheless been consumed and the still-owned reservation has been released, so neither retry nor `abandonTransaction` is available; the caller must rebuild.

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

tACK, this has been tested several times for BIP70 payment. it works well.

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

Preliminary review — Codex only

The finalized/deferred dispatch path correctly performs its freshness check and installs a generation-scoped input fence before releasing the manager guard. Two blocking gaps remain: immediate contact-payment and asset-lock broadcasts do not fence their own selected inputs, and the one-hour orphan timeout releases possibly submitted transactions without evidence that they can no longer be relayed. Source: reviewers gpt-5.6-sol; final verifier claude-opus-4-6; openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking

🤖 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/payments.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:1325-1341: Fence newly selected immediate sends before releasing the manager lock
  This new check prevents the contact-payment build from consuming an input fenced by another dispatch, but the newly selected transaction never installs a fence of its own. After the manager write guard is released at the end of this block, persistence and `self.broadcaster.broadcast(&tx).await` run without a generation pin. If catch-up advances by key-wallet's 24-block reservation TTL while the broadcaster is suspended, another build can sweep and reserve the same input; because the original transaction has no fence, this check passes for the competing build, after which the original future can resume and submit its already-signed transaction. The asset-lock flow has the same asymmetry after its conflict check at `wallet/asset_lock/build.rs:236` and before its direct broadcast at line 987. Install the transaction's pin atomically with its fresh reservation while the manager guard is still held, carry it through pre-send work and the broadcaster await, release it only on a definitive pre-send failure, and retain it for accepted, ambiguous, cancelled, or unwound dispatches until a safe settlement condition is established.

In `packages/rs-platform-wallet/src/wallet/core/generation.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/generation.rs:243-248: Elapsed time can retire a fence while the signed transaction remains valid
  `open_pending` gives every accepted, ambiguous, cancelled, or unwound dispatch a one-hour deadline, and `in_broadcast_conflict` later removes the fence solely because that deadline elapsed. Elapsed time does not invalidate the signed transaction or prove that no peer retained it. A malicious or isolated DAPI endpoint can receive the transaction while withholding it from the wallet and network, or a mobile wallet can remain backgrounded for more than an hour. Once catch-up also causes key-wallet's reservation to be swept, the next build prunes this fence and signs a conflicting transaction; the retained original can still be broadcast afterward, allowing either user intent to win the double-spend race. Non-rejected transactions must remain fenced until spend or replacement evidence, or an explicit abandon/replacement protocol, establishes that reselection is safe. A liveness path can persist and query/rebroadcast the pending transaction, but a timeout alone cannot make its inputs safe to reuse.

Comment thread packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/generation.rs Outdated
bfoss765 and others added 2 commits August 26, 2026 18:19
…ver on elapsed time

The pending-spend phase of the in-broadcast input fence carried a one-hour
monotonic deadline, and `in_broadcast_conflict` released the fence on that
deadline alone. Elapsed time is not evidence about the transaction it
protects: the signed transaction is still valid, and no amount of waiting
proves that no peer retained it. A DAPI endpoint that accepts the transaction
while withholding it from the network, or an app backgrounded past the
deadline, was enough — and once catch-up had also swept key-wallet's
reservation, the next build pruned the fence and signed a CONFLICTING
transaction over an input the original might still spend, so either user
intent could win the resulting double-spend race.

The monotonic clock had fixed the wrong half of the three height-anchored
bounds it replaced. Making a clock unfast-forwardable does not turn elapsed
time into evidence.

So the deadline is gone rather than re-tuned. `InBroadcastFence::pending_until`
becomes a plain `pending` flag; `blocks`, `open_pending`, `unpin_in_broadcast`
and `in_broadcast_conflict` take no clock of any kind; and
`IN_BROADCAST_FENCE_ORPHAN_TIMEOUT` is deleted. A fence is now released by
exactly two things: an observed spend of the outpoint
(`WalletGeneration::observe_spent`, the PR's existing evidence path, which
already covers the accepted / ambiguous / cancelled / unwound states), or a
definitive pre-send failure. The invariant — no quantity that merely elapses
may retire the phase — is documented where the deadline used to be, in the
`in_broadcast` field docs and in a standing comment at the removed constant,
together with the two liveness shapes that may shorten the wait later
(persist-and-requery, or an explicit abandon) and may not be replaced by a
timeout.

The cost is that a transaction the wallet never observes at all holds its
inputs for the rest of the process. That is the right trade: those are exactly
the inputs a possibly-live signed transaction spends. The map is per
generation and never persisted, so a restart clears it.

Red-then-green, with the round-6 deadline behaviour reconstructed as the red
harness:

* `generation::tests::the_pending_fence_outlives_any_elapsed_deadline` — unit
  level: settle a dispatch, elapse, assert the fence stands and only
  `observe_spent` clears it. Red: the fence was gone.
* `broadcast::tests::an_elapsed_deadline_cannot_retire_the_fence_a_spend_still_needs`
  — end to end through the real send path: accept the transaction, run
  catch-up past key-wallet's reservation TTL, elapse, rebuild. Red: the second
  build returned a fully signed transaction spending the same input.
* `broadcast::tests::cancelled_dispatch_keeps_its_fence_across_catch_up` — the
  cancellation path's tail now proves the same thing. Red: same double sign.

`cargo test -p platform-wallet --lib wallet::core::generation wallet::core::broadcast`
30/30 green after the fix, 3 failing before it.

Refs: #4309 (review round 7, finding c63ebc30aac4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… own selections

Both immediate-broadcast build paths ran a conflict check against the
in-broadcast fence and then never installed a fence of their own. The check
stops a build from CONSUMING an input another dispatch has pinned; on its own
that is only half of the contract, because the transaction the build just
signed travels to the network unpinned.

After the manager write guard drops, the contact-payment path runs its
durability store and `broadcaster.broadcast(&tx)` unfenced, and the asset-lock
path runs its pool durability gate, its `Built` tracking write and its direct
broadcast the same way. The broadcaster can suspend before submission; catch-up
can advance `last_processed_height` past key-wallet's 24-block reservation TTL
in that gap; a competing build then sweeps and re-reserves the same input,
finds no fence on it, passes its own copy of the conflict check, and completes
— after which the original future resumes and submits its already-signed
transaction against an input reassigned to another payment.

Both paths now install the pin ATOMICALLY with the fresh reservation, while the
guard that just proved the reservation is theirs is still held, and carry it
through the pre-send work and the broadcaster await — the same shape
`CoreWallet::dispatch_unexpired` already used for the finalized-handle path.

Settlement is accounted for on every exit. Released only where the transaction
provably never reached the network: the contact payment's failed used-flip
store, the asset lock's drain-floor refusal and its invitation durability
abort, and a definitive `Rejected` broadcast on either path (asset-lock only
when the `Built` row was actually removed — if the untrack guard fired, a
concurrent resume is positive evidence the transaction did reach the network,
so the fence stays with the reservation). Every other outcome — accepted,
ambiguous `MaybeSent`, or the future being cancelled or unwound inside
`broadcast`, which `InBroadcastPin::drop` covers with no code at the call site
— leaves the pending-spend fence standing until the wallet observes the spend.

`build_asset_lock_transaction_with_funding` keeps its public signature and its
exact behaviour: the pin is threaded through a new `pub(crate)`
`build_asset_lock_transaction_fenced`, and the public build-only form releases
it before returning, since it hands the transaction back unsent and has no
dispatch to keep a fence alive. The internal funded pipeline takes the fenced
form. The pin is installed after the two credit-key error paths rather than
beside the conflict check, so an abort cannot return past a live pin — with no
deadline behind the pending phase, a pin dropped on an abort would hold those
inputs with nothing able to clear them.

Red-then-green, the red harness being the pre-fix shape (selection never
fenced past the build's own guard, settle sites inert):

* `payments::tests::a_suspended_contact_payment_fences_its_inputs_against_a_competing_build`
  — parks a contact payment inside the broadcaster, runs catch-up past the
  reservation TTL, and races a second contact payment over the wallet's single
  spendable UTXO. Red: the competing send returned Ok with its own txid and
  PaymentEntry.
* `asset_lock::build::tests::a_suspended_asset_lock_fences_its_inputs_against_a_competing_build`
  — the same sequence for the asset-lock pipeline. Red: the competing build
  returned Ok with a second signed lock over the same outpoint.

Both green after the fix.

Refs: #4309 (review round 7, finding 1a9dfa2c1a29)

Co-Authored-By: Claude Fable 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.

Preliminary review — Codex only

The two prior blockers are fixed: immediate contact-payment and asset-lock sends now pin their own selections, and elapsed time no longer retires pending-spend fences. Three new blocking lifecycle defects remain: cancellation during asset-lock pre-send work strands inputs permanently, generation recreation discards protection for possibly live transactions, and contact-payment rejection lowers the fence before an unconditional cleanup that can erase a newer reservation. Several comments and the public ambiguous-broadcast error still describe the deleted timeout behavior.
Source: reviewers gpt-5.6-sol and claude-opus-4-6; final verifier claude-opus-4-6. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed), gpt-5.6-sol — security-auditor (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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/asset_lock/build.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:1017-1022: Release the asset-lock pin when cancellation happens before broadcast
  `build_asset_lock_transaction_fenced` creates `in_broadcast_pin` in its conservative pending-on-drop state, but this function subsequently awaits `persist_asset_lock_account_pools()` here and `track_asset_lock()` at lines 1060-1071 before reaching the broadcaster at line 1096. Cancelling the future during either pre-send await drops the pin as `Pending`, even though no transaction was submitted. Because no spend can ever be observed for that transaction, the evidence-only fence never clears; after key-wallet sweeps the reservation, every later build selecting the input is refused with `InputMidBroadcast` for the remainder of the process. Keep the dispatching hold active during pre-send work, but make pre-broadcast drop release it; arm pending-on-drop immediately before entering `broadcast`, where cancellation must remain conservative.

In `packages/rs-platform-wallet/src/wallet/core/generation.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/generation.rs:161-163: Preserve pending-spend protection across wallet recreation
  The evidence-only fence is explicitly process- and generation-local. `load_from_persistor` constructs a fresh `WalletGeneration` with an empty fence map, and `pins_do_not_cross_generations` confirms that replacing the generation immediately permits the same outpoint. After an accepted or `MaybeSent` DAPI broadcast that has not been observed locally, a restart or remove-and-recreate operation therefore restores the persisted UTXO without either the fence or key-wallet's memory-only reservation. The original signed transaction can still be retained and later relayed by a DAPI endpoint or peer, while the restored wallet can sign a conflicting intent. Synchronization alone does not close this when the original transaction is withheld until after the new selection. Persist pending transactions or their outpoints and rehydrate the fences before spending is enabled, or resolve each pending transaction through query, rebroadcast, replacement, or explicit abandonment before allowing restored inputs to be selected.

In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:1421-1434: Keep the contact-send fence up through reservation cleanup
  The definitive-rejection arm removes the fence before awaiting reservation cleanup. This path passes no owner token, so `release_reservation_after_rejected_broadcast` performs an unconditional `release_reservation`. If catch-up swept the first send's reservation while its broadcaster was suspended, a finalized-transaction build already queued on the manager write lock can run after `settle_released()`, reserve the same input, pass the now-absent conflict check, and release the manager lock while its external signer is pending. The cleanup then acquires the manager read lock and deletes that newer reservation. Because finalized builds do not install a pin until broadcast, another finalization can reserve and sign the same input, producing two fresh conflicting handles. Perform the unconditional cleanup while the original fence remains active, then release the pin; a queued build that runs first will still encounter the fence and roll back its own selection.

In `packages/rs-platform-wallet/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/error.rs:105-118: Remove obsolete timeout guarantees from fence documentation
  `TransactionBroadcastUnconfirmed` still promises reconciliation through the reservation TTL, but sweeping the key-wallet reservation no longer releases the generation-level pending-spend fence. The same obsolete contract appears in `wallet/core/broadcast.rs:148-152`, which says the fence answers to a monotonic clock; `wallet/core/generation.rs:775-783`, which describes installing an `Instant::now` orphan deadline; and `wallet/core/spend_observer.rs:77-80`, which says a dropped observation eventually reaches an orphan backstop. Commit `6c4ea7736d` removed every elapsed-time release. Update these sites to state the actual contract: a non-rejected send remains fenced until observed-spend evidence, an explicit definitive pre-send release, or generation teardown.

Comment thread packages/rs-platform-wallet/src/wallet/core/generation.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
Comment thread packages/rs-platform-wallet/src/error.rs
HashEngineering and others added 5 commits August 26, 2026 17:18
The definitive-rejection arm of the contact-payment send released the
in-broadcast pin FIRST and only then awaited
`release_reservation_after_rejected_broadcast`. That cleanup is an `.await` —
it re-acquires the wallet-manager read lock — and on this path it threads no
reservation token, so it performs an unconditional `release_reservation`.

Between the two, the input was neither fenced nor, once catch-up had swept
the build's reservation, reserved. A finalized-transaction build already
queued on the manager write lock could run in that window: reserve the same
input, pass the now-absent conflict check, and drop the lock with its
external signer still pending (finalized builds install no pin until
broadcast). The unconditional cleanup then deleted THAT build's newer
reservation, leaving the outpoint free for a second finalization to reserve
and sign — two fresh conflicting handles over one input, from a send that was
definitively rejected.

The fix is the ordering: cleanup runs under the still-live fence, and the pin
comes down after it. A build that runs first now meets the fence and rolls
back its own selection, so there is never a newer reservation for the
unconditional release to clobber.

The three asset-lock settle-with-cleanup sites (drain-floor refusal,
invitation durability abort, rejected broadcast with the `Built` row removed)
take the same order. Those releases ARE owner-guarded by the build's
reservation token, so the clobber cannot happen there today — but the
ordering is now uniform across every site rather than resting on that one
argument, so dropping a token later cannot silently reopen the window.

`release_reservation_after_rejected_broadcast` grows the contract as an
explicit two-sided rule, since both directions now have call sites:
resumability-removing cleanup (the asset lock's `untrack`) runs BEFORE the
release, protection-removing cleanup (the pin) runs AFTER.

Red-then-green:

* `payments::tests::the_contact_send_fence_outlives_its_rejected_broadcast_reservation_cleanup`
  — parks a contact payment inside the broadcaster, takes the wallet-manager
  WRITE lock, then lets the broadcaster return `Rejected`. The cleanup needs
  the READ lock, so it is provably still pending at the observation point,
  which makes the assertion an invariant rather than a race. Red: the fence
  was already gone (`observed: None`). Green: it still stands, and comes down
  only once the cleanup has run.

Refs: #4309 (review round 8, finding 0f152a899301)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eneration

The pending-spend fence was stored inside `WalletGeneration`, which made it
not merely process-local but GENERATION-local. `register_wallet` and
`load_from_persistor` each minted a generation with an empty map, so removing
a wallet and re-creating it under the same id dropped every fence the
previous instance held — while the signed transactions those fences protect
stay perfectly valid and relayable. The re-created wallet restored the
persisted UTXO with nothing holding it (not the fence, and not key-wallet's
memory-only reservation, which dies with the instance too) and could sign a
conflicting spend of an outpoint the original still spends. A DAPI endpoint
or peer that retained the original can relay it afterwards, so either intent
could win the resulting double-spend race.

The balance and the lifecycle gate genuinely describe one instance and must
not cross a recreation. A fence does not: it describes a transaction that may
be live on the network, and a transaction does not become invalid because the
wallet object holding its record was replaced.

So the map moves out of the generation into `InBroadcastFences`, an `Arc` the
manager keys by `wallet_id` and hands to every generation registered under
that id. `WalletGeneration::with_fences` is the production constructor;
`new()` keeps its meaning — a wallet with no predecessor — for tests and for
the no-inheritance case. The registry is DELIBERATELY never pruned on
removal: a removal is exactly when the protection has to survive.

Everything else about the fence is unchanged. It is still retired only by
evidence (`observe_spent`), never by anything that elapses, and an
observation on the new generation clears what the old one installed because
both name the same map. Inheritance is strictly the conservative direction.

This closes the remove-and-recreate half. It does NOT close a process
restart: the registry is process-lifetime, so a fresh process loads the
persisted UTXO with no fence on it. Closing that half needs the pending
transaction to be DURABLE — either recorded locally at dispatch the way the
SPV path already is through dash-spv's mempool injection (which would drop
the input from the persisted UTXO set via the existing
`CoreChangeSet::records` / `spent_utxos` fields, needing no new persistence
surface), or written to a dedicated pending-spend table and rehydrated before
spending is enabled. Both change host-visible state and are scoped as their
own change rather than smuggled into a review round; the requirement is
recorded on `InBroadcastFences` so it cannot be lost.

Red-then-green:

* `manager::wallet_lifecycle::…::a_recreated_wallet_inherits_the_pending_fences_of_the_generation_it_replaces`
  — end to end through the manager: create, settle a dispatch into the
  pending-spend phase, `remove_wallet`, re-create from the same seed, and
  assert the replacement still refuses the outpoint. Red: it reported no
  conflict at all (`left: None`).
* `…::fences_do_not_leak_between_different_wallets` — the isolation half:
  two wallets in one manager, one's fence must not block the other's builds.
  Green before and after.
* `generation::tests::pins_cross_generations_of_the_same_wallet` replaces
  `pins_do_not_cross_generations`, which asserted the old behaviour — that
  assertion WAS the bug. It also pins that an observation on the new
  generation clears the inherited fence.
* `generation::tests::pins_do_not_cross_between_wallets` keeps the isolation
  property the replaced test was really worth keeping.

Also drops the stale claim on `InBroadcastPin::settle_pending_spend` that it
installs an `Instant::now` orphan deadline (round 8, finding 6e648ef21468).

Refs: #4309 (review round 8, finding 2fbc74b6ef05)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fence docs

Commit 6c4ea77 removed every elapsed-time release from the pending-spend
fence, but four sites still promised one. They describe a contract the code
no longer has, which is worse than no comment: a reader reasoning about
ambiguous-broadcast safety would conclude a bound eventually reconciles it.

* `PlatformWalletError::TransactionBroadcastUnconfirmed` — both the doc and
  the user-visible `#[error]` string said the inputs stay held "until a sync
  or the reservation TTL reconciles the outcome". Sweeping key-wallet's
  reservation no longer releases the generation-level fence, and the fence is
  what actually keeps an ambiguous send's inputs out of the selectable set.
  Restated: the inputs are held by two independent things, only one of which
  expires, and the one that matters ends on an observed spend.
* `dispatch_unexpired` — "the fence answers to observed spends and a
  monotonic clock". There is no clock of any kind.
* `SpendObservationHandler` — a dropped observation was said to be covered by
  an orphan backstop that no longer exists. It is still fail-safe, but for a
  different reason: it costs a wait, not safety.
* `wallet::reservations` module docs — "kept (for the reservation-TTL
  backstop or a later sync)" implied the TTL is what makes the ambiguous case
  safe. It is not.

The historical narrative in the `WalletGeneration::in_broadcast` field docs
is deliberately left as it is: it explains why three height-anchored bounds
and one monotonic deadline were each unsound, which is the reasoning that
keeps a fifth from being added.

Docs and one error string only — no behaviour change.

Refs: #4309 (review round 8, finding 6e648ef21468)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

tACK

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

Preliminary review — Codex only

The exact head fixes the contact-payment cleanup ordering and obsolete timeout guarantees, and wallet-scoped fences now survive in-process generation replacement. Two blocking lifecycle gaps remain: pre-broadcast asset-lock cancellation can create an unreleasable pending fence, and a fresh manager or process still restores spendable UTXOs without protection for possibly live transactions; the public lifetime documentation also conflates the 20-block refusal bound with key-wallet's 24-block TTL. Source: reviewer backend model gpt-5.6-sol; final verifier backend model claude-opus-4-6. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

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

🤖 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/asset_lock/build.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:1037-1041: Release the asset-lock pin when cancellation happens before broadcast
  `build_asset_lock_transaction_fenced` returns an `InBroadcastPin` whose default drop settlement is `Pending`, but this function then awaits `persist_asset_lock_account_pools()` and `track_asset_lock()` before invoking the broadcaster. Cancelling during either wait can therefore open a permanent pending-spend fence even though no transaction was submitted; cancellation while waiting to track can also occur before any recoverable `Built` row exists. No spend event can clear such a fence, so after key-wallet's reservation TTL sweeps the underlying reservation, every later build selecting the outpoint is refused with `InputMidBroadcast` for the rest of the manager lifetime. Keep the dispatching hold active during pre-send work, but make pre-broadcast drop release it and arm conservative pending-on-drop immediately before entering the broadcaster await.

In `packages/rs-platform-wallet/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/error.rs:114-129: Correct the documented reservation and fence lifetimes
  This public error contract says key-wallet's `ReservationSet` is swept after `RESERVATION_MAX_AGE_BLOCKS`, but that constant is the wallet's 20-block pre-broadcast refusal bound; the underlying reservation TTL documented in `wallet/reservations.rs` is 24 blocks. It also says generation teardown ends the refusal, although the fence registry is now keyed by wallet ID and deliberately survives same-wallet removal and recreation within one manager. `wallet/core/generation.rs:161-163` likewise still calls the map per-generation despite the wallet-scoped ownership described later in that file. Document the 20-block age guard separately from the 24-block reservation TTL and state that only a fresh manager/process—not an in-process generation replacement—currently loses the fence.

In `packages/rs-platform-wallet/src/wallet/core/generation.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/generation.rs:246-258: Preserve pending-spend protection across wallet recreation
  (existing thread: https://github.com/dashpay/platform/pull/4309#discussion_r3867512339)
  The wallet-ID-keyed registry now preserves fences across removal and recreation within one live manager, but this section confirms that the registry is still process-local. A fresh manager or process can restore a persisted UTXO after an accepted or ambiguous submission without restoring either key-wallet's in-memory reservation or the pending-spend fence. This can happen when a DAPI submission is retained without local wallet injection, or when the process exits after an SPV send but before the spend is durably recorded. The restored wallet can then sign a conflicting transaction while the original remains relayable. Persist the pending transaction or its outpoints and rehydrate protection before spending is enabled, or durably record the submitted transaction through the existing transaction changeset path before returning control.

Comment thread packages/rs-platform-wallet/src/error.rs Outdated

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

Preliminary review — Codex only

The exact-head wallet code is unchanged from the prior review, and the finalized/deferred dispatch fence still closes the original in-process check-to-send race. Two blocking lifecycle gaps remain: cancellation during asset-lock pre-send awaits can strand an input behind an evidence-only fence, and a fresh manager/process restores possibly spent UTXOs without restoring pending-spend protection. The public lifetime documentation also still conflates the 20-block refusal guard with key-wallet's 24-block reservation TTL.
Source: reviewers gpt-5.6-sol; final verifier claude-opus-4-6. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking

1 additional finding(s) omitted (not in diff).

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

🤖 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/asset_lock/build.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:1037-1094: Release the asset-lock pin when cancellation happens before broadcast
  `build_asset_lock_transaction_fenced` returns a pin whose default `Drop` settlement opens the pending-spend phase. This function then awaits `persist_asset_lock_account_pools()` and `track_asset_lock()` before it reaches `broadcaster.broadcast()`. If the future is cancelled during either pre-send await, the pin becomes permanently pending even though no transaction was submitted; cancellation while waiting for `track_asset_lock` can also happen before a recoverable `Built` row exists. No spend can then be observed to clear the evidence-only fence, so once key-wallet sweeps the reservation, every later build selecting that outpoint is refused for the rest of the manager lifetime. Keep the dispatching hold during pre-send work with release-on-drop semantics, then arm conservative pending-on-drop immediately before entering the broadcaster await.

In `packages/rs-platform-wallet/src/wallet/core/generation.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/generation.rs:246-258: Preserve pending-spend protection across wallet recreation
  (existing thread: https://github.com/dashpay/platform/pull/4309#discussion_r3867512339)
  The wallet-ID registry preserves fences only within one live manager; these lines confirm that a fresh manager or process starts with an empty registry. After an accepted or ambiguous DAPI submission, no local mempool record is injected, and neither the pending fence nor key-wallet's reservation is durable. A restart can therefore restore the original UTXO as selectable while the first signed transaction remains relayable; the restored wallet may sign a conflicting intent before synchronization observes the old spend. Persist the pending transaction or its outpoints and rehydrate protection before spending is enabled, or durably apply the submitted transaction through the existing transaction changeset path before returning control.

In `packages/rs-platform-wallet/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/error.rs:114-129: Correct the documented reservation and fence lifetimes
  (existing thread: https://github.com/dashpay/platform/pull/4309#discussion_r3868930363)
  This public contract says key-wallet's `ReservationSet` is swept after `RESERVATION_MAX_AGE_BLOCKS`, but that constant is the wallet's 20-block pre-broadcast refusal bound; key-wallet's reservation TTL is 24 blocks. It also says generation teardown ends the refusal, although the fence registry is keyed by wallet ID and survives same-wallet removal and recreation within one live manager. `wallet/core/generation.rs:161-163` likewise still calls the map per-generation despite the wallet-scoped ownership documented later in that file. Distinguish the two block bounds and state that only a fresh manager/process—not an in-process generation replacement—currently loses the fence.

…t's 24-block TTL

The public `TransactionBroadcastUnconfirmed` contract conflated two different
numbers and one stale lifecycle claim.

It said key-wallet's `ReservationSet` entry is swept once
`last_processed_height` advances `RESERVATION_MAX_AGE_BLOCKS` past the stamp
height. That constant is 20 and is this crate's *pre-broadcast refusal* bound;
the sweep is key-wallet's own `RESERVATION_TTL_BLOCKS`, which is 24
(`key-wallet/src/managed_account/reservation.rs`). The refusal bound is
deliberately the lower of the two so a broadcast is turned away while its
reservation is still provably unswept — the doc now states both numbers and
that relationship instead of collapsing them into one.

It also ended the ambiguous-outcome refusal at "the generation is torn down".
That stopped being true when the fence map moved into the wallet-id-keyed
`InBroadcastFences` registry: a remove-and-recreate under the same id inherits
the pending spends, so only a fresh manager — in practice a process restart —
loses the fence today.

The same restart gap was mis-stated on the `WalletGeneration::in_broadcast`
field, which still called the map "per generation" and justified not persisting
it with "after a restart nothing is mid-dispatch". A restart genuinely can come
up with a transaction still in flight; that is the open half of
`#4309` and is now named as a gap rather than a saving, pointing
at the `InBroadcastFences` note that records what closing it costs.

No behavior change — doc comments only, so no test accompanies this by design.

Co-Authored-By: Claude Opus 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.

Preliminary review — Codex only

The exact-head code correctly separates the 20-block refusal guard from key-wallet's 24-block reservation TTL and preserves pending-spend fences through ordinary dispatch cancellation and in-process wallet recreation. Two in-scope cancellation paths can still create permanent evidence-only fences even though the broadcaster is known not to have sent anything, so changes are required; three public-contract/release-path suggestions also remain.
Source: reviewer backend model gpt-5.6-sol; final verifier backend model claude-opus-4-6. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking | 🟡 3 suggestion(s)

2 additional finding(s) omitted (not in diff).

🤖 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/asset_lock/build.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:1037-1094: Release the asset-lock pin when cancellation happens before broadcast
  `build_asset_lock_transaction_fenced` returns an `InBroadcastPin` whose default drop settlement opens a permanent pending-spend fence, but this caller then awaits `persist_asset_lock_account_pools()` and `track_asset_lock()` before it reaches `broadcaster.broadcast()`. Cancelling during the persistence await, or while `track_asset_lock` is still waiting before a recoverable `Built` row exists, therefore marks the inputs as possibly sent even though the broadcaster was never reached. No spend event can clear that evidence-only fence; after key-wallet sweeps the reservation, later selection of the outpoint is refused for the manager's lifetime. Keep release-on-drop semantics during all pre-send work and arm conservative pending-on-drop only immediately before entering the broadcaster await.

In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:1447-1457: Make rejected contact-payment cleanup cancellation-safe
  After `broadcast()` has definitively returned `Rejected`, this branch awaits reservation cleanup while the pin still has its default pending-on-drop settlement. The ordering correctly keeps the fence raised until the token-less reservation release finishes, but cancellation while that cleanup waits for the manager lock drops the pin as `Pending`. The transaction is known never to have been sent, so no later spend observation can clear the resulting non-expiring fence. Make the known-rejected cleanup independently cancellation-safe or use an atomic cleanup-and-release primitive that cannot leave the pin pending once rejection has been established.

In `packages/rs-platform-wallet/src/wallet/core/spend_observer.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/core/spend_observer.rs:99-105: Do not discard the only spend observation on map contention
  A failed `wallets.try_read()` permanently drops this spend observation. `TransactionDetected` may be the only event carrying a mempool spend: InstantLock events intentionally carry no record here, and an evicted or otherwise unconfirmed transaction may never produce an inserted `BlockProcessed` record. Because pending fences have no timeout, brief contention with wallet registration/removal can leave an input fenced for the manager's lifetime despite the wallet having observed it spent. Queue the observation for retry or route it through a lookup path that cannot discard the event.

In `packages/rs-platform-wallet/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/error.rs:183-213: Correct the retry contract for mid-broadcast conflicts
  The public contract says nothing was built or signed and that retrying unchanged is safe after the dispatch completes, but the contact-payment path calls `build_signed` before its conflict check and the asset-lock builder also has an already-signed transaction at that point. A non-rejected dispatch also remains fenced after the broadcaster returns and clears only when a spend is observed; blindly retrying after observing the original payment land can create a duplicate logical payment. Document that the attempted selection was discarded and its reservation released, signing may already have occurred, and callers must reconcile the transaction protected by the fence before deciding whether to retry the intent.

In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/error.rs:171-181: Mirror the corrected fence lifetime in native bindings
  This FFI contract still says the reservation TTL reconciles an ambiguous broadcast. The reservation entry can expire after 24 blocks, but the independent pending-spend fence has no elapsed-time release and remains until observed-spend evidence. The same stale promise appears in Kotlin's `DashSdkError.TransactionBroadcastUnconfirmed` documentation and Swift's `PlatformWalletResult.errorTransactionBroadcastUnconfirmed` documentation. Update all mirrors so native callers do not infer that waiting for the TTL makes a retry safe.

Comment thread packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/spend_observer.rs
Comment thread packages/rs-platform-wallet/src/error.rs
bfoss765 and others added 3 commits August 28, 2026 04:12
…n when its cleanup is cancelled

The definitive-rejection arms settle their in-broadcast pin only AFTER
awaiting release_reservation_after_rejected_broadcast — the round-8
ordering that keeps the fence up through the token-less cleanup. But the
pin carried its pending-on-drop DEFAULT through that await, so cancelling
the dispatch future while the cleanup waited on the manager lock dropped
the pin as Pending. The pending default is right only while the outcome
is unknown; here the rejection was already established, the transaction
provably never reached the wire, and no spend of it can ever be observed
— so the cancelled cleanup left a pending-spend fence that nothing could
clear, holding the inputs for the manager's lifetime (the pending phase
carries no deadline by design).

Fix: InBroadcastPin::settle_released_on_drop records the released verdict
on the live pin without consuming it. Every arm that has established a
definitive pre-send failure calls it synchronously, before the cleanup's
first await, so the fence still stays raised through the cleanup (the
dispatching hold is untouched) but a drop anywhere afterwards settles
released instead of pending. The existing settle_released now delegates
to it.

Applied on the contact-send rejection arm and on all three asset-lock
sibling sites, which have the identical shape: the drain-floor abort and
the invitation durability abort (both established before the broadcaster
is ever reached), and the rejected-broadcast arm once the untrack of the
Built row has proven the rejection unresumable. On that last path nothing
is recorded before the untrack await, deliberately: rejection alone does
not establish the verdict there — a concurrent resume_asset_lock may have
re-driven the transaction — and a cancellation inside the untrack leaves
the Built row tracked (its only await precedes the removal), so the
pending settle keeps a still-resumable transaction correctly fenced.

The new payments test drives the send by hand: parks it in the
broadcaster, pins the cleanup behind a held manager write lock, polls the
rejection through to the cleanup await, then drops the future there.
Before the fix it fails with the fence left as (0, pending, unobserved);
with it the outpoint is free. A generation unit test pins the primitive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… map would have dropped

SpendObservationHandler runs synchronously inside SPV's event fan-out, so
it probes the wallets map with try_read — and a failed probe permanently
dropped the observation. That drop was labelled fail-safe, but its cost
was not a wait: TransactionDetected can be the ONLY spend-bearing event a
dispatch ever produces (InstantLock promotions carry no record here by
design, and an evicted or never-confirmed transaction inserts no
BlockProcessed record), and the pending-spend fence has no deadline
behind it. Brief contention with wallet registration/removal could
therefore fence an input for the manager's lifetime even though the
wallet HAD observed it spent.

The handler cannot await (sync callback) and must not block (the map's
writer path must never wait on the event fan-out), so a failed probe now
queues the observation — per-wallet outpoint sets under a private
std::sync::Mutex, deduplicated since observe_spent is an idempotent
set-union — and EVERY delivered event retries the queue first, including
the spend-free variants the handler otherwise ignores. Draining on a
bare SyncHeightAdvanced does not retire a fence by chain progress: it
applies evidence that already arrived and was queued. The queue is
bounded at 4096 outpoints (shed with a warning; unreachable outside a
stalled-writer pathology) and a wallet absent from a successfully read
map remains terminal — that is a resolution, not contention.

Lock ordering is acyclic by construction: pending mutex, then
wallets.try_read (a probe, never a wait — so the handler can never be
the blocked edge in a cycle with the registration/removal writers), then
the generation's in_broadcast mutex, whose critical sections take no
further lock. Nothing else in the crate takes the pending mutex.

The new broadcast test holds the wallets-map write lock across the
delivery of the wallet's own spend event, then delivers a spend-free
watermark event. Before the fix the observation vanishes and the rebuild
stays refused with InputMidBroadcast; with it the queued observation
applies and the fence clears.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing and on retry

The variant's public contract overstated both halves of its safety story.

It said "NOTHING was built, signed or broadcast". Only the
finalized-transaction build checks its selection unsigned; the
contact-payment build calls build_signed before its conflict check, and
the asset-lock build's key-wallet builder signs as it builds — on those
two paths a fully signed transaction exists at the moment of refusal.
What actually holds on all three: the attempted selection is discarded,
its fresh reservation released, and nothing is broadcast. The doc now
states that, and that "nothing was signed" is not part of the contract.

It also called this "the one build failure a caller may safely retry
UNCHANGED once the in-flight dispatch settles". A non-rejected dispatch
stays fenced after the broadcaster returns and clears only on an
observed spend — and the overwhelmingly common observation is the fenced
dispatch's OWN payment landing, at which point the intent may already be
fulfilled and a blind retry mints a duplicate logical payment. The doc
now requires reconciling the fence-protected transaction before deciding
whether the retried intent is still owed, with the unconditional-retry
case narrowed to a definitive rejection (fence released with the
reservation).

The two internal comments repeating the retry claim (the finalize choke
point and the FFI code-mapping arm) now point at the corrected contract.

No behavior change — doc comments only, so no test accompanies this by
design.

Co-Authored-By: Claude Fable 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.

Preliminary review — Codex only

The age guard and wallet-scoped dispatch fence address the main in-process reassignment race, and three of the five prior findings are fixed. One blocking cancellation hole remains in the asset-lock pre-broadcast path; the queued spend-observation retry and native lifetime documentation also need correction.
Source: Codex reviewer lanes codex-general, codex-security-auditor, codex-rust-quality, and codex-ffi-engineer (exact backend model identifiers were not present in the supplied evidence); final verifier: Anthropic Claude (exact backend model identifier was not exposed). openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and is not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 2 suggestion(s)

2 additional finding(s) omitted (not in diff).

🤖 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/asset_lock/build.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:1046-1110: Release the asset-lock pin when cancellation happens before broadcast
  `build_asset_lock_transaction_fenced` returns a pin whose default drop verdict opens the pending-spend phase, but this caller then awaits `persist_asset_lock_account_pools()` and `track_asset_lock()` before reaching `broadcaster.broadcast()` at line 1135. Cancellation during either pre-send await therefore creates a non-expiring fence for a transaction that was never submitted; cancellation while `track_asset_lock` is waiting can also occur before any recoverable `Built` row exists. Since no spend can ever be observed for that unsent transaction, the input remains blocked after the underlying reservation is swept. Keep release-on-drop semantics throughout pre-send persistence/tracking, then arm pending-on-drop synchronously at the broadcaster boundary where cancellation first becomes ambiguous.

In `packages/rs-platform-wallet/src/wallet/core/spend_observer.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/core/spend_observer.rs:163-180: Schedule queued spend observations for an actual retry
  The contention fix now preserves an observation in `pending`, but it drains that queue only when a later wallet event happens to call `on_wallet_event`. If the contended `TransactionDetected` is the final event before the stream becomes idle or disconnects, the only spend evidence remains queued indefinitely and the non-expiring fence never clears. Schedule an independent drain after contention (or notify a task when an entry is queued) so release does not depend on an unrelated future event, and cover the no-second-event case.

In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/error.rs:171-180: Mirror the corrected fence lifetime in native bindings
  The native contract still says the reservation TTL or a sync reconciles an ambiguous Core broadcast. The key-wallet reservation can expire after 24 blocks, but the independent pending-spend fence has no elapsed-time release and a sync without spend evidence does not clear it. Kotlin repeats the TTL promise in `DashSdkError.TransactionBroadcastUnconfirmed`, and Swift repeats it in both `PlatformWalletResultCode.errorTransactionBroadcastUnconfirmed` and `PlatformWalletError.transactionBroadcastUnconfirmed`. Update these mirrors to state that reservation expiry proves nothing and that callers must reconcile the possibly submitted transaction from observed-spend evidence before deciding whether the intent remains outstanding.

Comment on lines +163 to +180
fn observe(&self, observation: Option<(WalletId, Vec<OutPoint>)>) {
let mut pending = self.pending_lock();
if let Some((wallet_id, outpoints)) = observation {
Self::enqueue(&mut pending, wallet_id, outpoints);
}
if pending.is_empty() {
return;
}
// try_read on the wallets map, NOT the SPV-contended wallet_manager
// lock — see the type docs.
let Ok(wallets) = self.wallets.try_read() else {
tracing::debug!(
wallets = pending.len(),
"in-broadcast fence release deferred: wallets-map lock \
contended; observation queued for the next event"
);
return;
};

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.

🟡 Suggestion: Schedule queued spend observations for an actual retry

The contention fix now preserves an observation in pending, but it drains that queue only when a later wallet event happens to call on_wallet_event. If the contended TransactionDetected is the final event before the stream becomes idle or disconnects, the only spend evidence remains queued indefinitely and the non-expiring fence never clears. Schedule an independent drain after contention (or notify a task when an entry is queued) so release does not depend on an unrelated future event, and cover the no-second-event case.

source: ['codex']

@HashEngineering

Copy link
Copy Markdown
Collaborator

On the open spend_observer.rs contention finding — there is a second instance of the same pattern in a file this PR does not touch, and it may bear on which fix shape you pick.

wallet/core/balance_handler.rs:66 does the same thing SpendObservationHandler used to do:

let Ok(wallets) = self.wallets.try_read() else {
    tracing::debug!(
        wallet = %hex::encode(wallet_id),
        "Wallet balance update dropped: wallets-map lock contended"
    );
    return;
};

Same try_read, same silent drop on contention, same comment about the map being written only by manager lifecycle methods. That is the handler SpendObservationHandler was modelled on, which is why the shapes match. The pending queue added in b6791d35 covers the spend observer only, so this one still drops.

The consequence here is milder — a stale balance until the next event, rather than an input fenced for the manager's lifetime — so I am not suggesting it belongs in this PR. I am raising it because it changes the cost comparison between the two fixes for the open finding:

  • Notify a drain task (the suggestion on the thread) fixes the spend observer and leaves this one as is.
  • Making the wallets map lock-free removes the contention branch from both, and deletes the pending queue and the finding along with it.

On the second option: arc_swap is already a dependency of this crate and already used for the same read-mostly, write-rarely shape one file over — PlatformEventManager holds ArcSwap<Vec<Arc<dyn PlatformEventHandler>>> (events.rs:115), documented there as lock-free. The wallets map has the same access profile: nine read sites, five write sites, all five writes in manager lifecycle or load.

Four of those writes are plain inserts or removes (load.rs:203, load.rs:215, wallet_lifecycle.rs:583, and a test insert in manager/mod.rs). The one that needs real thought is wallet_lifecycle.rs:879, which is a read-modify-write: it checks Arc::ptr_eq(wallet.generation(), &generation) and removes only if the entry is still the generation being torn down. That is expressible with arc_swap's rcu(), but it is a compare-and-swap protecting exactly the "a new generation was registered under this id in the window" case the comment there warns about, so it would want its own test rather than a mechanical conversion.

So: contained, but one genuinely non-mechanical site out of five, inside a PR that has already grown a fair amount. Sequencing it as the notify-a-drain fix here and the lock-free conversion as a follow-up that also picks up balance_handler seems reasonable to me — I mainly wanted the second instance on the record so the choice is made with both in view rather than just the one the bot found.

@shumkov
shumkov dismissed thepastaclaw’s stale review August 29, 2026 12:29

All findings addressed: in-process fence fixed by author (9e19d23, red-proven), doc contract corrected (72cc976), restart-half explicitly deferred as follow-up with rationale on-thread.

@shumkov
shumkov merged commit c14278f into v4.2-dev Aug 30, 2026
18 checks passed
@shumkov
shumkov deleted the followup/v4.1/v2-handle-age-guard branch August 30, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants