Skip to content

fix(swift-example-app): resume orphaned Broadcast asset-lock top-ups#4007

Merged
QuantumExplorer merged 2 commits into
v4.1-devfrom
claude/resume-orphaned-broadcast-topups
Jul 5, 2026
Merged

fix(swift-example-app): resume orphaned Broadcast asset-lock top-ups#4007
QuantumExplorer merged 2 commits into
v4.1-devfrom
claude/resume-orphaned-broadcast-topups

Conversation

@QuantumExplorer

Copy link
Copy Markdown
Member

Follow-up to #4006 (merged). That PR made the asset-lock ChainLock wait unbounded so a top-up can't falsely "fail"; this one gives an interrupted top-up a way out of the resulting Broadcast state.

Issue being fixed or feature implemented

Defect 2 from the ADDR-09 top-up QA. A platform-address top-up that broadcast its asset lock but was interrupted before finality — app killed mid-wait, or a prior attempt failed — leaves a PersistentAssetLock at statusRaw 1 (Broadcast). The "Pending Platform Top Ups" row gated its Resume affordance on canFundIdentity (statusRaw ∈ {2,3}), so a Broadcast row rendered only a non-interactive spinner — no way to resume, cancel, or retry. Result: a permanent "Waiting for InstantSend / ChainLock…" dead end with committed on-chain funds stuck behind it.

What was done?

resume_asset_lock already supports resuming a Broadcast lock (it re-enters the finality wait — now unbounded, per #4006), so the fix is UI-only.

  • Offer Resume on a Broadcast (statusRaw 1) orphan row in PendingPlatformFundFromAssetLocksList. Tapping it routes through the existing resumeFundFromAssetLockplatform-wallet path.
  • Guard against a duplicate-consume race. A fresh-build fund sits at Broadcast for the entire (unbounded) finality wait, so its lock co-renders as an orphan row. The controller doesn't expose the outpoint it's driving, so we can't distinguish that in-flight lock from a genuinely-orphaned one; resuming the in-flight one would spawn a second consuming flow on the same outpoint. So the Broadcast Resume is gated on "no funding in flight on this wallet" — a safe superset, since active controllers are transient (a truly orphaned lock always becomes resumable once they clear). Proof-ready (statusRaw 2/3) rows keep their existing unconditional Resume (their co-render window is brief).
  • Status-aware resume footer in FundFromAssetLockPlatformAddressView: it no longer claims a Broadcast lock "reached a usable proof state" — it says resuming will wait for finality, then credit the address.

No Rust and no FFI changes; this is presentation logic only (the resume itself routes through platform-wallet), consistent with the Swift-SDK architectural rules.

How Has This Been Tested?

Not yet built/run — validation is pending the xcframework rebuild (build_ios.sh) that also picks up #4006's runtime behavior, after which the resume can be exercised on the simulator against testnet (kill the app mid-top-up → confirm the orphaned Broadcast row now offers Resume and completes on finality). Code-level self-review only so far: single call site of the changed row initializer, and PersistentAssetLock: AssetLockResumeRow conformance is in place.

Breaking Changes

None.

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 added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

A platform-address top-up that broadcast its asset lock but was
interrupted before finality (app killed mid-wait, or a prior attempt
failed) leaves a PersistentAssetLock at statusRaw 1 (Broadcast). The
"Pending Platform Top Ups" row gated its Resume affordance on
canFundIdentity (statusRaw ∈ {2,3}), so a Broadcast row rendered only a
non-interactive spinner — no way to resume, and thus a permanent
"Waiting for InstantSend / ChainLock…" dead end with committed on-chain
funds stuck behind it.

resume_asset_lock already supports resuming a Broadcast lock (it re-enters
the finality wait, now unbounded), so the fix is UI-only: offer Resume on
a Broadcast orphan row.

The one hazard: a fresh-build fund sits at Broadcast for the entire
(now unbounded) finality wait, so its lock co-renders as an orphan row.
The controller doesn't expose the outpoint it's driving, so we can't tell
that in-flight lock from a genuinely-orphaned one — and resuming the
in-flight one would spawn a second consuming flow on the same outpoint.
Gate the Broadcast Resume on "no funding in flight on this wallet": a
safe superset, since active controllers are transient, so a truly
orphaned Broadcast lock always becomes resumable once they clear.
Proof-ready (statusRaw 2/3) rows keep their existing unconditional Resume
(brief co-render window).

Also make the resume view's footer status-aware: it no longer claims a
Broadcast lock "reached a usable proof state" — it says resuming will
wait for finality before crediting.

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

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 22 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fd03f022-a14a-4ea7-b932-6122515c7015

📥 Commits

Reviewing files that changed from the base of the PR and between e8b9da2 and a4ace46.

📒 Files selected for processing (3)
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockCoordinator.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundFromAssetLockPlatformAddressView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/resume-orphaned-broadcast-topups

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.

@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 5, 2026
@thepastaclaw

thepastaclaw commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit a4ace46)

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

Code Review

Presentation-layer only PR that exposes Resume on truly-orphaned Broadcast asset-lock rows, gated on wallet-scoped 'no funding in flight'. No Rust/FFI/consensus surface touched and the actual resume still routes through platform-wallet's existing FFI. One real SwiftUI observation-lifecycle gap in the new hasActiveFunding gate is worth calling out; the coarse wallet-scope granularity is a documented trade-off.

Source: reviewers=claude-general(opus), codex-general(gpt-5.5), claude-security-auditor(opus), codex-security-auditor(gpt-5.5), claude-ffi-engineer(opus), codex-ffi-engineer(gpt-5.5); verifier=claude(opus).

1 suggestion(s) | 1 nitpick(s)

Review findings
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift:60: `hasActiveFunding` goes stale on controller phase transitions
  The parent view observes `AddressFundFromAssetLockCoordinator` via `@ObservedObject`, which only fires `objectWillChange` on mutations of the `@Published controllers` dictionary (add/remove/reassign keys). An in-place `phase` transition on a controller already in the map (`.inFlight` → `.failed` or `.completed`) does not mutate the dict — the controller is a reference type stored as a value, so the dict itself is unchanged. The in-flight child row observes the controller directly and updates, but the parent's `body` is not re-invoked, so `hasActiveFunding` (computed from `inFlight.contains { $0.phase.isActive }`) sticks at the value captured when the controller was inserted at `.inFlight`. Consequences: (1) a truly-orphaned Broadcast row co-existing with a `.failed` controller keeps rendering the non-interactive `Waiting…` spinner instead of Resume until the user taps Dismiss on the failed row (which does mutate the map and re-renders the parent); (2) a truly-orphaned Broadcast row co-existing with a `.completed` controller stays non-resumable for the ~30s until the retention sweep removes the completed slot. The primary app-killed-during-broadcast recovery path (no controllers in memory on relaunch) is unaffected. A simple fix is to also observe phase changes at the parent level — e.g., have the parent watch each controller's `objectWillChange` and re-publish, or expose an `@Published anyPhaseActive: Bool` on the coordinator that is updated by the controllers on phase transitions.

- [NITPICK] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift:60-72: Wallet-scoped `hasActiveFunding` blocks unrelated orphan Broadcast rows
  `hasActiveFunding` is wallet-scoped, not outpoint- or account-scoped, so a genuinely orphaned Broadcast lock on wallet W stays non-resumable while any unrelated address-fund controller on W is `.inFlight`. This is called out as a deliberate 'safe superset' in both the code comment and the PR description because `AddressFundFromAssetLockController` does not currently expose the outpoint it is driving, and the stubbed `excludingControllerOutpoints` de-dupe therefore returns nil today. With the ChainLock finality wait now unbounded (#4006), the 'unresumable' window can be arbitrarily long in the worst case. The plumbing for the precise fix is already stubbed at lines 61–72; once the controller exposes its outpoint after broadcast, this coarse gate should be tightened rather than left in place.

// orphaned Broadcast lock always becomes resumable once they
// clear. `.completed`/`.failed` controllers aren't consuming, so
// only `.isActive` (`.inFlight`) counts.
let hasActiveFunding = inFlight.contains { $0.phase.isActive }

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: hasActiveFunding goes stale on controller phase transitions

The parent view observes AddressFundFromAssetLockCoordinator via @ObservedObject, which only fires objectWillChange on mutations of the @Published controllers dictionary (add/remove/reassign keys). An in-place phase transition on a controller already in the map (.inFlight.failed or .completed) does not mutate the dict — the controller is a reference type stored as a value, so the dict itself is unchanged. The in-flight child row observes the controller directly and updates, but the parent's body is not re-invoked, so hasActiveFunding (computed from inFlight.contains { $0.phase.isActive }) sticks at the value captured when the controller was inserted at .inFlight. Consequences: (1) a truly-orphaned Broadcast row co-existing with a .failed controller keeps rendering the non-interactive Waiting… spinner instead of Resume until the user taps Dismiss on the failed row (which does mutate the map and re-renders the parent); (2) a truly-orphaned Broadcast row co-existing with a .completed controller stays non-resumable for the ~30s until the retention sweep removes the completed slot. The primary app-killed-during-broadcast recovery path (no controllers in memory on relaunch) is unaffected. A simple fix is to also observe phase changes at the parent level — e.g., have the parent watch each controller's objectWillChange and re-publish, or expose an @Published anyPhaseActive: Bool on the coordinator that is updated by the controllers on phase transitions.

source: ['codex-general', 'claude-general']

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.

Resolved in a4ace46hasActiveFunding goes stale on controller phase transitions no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

…ver proof-ready rows

Follow-up review of the Broadcast-resume change surfaced three issues in
the `hasActiveFunding` gate; this addresses them.

1. Stale gate (correctness). The gate is computed in a body that observes
   only the coordinator, whose @published `controllers` map re-publishes
   on insert/remove only — NOT when a controller already in the map flips
   phase. So `.inFlight → .failed` (an ST submit failing, leaving a
   genuinely-orphaned Broadcast lock) did not re-render the list, and the
   orphan kept the "Waiting…" spinner instead of Resume — the exact dead
   end this PR fixes — until the user dismissed the failed row. The
   reverse `.failed → .inFlight` retry (reusing the controller via
   `startFunding`) also mutated no map entry, leaving Resume visible on a
   lock a retry was driving. Fix: the coordinator now forwards each
   controller's `objectWillChange` to its own, so phase transitions
   re-render observers (`phaseObservers`, torn down with the controller).

2. Proof-ready rows were ungated (correctness). The gate only covered the
   Broadcast branch; the `canFundIdentity` (statusRaw 2/3) branch always
   showed Resume. A fresh fund's own lock passes through 2/3 while its ST
   is mid-consume, co-rendering as an orphan row with a live Resume button
   — the same duplicate-consume race the gate exists to prevent, one
   status later. Unify: suppress Resume on EVERY row while a funding is in
   flight; otherwise (every visible row is statusRaw 1/2/3) offer Resume.

3. Misnomer (clarity). The `inFlight` local actually held controllers of
   all phases (completed/failed linger until dismissed), making
   `inFlight.contains { $0.phase.isActive }` read as redundant and
   inviting a `!inFlight.isEmpty` "simplification" that would regress the
   gate. Renamed to `walletControllers` / `controllersForWallet`.

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.

Code Review

Prior reconciliation: prior-1 FIXED — AddressFundFromAssetLockCoordinator now maintains phaseObservers: [SlotKey: AnyCancellable] that forward each controller's objectWillChange to the coordinator's own, and the list's hasActiveFunding (an @ObservedObject coordinator observer) now re-renders on .idle→.inFlight→.completed/.failed and on .failed→.inFlight retry; subscriptions are registered before submit() and are removed on dismiss() and by the 30s retention sweep, so no leak. prior-2 INTENTIONALLY DEFERRED — the wallet-scoped gate is deliberately broadened to suppress Resume on every status (1/2/3) while a funding is in flight, documented as a safe superset until controllers expose their outpoint via the already-stubbed excludingControllerOutpoints: de-dupe seam. Carried-forward prior findings: 0. New findings in latest delta: 0. Change is presentation-only, respects Swift SDK architectural rules (no orchestration, actual resume routes through platform-wallet), no consensus/versioning surface touched.

Source: reviewers=reviewer-claude-general-4007-1783252933(opus), reviewer-codex-general-4007-1783252933(gpt-5.5); verifier=verifier-4007-1783252933(opus).

@QuantumExplorer QuantumExplorer merged commit 9e2c18a into v4.1-dev Jul 5, 2026
16 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/resume-orphaned-broadcast-topups branch July 5, 2026 12:29
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