Skip to content

fix(platform-wallet): wait for SPV transport before resuming asset locks that need broadcast - #4355

Open
QuantumExplorer wants to merge 1 commit into
v4.2-devfrom
claude/blissful-pasteur-23db06
Open

fix(platform-wallet): wait for SPV transport before resuming asset locks that need broadcast#4355
QuantumExplorer wants to merge 1 commit into
v4.2-devfrom
claude/blissful-pasteur-23db06

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 10, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

The app-launch asset-lock catch-up races SPV client startup and loses permanently, stranding built-but-never-broadcast asset locks — and their funds — across every subsequent session.

Hosts drive the catch-up from wallet load: SwiftDashSDKHost.startPlatformWalletManager.loadFromPersistorcatchUpStuckAssetLocks, which selects every persisted lock at statusRaw < 2 (Built/Broadcast). That runs before startSpv — in dashwallet-ios's SwiftDashSDKSPVCoordinator.performStart, roughly fifty lines and two setup steps before it. There is no readiness gate in between, and nothing re-invokes the catch-up on foreground, reconnect, or SPV-ready.

So resume_asset_lock's Built arm broadcasts into a client that has not started. That failure is classified BroadcastError::Rejected — the "provably never sent" verdict — which ends the resume. The lock never leaves Built, so every later session repeats the identical race.

Observed in a QA sim run: 60ms after the unresolved-asset-lock restore completed, the catch-up entered for a Built lock and failed with Transaction broadcast failed: SPV broadcast not sent: client not started. That lock held 2 DASH for days until someone retried by hand.

What was done?

Gate the broadcast on transport readiness in Rust, rather than reordering each host.

  • TransactionBroadcaster gains wait_until_ready(timeout), defaulted to "always ready" — DapiBroadcaster and all nine existing test doubles are untouched.
  • SpvBroadcaster overrides it, delegating to a new SpvRuntime::wait_until_ready that waits for a started client and at least one connected peer. Both halves matter: zero-peers is the other pre-send Rejected shape at launch.
  • resume_asset_lock awaits readiness only in the two arms that actually broadcast (Built, Broadcast), and deducts the wait from the caller's timeout so the total stays inside the requested budget.

Two deliberate constraints:

  • IS/CL-staged locks never wait. InstantSendLocked / ChainLocked / RecoveredFromChain already hold a proof and broadcast nothing. This exclusion is load-bearing rather than an optimization — four callers pass timeout: None on exactly that branch, so gating them would convert a cheap path re-derivation into an indefinite hang.
  • A readiness timeout is not fatal. The broadcast is attempted anyway and reports the same error it would have reported without the wait, so bounded callers cannot inherit an unbounded park.

No app-side change is required. The Swift SDK doc at the catch-up call site now states explicitly that hosts must not add a readiness gate there, since load runs before startSpv and gating would only delay the locks that need no broadcast at all.

How Has This Been Tested?

Three regression tests in recovery.rs, driven by a broadcaster whose transport comes up partway through the test:

  • built_resume_waits_for_the_broadcast_transport_to_come_up — under the catch-up's unbounded timeout, no broadcast attempt is recorded while the transport is down; once it comes up, the original transaction is broadcast (not a rebuild). Verified load-bearing: with the gate temporarily removed it fails on exactly the stranding condition.
  • built_resume_bounds_the_transport_wait_by_the_caller_timeout — a transport that never comes up still fails fast with the never-sent verdict.
  • chain_locked_resume_does_not_wait_for_the_broadcast_transport — asserts zero readiness waits and zero broadcasts.

cargo test -p platform-wallet --all-targets passes 613 + 9, exit 0. cargo clippy --all-targets clean on platform-wallet and platform-wallet-ffi. cargo fmt --all applied.

Breaking Changes

None. wait_until_ready is a defaulted trait method, so external implementors of TransactionBroadcaster compile unchanged.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Asset-lock recovery now waits for SPV transport readiness before broadcasting pending transactions.
    • Readiness checks support configurable timeouts and account for time spent waiting.
    • Catch-up can be started before SPV is ready; the wallet handles transport readiness automatically.
  • Bug Fixes

    • Improved recovery behavior during wallet or SPV startup.
    • Locks that already include finality proofs continue without unnecessary waiting.

…cks that need broadcast

The app-launch asset-lock catch-up races SPV client startup and loses
permanently. Hosts drive the catch-up from wallet load, which runs
before `startSpv` — in dashwallet-ios roughly fifty lines and two setup
steps before it. `resume_asset_lock`'s `Built` arm broadcasts into a
client that has not started, and that failure is classified
`BroadcastError::Rejected`, the "provably never sent" verdict. Nothing
reschedules the catch-up, so the lock never leaves `Built` and every
later session repeats the identical race. One observed lock held 2 DASH
for days until a manual retry.

Gate the broadcast on transport readiness rather than reordering the
hosts. `TransactionBroadcaster` gains `wait_until_ready`, defaulted to
"always ready" so `DapiBroadcaster` and the test doubles are unchanged;
`SpvBroadcaster` delegates to a new `SpvRuntime::wait_until_ready` that
waits for a started client with at least one connected peer — both
halves matter, since zero-peers is the other pre-send `Rejected` shape
at launch.

Only the two arms that actually broadcast wait, and the wait is deducted
from the caller's timeout so the total stays inside the requested
budget. The `InstantSendLocked` / `ChainLocked` / `RecoveredFromChain`
arms already hold a proof and broadcast nothing, so they never wait —
that exclusion is load-bearing, not an optimization: four callers pass
`timeout: None` on exactly that branch and gating them would turn a
cheap path re-derivation into an indefinite hang. A readiness timeout is
not fatal; the broadcast is attempted anyway and reports the same error
it would have reported without the wait.

Regression tests cover all three behaviours: a `Built` resume under the
catch-up's unbounded timeout records no broadcast attempt until the
transport comes up and then sends the original transaction; a bounded
caller still fails fast instead of parking; and a chain-locked lock
resumes without consulting readiness at all.

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

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a26a5f81-485e-4b24-8f4b-f7713466e1a7

📥 Commits

Reviewing files that changed from the base of the PR and between 6373e00 and c2b7185.

📒 Files selected for processing (5)
  • packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs
  • packages/rs-platform-wallet/src/broadcaster.rs
  • packages/rs-platform-wallet/src/spv/runtime.rs
  • packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift

📝 Walkthrough

Walkthrough

Asset-lock recovery now waits for SPV transport readiness before broadcasting Built and Broadcast locks. The wait uses the remaining caller timeout. ChainLocked locks bypass readiness checks. FFI and Swift documentation describe this behavior.

Changes

Asset-lock broadcast readiness

Layer / File(s) Summary
SPV readiness contract
packages/rs-platform-wallet/src/broadcaster.rs, packages/rs-platform-wallet/src/spv/runtime.rs
TransactionBroadcaster and SpvRuntime now expose readiness waits. SPV readiness requires a started client and a connected peer.
Recovery broadcast gating
packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs
Built and Broadcast resumes wait for readiness and reduce the proof timeout. Tests cover delayed startup, bounded waits, broadcast failure, and ChainLocked behavior.
Catch-up invocation documentation
packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
Documentation states that callers do not need to gate catch-up on SPV readiness.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AssetLockRecovery
  participant SpvBroadcaster
  participant SpvRuntime
  AssetLockRecovery->>SpvBroadcaster: wait_until_ready(timeout)
  SpvBroadcaster->>SpvRuntime: wait_until_ready(timeout)
  SpvRuntime-->>SpvBroadcaster: readiness result
  SpvBroadcaster-->>AssetLockRecovery: readiness result
  AssetLockRecovery->>SpvBroadcaster: broadcast transaction
Loading

Possibly related PRs

Suggested reviewers: shumkov, lklimek, llbartekll

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: waiting for SPV transport before resuming asset locks that require broadcasting.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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 claude/blissful-pasteur-23db06

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

@github-actions github-actions Bot added this to the v4.2.0 milestone Aug 10, 2026
@thepastaclaw

thepastaclaw commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit c2b7185)
Canonical validated blockers: 3

@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 readiness gate addresses the reported SPV startup race, but verification found three blocking regressions: an overflowing FFI timeout can abort the host, the synchronous waits can starve the executor needed to start SPV, and a delayed resume can overwrite newer asset-lock state. Two additional test gaps leave the production readiness delegation and the changed Broadcast-status path unprotected.
Source: reviewers codex general/security-auditor/rust-quality/ffi-engineer (backend model gpt-5.6-sol); final verifier codex (backend model gpt-5.6-sol). Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (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)

🔴 3 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/spv/runtime.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/spv/runtime.rs:227: An overflowing FFI timeout can panic across the C boundary
  `Instant::now() + t` panics when the resulting instant is not representable. Both public asset-lock resume FFI functions accept an unrestricted `u64` timeout and convert it directly with `Duration::from_secs`, so a value such as `UInt64.max` reaches this newly added calculation for Built or Broadcast locks. The panic occurs inside an `extern "C"` call with no unwind guard, which aborts the host instead of returning `PlatformWalletFFIResult`. Use elapsed-time subtraction or checked deadline arithmetic here, or reject unrepresentable timeout values before entering the async resume path.

In `packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs:305: The readiness wait can prevent the host from ever starting SPV
  This await is reached through `asset_lock_manager_catch_up_blocking`, which calls `runtime().block_on` from synchronous Swift task-group children. The Swift call site starts four such calls concurrently and explicitly documents that each parks one worker from the typical 4–8-worker cooperative pool for up to 300 seconds. With four Built locks on a four-worker pool, the new readiness waits can occupy every generic executor worker while waiting for SPV; if the host startup task has suspended between wallet loading and `startSpv`, it cannot resume to start the transport that would release those workers. Additional locks can repeat the delay as timed-out slots are replenished. Run these synchronous FFI calls on an overcommitting or dedicated blocking queue, expose a genuinely asynchronous FFI operation, or otherwise reserve executor capacity so catch-up cannot starve SPV startup.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs:305-309: Revalidate the asset-lock status after waiting for readiness
  `status` and `tx` are snapshotted before the readiness wait, which may now last minutes. Concurrent catch-up and explicit funding flows are supported, so another flow can advance, consume, or untrack the same lock while this task is parked. The stale Built arm then broadcasts the old transaction and `advance_asset_lock_status` unconditionally overwrites the current entry; it can downgrade a terminal Consumed tombstone to Broadcast or broadcast a transaction after a rejected build removed the row and released its input reservation. Re-read the tracked entry after readiness and make Built-to-Broadcast and final proof updates conditional on the expected current status, so terminal, removed, or newer transitions cannot be overwritten in a subsequent race window.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs:336-337: Broadcast-status readiness behavior is not covered
  The PR adds readiness waiting to both broadcasting arms, but the regression suite only constructs a Built lock. The Broadcast arm has materially different behavior because it swallows defensive re-broadcast errors and continues into proof waiting. Add a Broadcast-status test that verifies no re-broadcast occurs while transport is unavailable, that broadcasting begins after readiness, and that proof waiting still proceeds when the defensive re-broadcast returns an error.

In `packages/rs-platform-wallet/src/broadcaster.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/broadcaster.rs:249-250: Regression tests bypass the production SPV readiness path
  The recovery tests use `StartingUpBroadcaster`, which supplies its own readiness loop and a single synthetic flag. They prove that `AssetLockManager` invokes the trait method, but they do not exercise `SpvBroadcaster` delegation or the production `SpvRuntime` predicate requiring both a started client and at least one connected peer. Removing this override or regressing either half of the production predicate would leave all new tests green. Add production-boundary coverage through a recording `SpvChannel`, plus runtime coverage for the no-client and no-peer states.

/// event, with no combined signal to subscribe to. The poll interval is
/// irrelevant next to the network latency being waited on.
pub async fn wait_until_ready(&self, timeout: Option<Duration>) -> bool {
let deadline = timeout.map(|t| tokio::time::Instant::now() + t);

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.

🔴 Blocking: An overflowing FFI timeout can panic across the C boundary

Instant::now() + t panics when the resulting instant is not representable. Both public asset-lock resume FFI functions accept an unrestricted u64 timeout and convert it directly with Duration::from_secs, so a value such as UInt64.max reaches this newly added calculation for Built or Broadcast locks. The panic occurs inside an extern "C" call with no unwind guard, which aborts the host instead of returning PlatformWalletFFIResult. Use elapsed-time subtraction or checked deadline arithmetic here, or reject unrepresentable timeout values before entering the async resume path.

source: ['codex']

let proof = match status {
AssetLockStatus::Built => {
// Re-broadcast and wait for proof.
let timeout = self.await_broadcast_ready(out_point, timeout).await;

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.

🔴 Blocking: The readiness wait can prevent the host from ever starting SPV

This await is reached through asset_lock_manager_catch_up_blocking, which calls runtime().block_on from synchronous Swift task-group children. The Swift call site starts four such calls concurrently and explicitly documents that each parks one worker from the typical 4–8-worker cooperative pool for up to 300 seconds. With four Built locks on a four-worker pool, the new readiness waits can occupy every generic executor worker while waiting for SPV; if the host startup task has suspended between wallet loading and startSpv, it cannot resume to start the transport that would release those workers. Additional locks can repeat the delay as timed-out slots are replenished. Run these synchronous FFI calls on an overcommitting or dedicated blocking queue, expose a genuinely asynchronous FFI operation, or otherwise reserve executor capacity so catch-up cannot starve SPV startup.

source: ['codex']

Comment on lines +249 to +250
async fn wait_until_ready(&self, timeout: Option<Duration>) -> bool {
self.spv.wait_until_ready(timeout).await

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: Regression tests bypass the production SPV readiness path

The recovery tests use StartingUpBroadcaster, which supplies its own readiness loop and a single synthetic flag. They prove that AssetLockManager invokes the trait method, but they do not exercise SpvBroadcaster delegation or the production SpvRuntime predicate requiring both a started client and at least one connected peer. Removing this override or regressing either half of the production predicate would leave all new tests green. Add production-boundary coverage through a recording SpvChannel, plus runtime coverage for the no-client and no-peer states.

source: ['codex']

Comment on lines +336 to 337
let timeout = self.await_broadcast_ready(out_point, timeout).await;
if let Err(e) = self.broadcaster.broadcast(&tx).await {

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: Broadcast-status readiness behavior is not covered

The PR adds readiness waiting to both broadcasting arms, but the regression suite only constructs a Built lock. The Broadcast arm has materially different behavior because it swallows defensive re-broadcast errors and continues into proof waiting. Add a Broadcast-status test that verifies no re-broadcast occurs while transport is unavailable, that broadcasting begins after readiness, and that proof waiting still proceeds when the defensive re-broadcast returns an error.

source: ['codex']

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.

2 participants