@@ -9,23 +9,32 @@ import SwiftDashSDK
99/// so without the SwiftData-backed cross-wallet filter the user
1010/// would have no signal that an orphan asset lock is waiting.
1111///
12- /// The filter has four pieces of business logic that can silently
12+ /// The filter has five pieces of business logic that can silently
1313/// regress:
1414///
15- /// 1. `statusRaw >= 1` floor — Built (0) is rejected (tight
16- /// crash window with no useful UX action), Broadcast (1) and
17- /// every later status are accepted. The row's *trailing
18- /// affordance* — spinner vs. Resume button — is staged on
19- /// `statusRaw >= 2` separately inside `ResumableRegistrationRow`,
20- /// not at the filter level.
21- /// 2. Anti-join on `(walletId, identityIndex)` — a slot taken
15+ /// 1. `fundingTypeRaw` in `0...3` — only identity-funding variants
16+ /// (IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound /
17+ /// IdentityInvitation) reach this surface. `4` (AssetLockAddressTopUp)
18+ /// and `5` (AssetLockShieldedAddressTopUp) belong to the address /
19+ /// shielded-address flows tracked by
20+ /// `PendingPlatformFundFromAssetLocksList` — routing them into the
21+ /// identity Resume path would submit an IdentityCreate against an
22+ /// address-flow lock.
23+ /// 2. `statusRaw` in `1...3` — Built (0) is rejected (tight
24+ /// crash window with no useful UX action), Broadcast (1),
25+ /// InstantSendLocked (2), and ChainLocked (3) are accepted.
26+ /// Since PR #4010 every row now renders a Resume button; the
27+ /// Broadcast row's Resume tap re-enters the finality wait via
28+ /// `resume_asset_lock`. Consumed (4) is rejected — see
29+ /// `testConsumedLocksAreHiddenFromResumableList`.
30+ /// 3. Anti-join on `(walletId, identityIndex)` — a slot taken
2231/// by a `PersistentIdentity` row removes its lock from the
2332/// surface even if the lock's own status would otherwise
2433/// qualify. This is what makes the filter "orphan-only".
25- /// 3 . The anti-join is **per-wallet**: slot 0 used on wallet A
34+ /// 4 . The anti-join is **per-wallet**: slot 0 used on wallet A
2635/// must not block slot 0 on wallet B. The `UsedSlot` value
2736/// key (walletId + slot) carries that scoping.
28- /// 4 . `identityIndexRaw` is stored as `Int32` but compared as
37+ /// 5 . `identityIndexRaw` is stored as `Int32` but compared as
2938/// `UInt32` via `UInt32(bitPattern:)`. A future cast change
3039/// (e.g. `UInt32(lockIdentityIndexRaw)` — which would trap on
3140/// negative inputs) must fail loudly here, not in production.
@@ -38,6 +47,10 @@ final class CreateIdentityResumableTests: XCTestCase {
3847 let walletId : Data
3948 let statusRaw : Int
4049 let identityIndexRaw : Int32
50+ /// Defaults to `0` (IdentityRegistration) so existing status /
51+ /// anti-join / bridge tests stay focused on their invariant.
52+ /// Funding-type coverage lives in its own MARK block below.
53+ var fundingTypeRaw : Int = 0
4154 }
4255
4356 private let walletA = Data ( repeating: 0xA1 , count: 8 )
@@ -59,10 +72,13 @@ final class CreateIdentityResumableTests: XCTestCase {
5972 XCTAssertTrue ( result. isEmpty)
6073 }
6174
62- /// Broadcast (1) must surface even though it's not actionable —
63- /// the row renders a spinner ("Waiting for InstantSendLock…")
64- /// until SPV delivers the IS lock and the persister flips it
65- /// to (2). Hiding (1) would create the UX asymmetry where a
75+ /// Broadcast (1) must surface. Since PR #4010 the trailing
76+ /// affordance is a Resume button for every row — tapping it
77+ /// on a Broadcast lock re-enters the finality wait via
78+ /// `resume_asset_lock` (re-broadcast + await ChainLock), which
79+ /// is what unstranded broadcast-but-interrupted locks (app
80+ /// killed mid-wait, or an aged lock whose IS quorum has
81+ /// rotated). Hiding (1) would create the UX asymmetry where a
6682 /// just-broadcast lock vanishes from the UI for ~10-30 seconds
6783 /// and then reappears at (2) — confusing rather than reassuring.
6884 func testAcceptsBroadcastForVisibility( ) {
@@ -93,7 +109,7 @@ final class CreateIdentityResumableTests: XCTestCase {
93109 /// delete-identity action removes the identity row WITHOUT the
94110 /// asset-lock row, which frees the slot in the anti-join. Without
95111 /// the upper bound (`<= 3`) on the filter, a Consumed lock would
96- /// re-surface as a perpetual-spinner row that can't be advanced
112+ /// re-surface as a Resume row that can't advance
97113 /// (`resume_asset_lock` rejects Consumed entries with
98114 /// "already Consumed — nothing to resume").
99115 func testConsumedLocksAreHiddenFromResumableList( ) {
@@ -105,6 +121,82 @@ final class CreateIdentityResumableTests: XCTestCase {
105121 XCTAssertEqual ( result, [ ] )
106122 }
107123
124+ // MARK: - funding-type filter
125+
126+ /// Regression for the PR #4010 review finding: the resumable
127+ /// surface's SwiftData-backed input includes every
128+ /// `PersistentAssetLock` on the active network, and `walletId` /
129+ /// `statusRaw` alone don't distinguish identity funding from
130+ /// platform-address top-ups. Without a funding-type gate an
131+ /// `AssetLockAddressTopUp` (`fundingTypeRaw == 4`) or
132+ /// `AssetLockShieldedAddressTopUp` (`fundingTypeRaw == 5`) would
133+ /// reach `CreateIdentityView(preselectedAssetLock:)`, at which
134+ /// point tapping Resume would fire IdentityCreate against a lock
135+ /// whose script commits to an address-flow recipient — bogus
136+ /// registration attempt the user never intended. Address-flow
137+ /// locks belong on `PendingPlatformFundFromAssetLocksList`.
138+ func testFiltersOutAddressFundingLocks( ) {
139+ let addressTopUp = FakeAssetLockRow (
140+ walletId: walletA,
141+ statusRaw: 2 ,
142+ identityIndexRaw: 0 ,
143+ fundingTypeRaw: 4 // AssetLockAddressTopUp
144+ )
145+ let shieldedTopUp = FakeAssetLockRow (
146+ walletId: walletA,
147+ statusRaw: 2 ,
148+ identityIndexRaw: 1 ,
149+ fundingTypeRaw: 5 // AssetLockShieldedAddressTopUp
150+ )
151+ let result = IdentitiesContentView . crossWalletResumableLocks (
152+ in: [ addressTopUp, shieldedTopUp] ,
153+ usedSlots: [ ]
154+ )
155+ XCTAssertTrue (
156+ result. isEmpty,
157+ " address / shielded-address top-up locks must not reach the identity-registration Resume surface "
158+ )
159+ }
160+
161+ /// Positive coverage: each of the four identity-funding
162+ /// discriminators (0 IdentityRegistration, 1 IdentityTopUp,
163+ /// 2 IdentityTopUpNotBound, 3 IdentityInvitation) is accepted.
164+ /// If a future refactor narrows the filter to `== 0` only,
165+ /// top-ups and invitations would silently disappear from the
166+ /// orphan-recovery surface — this test catches that.
167+ func testAcceptsAllIdentityFundingTypes( ) {
168+ let locks : [ FakeAssetLockRow ] = ( 0 ... 3 ) . map { fundingType in
169+ FakeAssetLockRow (
170+ walletId: walletA,
171+ statusRaw: 2 ,
172+ identityIndexRaw: Int32 ( fundingType) ,
173+ fundingTypeRaw: fundingType
174+ )
175+ }
176+ let result = IdentitiesContentView . crossWalletResumableLocks (
177+ in: locks,
178+ usedSlots: [ ]
179+ )
180+ XCTAssertEqual ( result, locks)
181+ }
182+
183+ /// Defensive: any unknown funding-type discriminator (negative or
184+ /// >= 6) must be rejected. If a new address-flow variant lands on
185+ /// the Rust side we don't want a Swift-side default of "accept
186+ /// unknown" to route it into the identity Resume path.
187+ func testRejectsUnknownFundingTypes( ) {
188+ let locks = [
189+ FakeAssetLockRow ( walletId: walletA, statusRaw: 2 , identityIndexRaw: 0 , fundingTypeRaw: - 1 ) ,
190+ FakeAssetLockRow ( walletId: walletA, statusRaw: 2 , identityIndexRaw: 1 , fundingTypeRaw: 6 ) ,
191+ FakeAssetLockRow ( walletId: walletA, statusRaw: 2 , identityIndexRaw: 2 , fundingTypeRaw: 99 ) ,
192+ ]
193+ let result = IdentitiesContentView . crossWalletResumableLocks (
194+ in: locks,
195+ usedSlots: [ ]
196+ )
197+ XCTAssertTrue ( result. isEmpty)
198+ }
199+
108200 // MARK: - anti-join
109201
110202 func testFiltersOutLocksWhoseOwnWalletSlotIsAlreadyUsed( ) {
0 commit comments