From 786deb9ead6a0967451126a08485a1af9a612356 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 5 Jul 2026 18:59:19 +0700 Subject: [PATCH] fix(swift-example-app): resume orphaned Broadcast identity-registration locks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling of the address-funding fix (#4007). `ResumableRegistrationRow` gated its Resume button on `canFundIdentity` (statusRaw 2/3) and rendered a permanent "Waiting for InstantSendLock or ChainLock…" spinner for a Broadcast (statusRaw 1) lock. An identity registration interrupted at Broadcast — app killed mid-wait, or an aged lock whose InstantSend quorum rotated — left committed on-chain funds behind that spinner with no in-app recovery (the launch catch-up sweep caps each pass at 300s). Offer Resume for every row instead. Safe without any extra gate: the "Resumable Registrations" list already filters to statusRaw 1...3 and anti-joins on `(walletId, identityIndex)` unioned with active-controller slots, so any lock reaching a row is a genuinely-orphaned one with no driver. Resuming a Broadcast lock re-enters the finality wait via `resume_asset_lock`. Unlike the address-funding sibling — whose lock doesn't know its recipient, forcing the coarser `hasActiveFunding` gate — identity locks carry their `identityIndex`, so the anti-join excludes the in-flight lock precisely and no gate is needed. Co-Authored-By: Claude Opus 4.8 --- .../Core/Views/IdentitiesContentView.swift | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 777966637c9..98f559936e9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -570,32 +570,37 @@ private struct ResumableRegistrationRow: View { .padding(.vertical, 2) } - /// Trailing view that depends on the lock's stage. At Broadcast - /// (`!canFundIdentity`) the lock isn't usable yet — SPV is waiting - /// on the masternodes to sign an InstantSendLock — so we show - /// a spinner instead of a button. SwiftData `@Query` is - /// reactive, so when the persister flips the row to - /// `InstantSendLocked` this view re-renders into the Resume - /// button without any extra plumbing. - @ViewBuilder + /// Trailing Resume button. Every lock reaching this row is + /// `statusRaw` 1...3 (per `crossWalletResumableLocks`) AND — by the + /// list's `(walletId, identityIndex)` slot anti-join, which unions + /// active-controller slots — is NOT owned by an in-flight + /// registration. So every row is a genuinely-orphaned lock with no + /// driver, and offering Resume is always safe: + /// - InstantSendLocked / ChainLocked (`canFundIdentity`) submits the + /// IdentityCreate as soon as it's tapped. + /// - Broadcast (statusRaw 1) has no proof yet; resuming re-enters the + /// finality wait via `resume_asset_lock` (which re-broadcasts the + /// tx, then waits for the ChainLock — guaranteed finality, however + /// long it takes). This previously rendered a permanent spinner, + /// stranding a broadcast-but-interrupted registration lock (app + /// killed mid-wait, or an aged lock whose IS quorum rotated) with + /// no in-app recovery path. + /// + /// Unlike the address-funding sibling, no `hasActiveFunding` gate is + /// needed here: identity locks carry their `identityIndex`, so the + /// anti-join excludes the in-flight lock precisely, where the + /// address flow (whose lock doesn't know its recipient) cannot. + /// + /// SwiftData `@Query` re-renders this row as the persister advances + /// the lock's status. private var trailingAffordance: some View { - if lock.canFundIdentity { - Button(action: onResume) { - Label("Resume", systemImage: "arrow.clockwise") - .labelStyle(.titleAndIcon) - .font(.callout) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } else { - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - Text("Waiting for InstantSendLock or ChainLock…") - .font(.caption) - .foregroundColor(.secondary) - } + Button(action: onResume) { + Label("Resume", systemImage: "arrow.clockwise") + .labelStyle(.titleAndIcon) + .font(.callout) } + .buttonStyle(.borderedProminent) + .controlSize(.small) } private func formatDuffs(_ amountDuffs: Int64) -> String {