Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import Foundation
import SwiftDashSDK

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -137,6 +158,7 @@ final class AddressFundFromAssetLockCoordinator: ObservableObject {
recipientHash: recipientHash
)
controllers.removeValue(forKey: key)
phaseObservers.removeValue(forKey: key)
}

// MARK: - Retention sweep
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 }
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading