From 99f471dea3a6df1d0384e5991922a9ba1e14b789 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 5 Jul 2026 18:25:31 +0700 Subject: [PATCH 1/2] fix(swift-example-app): resume orphaned Broadcast asset-lock top-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...FundFromAssetLockPlatformAddressView.swift | 11 +++- ...endingPlatformFundFromAssetLocksList.swift | 52 ++++++++++++++++--- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundFromAssetLockPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundFromAssetLockPlatformAddressView.swift index 63ef9aead89..427da0cb260 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundFromAssetLockPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/FundFromAssetLockPlatformAddressView.swift @@ -311,7 +311,16 @@ struct FundFromAssetLockPlatformAddressView: View { } header: { Text("Resuming") } footer: { - Text("The asset lock was already built and reached a usable proof state. Pick a destination address to complete the funding.") + // Status-aware: a proof-ready lock (InstantSendLocked / + // ChainLocked) submits as soon as a recipient is picked; a + // Broadcast lock still needs finality, so resuming it waits + // for the ChainLock (guaranteed finality, however long it + // takes) before crediting the address. + if lock.canFundIdentity { + Text("The asset lock already reached a usable proof state. Pick a destination address to complete the funding.") + } else { + Text("The asset lock is broadcast and still awaiting InstantSend / ChainLock finality. Pick a destination address; resuming will wait for finality, then credit the address.") + } } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift index bb73bd65d11..497fced5fac 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift @@ -44,6 +44,20 @@ struct PendingPlatformFundFromAssetLocksList: View { var body: some View { let inFlight = activeControllersForWallet + // Whether a funding is actively driving an asset lock on this + // wallet right now. A fresh-build fund sits at `Broadcast` + // (statusRaw 1) for the entire finality wait (unbounded, since a + // ChainLock is guaranteed finality), so its lock co-renders as an + // orphan row. We must NOT offer Resume on a Broadcast row while + // this is true — the outpoint isn't exposed by the controller, so + // we can't tell the in-flight lock from a genuinely-orphaned one, + // and resuming the in-flight one would spawn a second consuming + // flow on the same outpoint. Gating on "no active funding" is the + // safe superset: active controllers are transient, so a truly + // 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 } let orphans = resumableLocks(excludingControllerOutpoints: Set(inFlight.compactMap { _ in // Controllers don't currently store the outpoint of the // asset lock they're driving. The de-dupe set therefore @@ -78,6 +92,7 @@ struct PendingPlatformFundFromAssetLocksList: View { ForEach(Array(orphans.enumerated()), id: \.element.id) { idx, lock in ResumablePlatformFundFromAssetLockRow( lock: lock, + hasActiveFunding: hasActiveFunding, onResume: { resumingAssetLock = lock } ) .padding(.horizontal) @@ -225,6 +240,12 @@ struct PendingPlatformFundFromAssetLockRow: View { /// the outpoint. struct ResumablePlatformFundFromAssetLockRow: View { let lock: PersistentAssetLock + /// `true` when some funding is actively in flight on this wallet. + /// Suppresses the Resume affordance on a `Broadcast` (statusRaw 1) + /// row — that lock might be the one the in-flight funding is + /// driving, and resuming it would race a second consume on the same + /// outpoint. See `PendingPlatformFundFromAssetLocksList.body`. + let hasActiveFunding: Bool let onResume: () -> Void var body: some View { @@ -258,14 +279,21 @@ struct ResumablePlatformFundFromAssetLockRow: View { // it encodes — `statusRaw ∈ {2, 3}` — is exactly the // "lock has a usable IS or CL proof" gate the address- // funding submit path needs. Naming carryover only. - Button(action: onResume) { - Label("Resume", systemImage: "arrow.clockwise") - .labelStyle(.titleAndIcon) - .font(.callout) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) + resumeButton + } else if !hasActiveFunding { + // Broadcast (statusRaw 1) with no funding in flight on this + // wallet: the lock is genuinely orphaned (app killed mid-wait, + // or a prior attempt failed), so it has no driver and would + // otherwise be a permanent "Waiting…" dead end. Offer Resume — + // `resume_asset_lock` picks the Broadcast lock back up and + // waits for finality (now unbounded, since a ChainLock is + // guaranteed). Safe here precisely because nothing else is + // driving this outpoint. + resumeButton } else { + // Broadcast while a funding is in flight — this row may be the + // lock that funding is driving. Keep the non-interactive + // spinner so the user can't spawn a duplicate consume on it. HStack(spacing: 6) { ProgressView() .controlSize(.small) @@ -276,6 +304,16 @@ struct ResumablePlatformFundFromAssetLockRow: View { } } + private var resumeButton: some View { + Button(action: onResume) { + Label("Resume", systemImage: "arrow.clockwise") + .labelStyle(.titleAndIcon) + .font(.callout) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + private func formatDuffs(_ amountDuffs: Int64) -> String { let dash = Double(amountDuffs) / 1e8 return String(format: "%g DASH", dash) From a4ace46c34542c9b3e0ff2fd364fa21e464e7187 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 5 Jul 2026 18:52:19 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(swift-example-app):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20fix=20stale=20Resume=20gate=20and=20cover=20proo?= =?UTF-8?q?f-ready=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../AddressFundFromAssetLockCoordinator.swift | 23 ++++ ...endingPlatformFundFromAssetLocksList.swift | 114 ++++++++++-------- 2 files changed, 85 insertions(+), 52 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockCoordinator.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockCoordinator.swift index 2644cdaed07..fb3e6febe57 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockCoordinator.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/AddressFundFromAssetLockCoordinator.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import SwiftDashSDK @@ -30,6 +31,20 @@ final class AddressFundFromAssetLockCoordinator: ObservableObject { /// screen can observe map mutations via `objectWillChange`. @Published private(set) var controllers: [SlotKey: AddressFundFromAssetLockController] = [:] + /// Per-slot subscriptions that forward each controller's + /// `objectWillChange` (fired on a `phase` transition) to this + /// coordinator's own `objectWillChange`. + /// + /// The `@Published controllers` map only re-publishes on + /// insert/remove, NOT when a controller already in the map flips + /// phase (`.inFlight → .failed`/`.completed`, or a `.failed`-slot + /// retry that reuses the controller). A view that observes only the + /// coordinator — e.g. the wallet-detail "Pending Top Ups" list, whose + /// Resume gate reads controller `phase` — would otherwise render from + /// stale phase state across those transitions. Keyed by slot so the + /// subscription is torn down with the controller it tracks. + private var phaseObservers: [SlotKey: AnyCancellable] = [:] + /// True when at least one slot is currently in flight (phase /// `.inFlight`). Used by the network toggle's `.disabled(_:)` /// modifier — switching testnet ↔ mainnet mid-flight tears down @@ -117,6 +132,12 @@ final class AddressFundFromAssetLockCoordinator: ObservableObject { recipientType: recipientType ) controllers[key] = controller + // Forward this controller's phase transitions to observers of the + // coordinator (see `phaseObservers`). Registered before `submit()` + // so the initial `.idle → .inFlight` flip re-publishes too. + phaseObservers[key] = controller.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } controller.submit(body: body) scheduleRetentionSweep(key: key, controller: controller) return controller @@ -137,6 +158,7 @@ final class AddressFundFromAssetLockCoordinator: ObservableObject { recipientHash: recipientHash ) controllers.removeValue(forKey: key) + phaseObservers.removeValue(forKey: key) } // MARK: - Retention sweep @@ -163,6 +185,7 @@ final class AddressFundFromAssetLockCoordinator: ObservableObject { Date().timeIntervalSince(at) >= 30 { await MainActor.run { _ = self?.controllers.removeValue(forKey: key) + self?.phaseObservers.removeValue(forKey: key) } return } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift index 497fced5fac..1bc59ca9478 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift @@ -43,22 +43,24 @@ struct PendingPlatformFundFromAssetLocksList: View { @Binding var resumingAssetLock: PersistentAssetLock? var body: some View { - let inFlight = activeControllersForWallet - // Whether a funding is actively driving an asset lock on this - // wallet right now. A fresh-build fund sits at `Broadcast` - // (statusRaw 1) for the entire finality wait (unbounded, since a - // ChainLock is guaranteed finality), so its lock co-renders as an - // orphan row. We must NOT offer Resume on a Broadcast row while - // this is true — the outpoint isn't exposed by the controller, so - // we can't tell the in-flight lock from a genuinely-orphaned one, - // and resuming the in-flight one would spawn a second consuming - // flow on the same outpoint. Gating on "no active funding" is the - // safe superset: active controllers are transient, so a truly - // 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 } - let orphans = resumableLocks(excludingControllerOutpoints: Set(inFlight.compactMap { _ in + let walletControllers = controllersForWallet + // Whether any funding is currently in flight on this wallet (a + // controller in `.inFlight`). We can't match a controller to the + // outpoint it's driving, so while this holds, ANY resumable row + // might be the lock that funding is consuming — a fresh fund's own + // lock passes through Broadcast → InstantSendLocked during its + // (unbounded) finality wait + ST submit, and it's indistinguishable + // here from an unrelated proof-ready (statusRaw 2/3) orphan. The + // gate below therefore suppresses Resume on EVERY row while a + // funding is in flight; offering it would risk a second consume on + // the same outpoint. Safe superset: `.inFlight` controllers are + // transient, so a genuinely-orphaned lock always becomes resumable + // once they clear. `.completed`/`.failed` controllers aren't + // consuming (only `.isActive` counts), and their phase transitions + // re-render this view via the coordinator's forwarded + // `objectWillChange`. + let hasActiveFunding = walletControllers.contains { $0.phase.isActive } + let orphans = resumableLocks(excludingControllerOutpoints: Set(walletControllers.compactMap { _ in // Controllers don't currently store the outpoint of the // asset lock they're driving. The de-dupe set therefore // never has entries today — but the SwiftData status @@ -71,21 +73,21 @@ struct PendingPlatformFundFromAssetLocksList: View { nil })) - if !inFlight.isEmpty || !orphans.isEmpty { + if !walletControllers.isEmpty || !orphans.isEmpty { VStack(alignment: .leading, spacing: 8) { HStack { - Text("Pending Platform Top Ups (\(inFlight.count + orphans.count))") + Text("Pending Platform Top Ups (\(walletControllers.count + orphans.count))") .font(.headline) Spacer() } .padding(.horizontal) VStack(spacing: 0) { - ForEach(Array(inFlight.enumerated()), id: \.element.platformFundFromAssetLockRowID) { idx, controller in + ForEach(Array(walletControllers.enumerated()), id: \.element.platformFundFromAssetLockRowID) { idx, controller in PendingPlatformFundFromAssetLockRow(controller: controller) .padding(.horizontal) .padding(.vertical, 10) - if idx < inFlight.count - 1 || !orphans.isEmpty { + if idx < walletControllers.count - 1 || !orphans.isEmpty { Divider() } } @@ -109,8 +111,13 @@ struct PendingPlatformFundFromAssetLocksList: View { } } - /// In-flight controllers scoped to this wallet, newest-first. - private var activeControllersForWallet: [AddressFundFromAssetLockController] { + /// All funding controllers for this wallet, newest-first — named for + /// the wallet scope, not a phase. `coordinator.activeControllers()` + /// returns every slot in the map, including `.completed`/`.failed` + /// ones that linger until dismissed or swept, so the `.isActive` + /// filter in `hasActiveFunding` is doing real work, not restating the + /// name. + private var controllersForWallet: [AddressFundFromAssetLockController] { coordinator.activeControllers().filter { $0.walletId == walletId } } @@ -241,10 +248,11 @@ struct PendingPlatformFundFromAssetLockRow: View { struct ResumablePlatformFundFromAssetLockRow: View { let lock: PersistentAssetLock /// `true` when some funding is actively in flight on this wallet. - /// Suppresses the Resume affordance on a `Broadcast` (statusRaw 1) - /// row — that lock might be the one the in-flight funding is - /// driving, and resuming it would race a second consume on the same - /// outpoint. See `PendingPlatformFundFromAssetLocksList.body`. + /// Suppresses the Resume affordance on this row (at ANY status) — the + /// in-flight funding might be driving this very lock (the controller + /// doesn't expose the outpoint it's consuming), and resuming it would + /// race a second consume on the same outpoint. See + /// `PendingPlatformFundFromAssetLocksList.body`. let hasActiveFunding: Bool let onResume: () -> Void @@ -274,33 +282,25 @@ struct ResumablePlatformFundFromAssetLockRow: View { @ViewBuilder private var trailingAffordance: some View { - if lock.canFundIdentity { - // `canFundIdentity` is identity-named but the predicate - // it encodes — `statusRaw ∈ {2, 3}` — is exactly the - // "lock has a usable IS or CL proof" gate the address- - // funding submit path needs. Naming carryover only. - resumeButton - } else if !hasActiveFunding { - // Broadcast (statusRaw 1) with no funding in flight on this - // wallet: the lock is genuinely orphaned (app killed mid-wait, - // or a prior attempt failed), so it has no driver and would - // otherwise be a permanent "Waiting…" dead end. Offer Resume — - // `resume_asset_lock` picks the Broadcast lock back up and - // waits for finality (now unbounded, since a ChainLock is - // guaranteed). Safe here precisely because nothing else is - // driving this outpoint. - resumeButton + if hasActiveFunding { + // A funding is in flight on this wallet. We can't tell whether + // it's driving THIS lock (the controller doesn't expose its + // outpoint), so offering Resume — at any status, including a + // proof-ready (2/3) lock whose ST is mid-consume — could race a + // second consume on the same outpoint. Keep the non-interactive + // spinner until the funding clears. Over-suppresses a genuinely + // orphaned lock during an unrelated fund, but only transiently. + waitingIndicator } else { - // Broadcast while a funding is in flight — this row may be the - // lock that funding is driving. Keep the non-interactive - // spinner so the user can't spawn a duplicate consume on it. - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - Text("Waiting for InstantSend / ChainLock…") - .font(.caption) - .foregroundColor(.secondary) - } + // Nothing is driving a fund on this wallet, so every visible row + // (statusRaw 1/2/3, per the list's `isVisibleAsResumable` + // filter) is a genuinely-resumable orphan with no driver: a + // proof-ready (`canFundIdentity`, statusRaw 2/3) lock submits as + // soon as a recipient is picked; a Broadcast (statusRaw 1) lock + // re-enters the (now unbounded) finality wait via + // `resume_asset_lock`. Without this the Broadcast case was a + // permanent "Waiting…" dead end. + resumeButton } } @@ -314,6 +314,16 @@ struct ResumablePlatformFundFromAssetLockRow: View { .controlSize(.small) } + private var waitingIndicator: some View { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Waiting for InstantSend / ChainLock…") + .font(.caption) + .foregroundColor(.secondary) + } + } + private func formatDuffs(_ amountDuffs: Int64) -> String { let dash = Double(amountDuffs) / 1e8 return String(format: "%g DASH", dash)