fix(platform-wallet): wait for SPV transport before resuming asset locks that need broadcast - #4355
fix(platform-wallet): wait for SPV transport before resuming asset locks that need broadcast#4355QuantumExplorer wants to merge 1 commit into
Conversation
…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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughAsset-lock recovery now waits for SPV transport readiness before broadcasting ChangesAsset-lock broadcast 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
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
⛔ Blockers found — Opus deferred (commit c2b7185) |
thepastaclaw
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
🔴 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; |
There was a problem hiding this comment.
🔴 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']
| async fn wait_until_ready(&self, timeout: Option<Duration>) -> bool { | ||
| self.spv.wait_until_ready(timeout).await |
There was a problem hiding this comment.
🟡 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']
| let timeout = self.await_broadcast_ready(out_point, timeout).await; | ||
| if let Err(e) = self.broadcaster.broadcast(&tx).await { |
There was a problem hiding this comment.
🟡 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']
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.start→PlatformWalletManager.loadFromPersistor→catchUpStuckAssetLocks, which selects every persisted lock atstatusRaw < 2(Built/Broadcast). That runs beforestartSpv— in dashwallet-ios'sSwiftDashSDKSPVCoordinator.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'sBuiltarm broadcasts into a client that has not started. That failure is classifiedBroadcastError::Rejected— the "provably never sent" verdict — which ends the resume. The lock never leavesBuilt, 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
Builtlock and failed withTransaction 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.
TransactionBroadcastergainswait_until_ready(timeout), defaulted to "always ready" —DapiBroadcasterand all nine existing test doubles are untouched.SpvBroadcasteroverrides it, delegating to a newSpvRuntime::wait_until_readythat waits for a started client and at least one connected peer. Both halves matter: zero-peers is the other pre-sendRejectedshape at launch.resume_asset_lockawaits 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:
InstantSendLocked/ChainLocked/RecoveredFromChainalready hold a proof and broadcast nothing. This exclusion is load-bearing rather than an optimization — four callers passtimeout: Noneon exactly that branch, so gating them would convert a cheap path re-derivation into an indefinite hang.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
startSpvand 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-targetspasses 613 + 9, exit 0.cargo clippy --all-targetsclean onplatform-walletandplatform-wallet-ffi.cargo fmt --allapplied.Breaking Changes
None.
wait_until_readyis a defaulted trait method, so external implementors ofTransactionBroadcastercompile unchanged.Checklist:
For repository code-owners and collaborators only
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes