diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index eb621986d0..794331c1cf 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...2`), 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 @@ -342,23 +344,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 @@ -387,6 +382,8 @@ struct IdentitiesContentView: View { /// Pure anti-join across all wallets. A lock is *visible* on the /// Resumable Registrations surface iff + /// - `fundingTypeRaw` is in `0...2` (generic identity-resume variants: + /// IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound), AND /// - `statusRaw` is in `1...3` (Broadcast through ChainLocked), AND /// - no `(walletId, identityIndex)` slot is in `usedSlots`. /// @@ -402,14 +399,28 @@ struct IdentitiesContentView: View { /// the in-memory Pending Registrations list, letting the /// user race a duplicate Resume tap against the original. /// + /// The funding-type whitelist keeps every incompatible asset lock + /// off this surface. IdentityInvitation (3) is a bearer voucher whose + /// key is shared in the invitation link. Generic Resume must not + /// consume it into an unrelated local identity and invalidate the + /// invitee's claim; only the Invitations reclaim flow may authorize + /// that consumption with `consumeInvitationVoucher: true`, and the + /// Rust funding resolver enforces the same restriction. + /// AssetLockAddressTopUp (4) and + /// AssetLockShieldedAddressTopUp (5) target the platform-address and + /// shielded-address flows and are tracked by their own surfaces. + /// Explicitly whitelisting 0...2 also rejects negative and unknown + /// future discriminators fail-closed. + /// /// 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 @@ -423,9 +434,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. @@ -434,16 +445,8 @@ struct IdentitiesContentView: View { usedSlots: Set ) -> [R] { locks.filter { lock in + guard lock.fundingTypeRaw >= 0 && lock.fundingTypeRaw <= 2 else { return false } guard lock.statusRaw >= 1 && lock.statusRaw <= 3 else { return false } - // Invitation vouchers (fundingTypeRaw 3, IdentityInvitation) are - // bearer locks whose key is shared in the invitation link — they - // are reclaimed via the Invitations screen, never resumed as a - // local registration (which would consume the voucher into an - // unrelated identity and kill the invitee's claim). Their nominal - // identity slot is 0, so without this exclusion an unclaimed - // voucher surfaces here whenever slot 0 is free. The Rust funding - // resolver refuses them too; this keeps the row from rendering. - guard lock.fundingTypeRaw != 3 else { return false } let slot = UInt32(bitPattern: lock.identityIndexRaw) return !usedSlots.contains( UsedSlot(walletId: lock.walletId, slot: slot) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index d4e8807047..0a0950356e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -37,16 +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). Generic identity Resume accepts +/// only 0...2. IdentityInvitation (3) is a bearer voucher consumed +/// only by the invitation reclaim flow with +/// `consumeInvitationVoucher: true`; 4 and 5 belong to the platform- +/// address and shielded-address trackers. The filter rejects every +/// other discriminator fail-closed. protocol AssetLockResumeRow { var walletId: Data { get } var statusRaw: Int { get } var identityIndexRaw: Int32 { get } - /// Funding-type discriminant (mirrors the Rust `AssetLockFundingType`). - /// Carried through the row contract so the resumable-registrations - /// anti-join can exclude `IdentityInvitation` (3) vouchers — a shared - /// bearer lock that generic resume must never consume. var fundingTypeRaw: Int { get } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index 64e3e00d14..fffc94c2c4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -9,23 +9,30 @@ 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...2` — only generic identity-resume +/// variants (IdentityRegistration / IdentityTopUp / +/// IdentityTopUpNotBound) reach this surface. IdentityInvitation +/// (3) is a bearer voucher restricted to the invitation reclaim +/// flow; address and shielded-address top-ups (4 / 5) have their +/// own surfaces; every other discriminator is rejected fail-closed. +/// 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,9 +45,9 @@ final class CreateIdentityResumableTests: XCTestCase { let walletId: Data let statusRaw: Int let identityIndexRaw: Int32 - /// Defaults to 0 (IdentityRegistration) so the pre-existing cases - /// exercise the non-invitation path unchanged; the invitation - /// exclusion tests pass 3 (IdentityInvitation) explicitly. + /// 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 } @@ -63,10 +70,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 unstrands 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() { @@ -97,7 +107,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() { @@ -109,50 +119,45 @@ final class CreateIdentityResumableTests: XCTestCase { XCTAssertEqual(result, []) } - // MARK: - invitation-voucher exclusion - - /// An `IdentityInvitation` (fundingTypeRaw 3) lock is a shared bearer - /// voucher — it must NEVER surface as a resumable registration, even at - /// a fully actionable status with its nominal slot 0 unused (the exact - /// state of every unclaimed voucher). Resuming it would consume the - /// voucher into an unrelated local identity and kill the invitee's claim; - /// vouchers are recovered only via the Invitations screen's reclaim flow. - func testInvitationVouchersAreExcludedFromResumableList() { - for status in 1...3 { - let voucher = FakeAssetLockRow( - walletId: walletA, - statusRaw: status, - identityIndexRaw: 0, - fundingTypeRaw: 3 - ) - let result = IdentitiesContentView.crossWalletResumableLocks( - in: [voucher], - usedSlots: [] - ) - XCTAssertEqual( - result, [], - "unclaimed invitation voucher at status \(status) must not be resumable" - ) - } - } + // MARK: - funding-type filter + + /// The generic Resume surface is a strict whitelist. Invitation (3) + /// is tested at every otherwise-resumable status because it is a + /// shared bearer voucher: only the invitation reclaim flow may opt in + /// to consuming it. Address variants (4 / 5) and unknown values must + /// also fail closed rather than reaching IdentityCreate. + func testFundingTypeGateAcceptsOnlyGenericIdentityResumeTypes() { + let cases: [(fundingType: Int, statuses: [Int], shouldSurface: Bool)] = [ + (0, [2], true), + (1, [2], true), + (2, [2], true), + (3, Array(1...3), false), // IdentityInvitation + (4, [2], false), // AssetLockAddressTopUp + (5, [2], false), // AssetLockShieldedAddressTopUp + (-1, [2], false), + (6, [2], false), + (99, [2], false), + ] - /// The exclusion is invitation-specific: the other identity funding - /// types (0 registration / 1 top-up / 2 top-up-not-bound) keep - /// surfacing exactly as before. - func testNonInvitationFundingTypesStillSurface() { - let locks = (0...2).map { fundingType in - FakeAssetLockRow( - walletId: walletA, - statusRaw: 2, - identityIndexRaw: Int32(fundingType), // distinct free slots - fundingTypeRaw: fundingType - ) + for testCase in cases { + for status in testCase.statuses { + let lock = FakeAssetLockRow( + walletId: walletA, + statusRaw: status, + identityIndexRaw: 0, + fundingTypeRaw: testCase.fundingType + ) + let result = IdentitiesContentView.crossWalletResumableLocks( + in: [lock], + usedSlots: [] + ) + XCTAssertEqual( + result, + testCase.shouldSurface ? [lock] : [], + "fundingTypeRaw \(testCase.fundingType) at status \(status)" + ) + } } - let result = IdentitiesContentView.crossWalletResumableLocks( - in: locks, - usedSlots: [] - ) - XCTAssertEqual(result, locks) } // MARK: - anti-join