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/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..1bc59ca9478 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PendingPlatformFundFromAssetLocksList.swift @@ -43,8 +43,24 @@ struct PendingPlatformFundFromAssetLocksList: View { @Binding var resumingAssetLock: PersistentAssetLock? var body: some View { - let inFlight = activeControllersForWallet - 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 @@ -57,27 +73,28 @@ 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() } } ForEach(Array(orphans.enumerated()), id: \.element.id) { idx, lock in ResumablePlatformFundFromAssetLockRow( lock: lock, + hasActiveFunding: hasActiveFunding, onResume: { resumingAssetLock = lock } ) .padding(.horizontal) @@ -94,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 } } @@ -225,6 +247,13 @@ 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 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 var body: some View { @@ -253,26 +282,45 @@ 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. - Button(action: onResume) { - Label("Resume", systemImage: "arrow.clockwise") - .labelStyle(.titleAndIcon) - .font(.callout) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) + 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 { - 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 + } + } + + private var resumeButton: some View { + Button(action: onResume) { + Label("Resume", systemImage: "arrow.clockwise") + .labelStyle(.titleAndIcon) + .font(.callout) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + private var waitingIndicator: some View { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Waiting for InstantSend / ChainLock…") + .font(.caption) + .foregroundColor(.secondary) } }