From 739efab9ab31d7accd39fbf19a16d29ba9446489 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 5 Jul 2026 14:32:16 -0500 Subject: [PATCH 1/2] fix(swift-example-app): gate Resumable Registrations to identity funding types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Identities-tab Resumable Registrations surface filtered `PersistentAssetLock` rows on `statusRaw` and the per-slot anti-join alone. Every asset lock on the active network was in scope — including `fundingTypeRaw == 4` (AssetLockAddressTopUp) and `5` (AssetLockShieldedAddressTopUp), whose scripts commit to an address-flow recipient. Tapping Resume on one of those rows routed the lock into `CreateIdentityView(preselectedAssetLock:)` and would fire an IdentityCreate the user never intended. Add `fundingTypeRaw` to `AssetLockResumeRow` and require `0...3` (IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound / IdentityInvitation) in `crossWalletResumableLocks` before the status and anti-join gates. Address-flow locks stay on `PendingPlatformFundFromAssetLocksList`, their proper surface. Also refresh stale docs/comments that still described Broadcast rows as spinner-only and Consumed as perpetual-spinner — PR #4010 made every resumable row a Resume button. Regression coverage in `CreateIdentityResumableTests`: address / shielded-address top-ups are rejected, each of `fundingTypeRaw` 0...3 is accepted, and unknown discriminators default to reject. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 61 +++++---- .../Views/CreateIdentityView.swift | 12 +- .../CreateIdentityResumableTests.swift | 122 +++++++++++++++--- 3 files changed, 154 insertions(+), 41 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index 98f559936e9..d603eb98754 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -236,23 +236,16 @@ struct IdentitiesContentView: View { /// SwiftData, but the "Pending Registrations" section above /// only reflects the in-memory coordinator state. /// - /// Each row's trailing affordance is staged on the lock's - /// `statusRaw`: - /// - `1` Broadcast: spinner + "Waiting for InstantSendLock or - /// ChainLock…" — the lock can't fund a Platform identity - /// until the masternodes sign either lock type. For - /// freshly broadcast funding txs the IS lock typically - /// arrives within seconds; for aged stuck locks (catch-up - /// case) the IS quorum has rotated so only a ChainLock - /// can resolve them. Either path lands as - /// `statusRaw = 2` or `3`. SPV is running; the persister - /// will flip the row when the event arrives, and - /// SwiftData `@Query` re-renders the row into the - /// actionable state without any extra wiring. - /// - `2` / `3` InstantSendLocked / ChainLocked: Resume - /// button. Tapping opens `CreateIdentityView` pre-configured - /// for the `.unusedAssetLock` funding path with this lock - /// pinned. + /// Every row now renders a Resume button (see PR #4010): tapping + /// opens `CreateIdentityView` pre-configured for the + /// `.unusedAssetLock` funding path with this lock pinned. + /// - `1` Broadcast: no proof yet — `resume_asset_lock` re-broadcasts + /// the tx and re-enters the finality wait (indefinite ChainLock + /// wait). Without a Resume path a broadcast-but-interrupted lock + /// (app killed mid-wait, or an aged lock whose IS quorum has + /// rotated) would sit forever with no in-app recovery. + /// - `2` / `3` InstantSendLocked / ChainLocked: the lock is + /// fund-ready; Resume submits IdentityCreate directly. /// /// Empty when there are no orphan locks; collapses to nothing /// in that case so the rest of the screen isn't pushed down by @@ -281,6 +274,9 @@ struct IdentitiesContentView: View { /// Pure anti-join across all wallets. A lock is *visible* on the /// Resumable Registrations surface iff + /// - `fundingTypeRaw` is in `0...3` (identity-funding variants: + /// IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound / + /// IdentityInvitation), AND /// - `statusRaw` is in `1...3` (Broadcast through ChainLocked), AND /// - no `(walletId, identityIndex)` slot is in `usedSlots`. /// @@ -296,14 +292,28 @@ struct IdentitiesContentView: View { /// the in-memory Pending Registrations list, letting the /// user race a duplicate Resume tap against the original. /// + /// Funding-type gate (`0...3` only) keeps non-identity asset locks + /// off this surface. `fundingTypeRaw == 4` (AssetLockAddressTopUp) + /// and `5` (AssetLockShieldedAddressTopUp) target the platform- + /// address / shielded-address flows; they carry a `walletId` but + /// their `identityIndexRaw` slot is unrelated to any identity, and + /// tapping Resume would route them into `CreateIdentityView`'s + /// `.unusedAssetLock` funding path where `resumeIdentityWithAssetLock` + /// would try to register an identity against a lock whose script + /// commits to an address-flow recipient — the Platform validation + /// would reject it, but only after the user has taken an action + /// they never intended. Address-flow locks are tracked in + /// `PendingPlatformFundFromAssetLocksList` (their proper surface). + /// /// The status floor (`>= 1`, Broadcast) is intentionally low: /// a lock at Broadcast (1) is in mid-handoff — SPV will /// deliver the InstantSendLock shortly and the persister will - /// flip it to (2), at which point the row's trailing affordance - /// flips from a spinner to a Resume button automatically - /// (SwiftData `@Query` is reactive). Hiding (1) entirely would - /// create a UX asymmetry where the just-broadcast lock vanishes - /// from the UI then reappears seconds later at (2). + /// flip it to (2). Hiding (1) entirely would create a UX + /// asymmetry where the just-broadcast lock vanishes from the UI + /// then reappears seconds later at (2). Since PR #4010 the + /// trailing affordance is a Resume button for every row — + /// tapping Broadcast re-enters the finality wait via + /// `resume_asset_lock`. /// /// `statusRaw == 0` (Built but never broadcast) is filtered /// out: a tight crash window between TX build and broadcast @@ -317,9 +327,9 @@ struct IdentitiesContentView: View { /// slot), but the local-only delete-identity action removes /// the identity row WITHOUT the asset-lock row, which frees the /// slot in the anti-join. Without this upper bound a Consumed - /// lock would re-surface as a perpetual-spinner row whose - /// "Resume" path can't advance — `resume_asset_lock` rejects - /// Consumed entries with "already Consumed — nothing to resume". + /// lock would re-surface as a Resume row that can't advance — + /// `resume_asset_lock` rejects Consumed entries with + /// "already Consumed — nothing to resume". /// /// Generic over `AssetLockResumeRow` so the pure filter is /// unit-testable without a SwiftData container. @@ -328,6 +338,7 @@ struct IdentitiesContentView: View { usedSlots: Set ) -> [R] { locks.filter { lock in + guard lock.fundingTypeRaw >= 0 && lock.fundingTypeRaw <= 3 else { return false } guard lock.statusRaw >= 1 && lock.statusRaw <= 3 else { return false } let slot = UInt32(bitPattern: lock.identityIndexRaw) return !usedSlots.contains( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index ce2ae408048..d8e843c8bc5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -37,12 +37,22 @@ import SwiftData /// can be unit-tested with lightweight structs instead of forcing /// tests to spin up a SwiftData `ModelContainer` just to construct /// `PersistentAssetLock` `@Model` instances. `PersistentAssetLock` -/// conforms automatically because it already exposes all three +/// conforms automatically because it already exposes all four /// properties on its public surface. +/// +/// `fundingTypeRaw` is the `AssetLockFundingType` discriminator +/// (0 IdentityRegistration, 1 IdentityTopUp, 2 IdentityTopUpNotBound, +/// 3 IdentityInvitation, 4 AssetLockAddressTopUp, +/// 5 AssetLockShieldedAddressTopUp). Only the identity-funding +/// variants (0...3) belong on the identity-registration Resume +/// surface — the address top-up variants target a platform-address +/// or shielded-address flow and have their own tracker in +/// `PendingPlatformFundFromAssetLocksList`. protocol AssetLockResumeRow { var walletId: Data { get } var statusRaw: Int { get } var identityIndexRaw: Int32 { get } + var fundingTypeRaw: Int { get } } extension PersistentAssetLock: AssetLockResumeRow {} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index d24354fb320..1657061eced 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -9,23 +9,32 @@ import SwiftDashSDK /// so without the SwiftData-backed cross-wallet filter the user /// would have no signal that an orphan asset lock is waiting. /// -/// The filter has four pieces of business logic that can silently +/// The filter has five pieces of business logic that can silently /// regress: /// -/// 1. `statusRaw >= 1` floor — Built (0) is rejected (tight -/// crash window with no useful UX action), Broadcast (1) and -/// every later status are accepted. The row's *trailing -/// affordance* — spinner vs. Resume button — is staged on -/// `statusRaw >= 2` separately inside `ResumableRegistrationRow`, -/// not at the filter level. -/// 2. Anti-join on `(walletId, identityIndex)` — a slot taken +/// 1. `fundingTypeRaw` in `0...3` — only identity-funding variants +/// (IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound / +/// IdentityInvitation) reach this surface. `4` (AssetLockAddressTopUp) +/// and `5` (AssetLockShieldedAddressTopUp) belong to the address / +/// shielded-address flows tracked by +/// `PendingPlatformFundFromAssetLocksList` — routing them into the +/// identity Resume path would submit an IdentityCreate against an +/// address-flow lock. +/// 2. `statusRaw` in `1...3` — Built (0) is rejected (tight +/// crash window with no useful UX action), Broadcast (1), +/// InstantSendLocked (2), and ChainLocked (3) are accepted. +/// Since PR #4010 every row now renders a Resume button; the +/// Broadcast row's Resume tap re-enters the finality wait via +/// `resume_asset_lock`. Consumed (4) is rejected — see +/// `testConsumedLocksAreHiddenFromResumableList`. +/// 3. Anti-join on `(walletId, identityIndex)` — a slot taken /// by a `PersistentIdentity` row removes its lock from the /// surface even if the lock's own status would otherwise /// qualify. This is what makes the filter "orphan-only". -/// 3. The anti-join is **per-wallet**: slot 0 used on wallet A +/// 4. The anti-join is **per-wallet**: slot 0 used on wallet A /// must not block slot 0 on wallet B. The `UsedSlot` value /// key (walletId + slot) carries that scoping. -/// 4. `identityIndexRaw` is stored as `Int32` but compared as +/// 5. `identityIndexRaw` is stored as `Int32` but compared as /// `UInt32` via `UInt32(bitPattern:)`. A future cast change /// (e.g. `UInt32(lockIdentityIndexRaw)` — which would trap on /// negative inputs) must fail loudly here, not in production. @@ -38,6 +47,10 @@ final class CreateIdentityResumableTests: XCTestCase { let walletId: Data let statusRaw: Int let identityIndexRaw: Int32 + /// Defaults to `0` (IdentityRegistration) so existing status / + /// anti-join / bridge tests stay focused on their invariant. + /// Funding-type coverage lives in its own MARK block below. + var fundingTypeRaw: Int = 0 } private let walletA = Data(repeating: 0xA1, count: 8) @@ -59,10 +72,13 @@ final class CreateIdentityResumableTests: XCTestCase { XCTAssertTrue(result.isEmpty) } - /// Broadcast (1) must surface even though it's not actionable — - /// the row renders a spinner ("Waiting for InstantSendLock…") - /// until SPV delivers the IS lock and the persister flips it - /// to (2). Hiding (1) would create the UX asymmetry where a + /// Broadcast (1) must surface. Since PR #4010 the trailing + /// affordance is a Resume button for every row — tapping it + /// on a Broadcast lock re-enters the finality wait via + /// `resume_asset_lock` (re-broadcast + await ChainLock), which + /// is what unstranded broadcast-but-interrupted locks (app + /// killed mid-wait, or an aged lock whose IS quorum has + /// rotated). Hiding (1) would create the UX asymmetry where a /// just-broadcast lock vanishes from the UI for ~10-30 seconds /// and then reappears at (2) — confusing rather than reassuring. func testAcceptsBroadcastForVisibility() { @@ -93,7 +109,7 @@ final class CreateIdentityResumableTests: XCTestCase { /// delete-identity action removes the identity row WITHOUT the /// asset-lock row, which frees the slot in the anti-join. Without /// the upper bound (`<= 3`) on the filter, a Consumed lock would - /// re-surface as a perpetual-spinner row that can't be advanced + /// re-surface as a Resume row that can't advance /// (`resume_asset_lock` rejects Consumed entries with /// "already Consumed — nothing to resume"). func testConsumedLocksAreHiddenFromResumableList() { @@ -105,6 +121,82 @@ final class CreateIdentityResumableTests: XCTestCase { XCTAssertEqual(result, []) } + // MARK: - funding-type filter + + /// Regression for the PR #4010 review finding: the resumable + /// surface's SwiftData-backed input includes every + /// `PersistentAssetLock` on the active network, and `walletId` / + /// `statusRaw` alone don't distinguish identity funding from + /// platform-address top-ups. Without a funding-type gate an + /// `AssetLockAddressTopUp` (`fundingTypeRaw == 4`) or + /// `AssetLockShieldedAddressTopUp` (`fundingTypeRaw == 5`) would + /// reach `CreateIdentityView(preselectedAssetLock:)`, at which + /// point tapping Resume would fire IdentityCreate against a lock + /// whose script commits to an address-flow recipient — bogus + /// registration attempt the user never intended. Address-flow + /// locks belong on `PendingPlatformFundFromAssetLocksList`. + func testFiltersOutAddressFundingLocks() { + let addressTopUp = FakeAssetLockRow( + walletId: walletA, + statusRaw: 2, + identityIndexRaw: 0, + fundingTypeRaw: 4 // AssetLockAddressTopUp + ) + let shieldedTopUp = FakeAssetLockRow( + walletId: walletA, + statusRaw: 2, + identityIndexRaw: 1, + fundingTypeRaw: 5 // AssetLockShieldedAddressTopUp + ) + let result = IdentitiesContentView.crossWalletResumableLocks( + in: [addressTopUp, shieldedTopUp], + usedSlots: [] + ) + XCTAssertTrue( + result.isEmpty, + "address / shielded-address top-up locks must not reach the identity-registration Resume surface" + ) + } + + /// Positive coverage: each of the four identity-funding + /// discriminators (0 IdentityRegistration, 1 IdentityTopUp, + /// 2 IdentityTopUpNotBound, 3 IdentityInvitation) is accepted. + /// If a future refactor narrows the filter to `== 0` only, + /// top-ups and invitations would silently disappear from the + /// orphan-recovery surface — this test catches that. + func testAcceptsAllIdentityFundingTypes() { + let locks: [FakeAssetLockRow] = (0...3).map { fundingType in + FakeAssetLockRow( + walletId: walletA, + statusRaw: 2, + identityIndexRaw: Int32(fundingType), + fundingTypeRaw: fundingType + ) + } + let result = IdentitiesContentView.crossWalletResumableLocks( + in: locks, + usedSlots: [] + ) + XCTAssertEqual(result, locks) + } + + /// Defensive: any unknown funding-type discriminator (negative or + /// >= 6) must be rejected. If a new address-flow variant lands on + /// the Rust side we don't want a Swift-side default of "accept + /// unknown" to route it into the identity Resume path. + func testRejectsUnknownFundingTypes() { + let locks = [ + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0, fundingTypeRaw: -1), + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 1, fundingTypeRaw: 6), + FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 2, fundingTypeRaw: 99), + ] + let result = IdentitiesContentView.crossWalletResumableLocks( + in: locks, + usedSlots: [] + ) + XCTAssertTrue(result.isEmpty) + } + // MARK: - anti-join func testFiltersOutLocksWhoseOwnWalletSlotIsAlreadyUsed() { From a81cae4f5f460d6e9b5bdb2eb8b98d4226c39057 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 5 Jul 2026 14:34:48 -0500 Subject: [PATCH 2/2] docs(swift-example-app): sync @Query docstring with the current resumable filter The `allAssetLocks` @Query comment predates the current `crossWalletResumableLocks` shape and still described the filter as "status >= InstantSendLocked". The floor is actually `>= 1` (Broadcast), and the filter now also excludes non-identity funding types (0...3 only) and in-flight controller slots. Bring the docstring in line so future readers don't work off the old contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/IdentitiesContentView.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index d603eb98754..b80c8a3cd24 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -20,12 +20,14 @@ struct IdentitiesContentView: View { private let network: Network @Query private var identities: [PersistentIdentity] /// All tracked asset locks across wallets. Filtered into - /// "resumable" rows (status >= `InstantSendLocked` AND no - /// `PersistentIdentity` at the same `(walletId, identityIndex)` - /// slot) by `resumableAssetLocks` so the orphan-lock-after-crash - /// case surfaces as a tappable Resume row. Sorted newest-first - /// by `updatedAt` so the most recent unfinished registration - /// sits at the top of the section. + /// "resumable" rows by `crossWalletResumableLocks` — identity- + /// funding types only (`fundingTypeRaw` in `0...3`), status in + /// `1...3` (Broadcast through ChainLocked), and no + /// `PersistentIdentity` / in-flight controller at the same + /// `(walletId, identityIndex)` slot — so the orphan-lock-after- + /// crash case surfaces as a tappable Resume row. Sorted newest- + /// first by `updatedAt` so the most recent unfinished + /// registration sits at the top of the section. @Query(sort: [SortDescriptor(\PersistentAssetLock.updatedAt, order: .reverse)]) private var allAssetLocks: [PersistentAssetLock] /// All wallets, used purely for the "wallet name" lookup on the