Skip to content

Commit 739efab

Browse files
thepastaclawclaude
andcommitted
fix(swift-example-app): gate Resumable Registrations to identity funding types
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) <noreply@anthropic.com>
1 parent 0877460 commit 739efab

3 files changed

Lines changed: 154 additions & 41 deletions

File tree

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -236,23 +236,16 @@ struct IdentitiesContentView: View {
236236
/// SwiftData, but the "Pending Registrations" section above
237237
/// only reflects the in-memory coordinator state.
238238
///
239-
/// Each row's trailing affordance is staged on the lock's
240-
/// `statusRaw`:
241-
/// - `1` Broadcast: spinner + "Waiting for InstantSendLock or
242-
/// ChainLock…" — the lock can't fund a Platform identity
243-
/// until the masternodes sign either lock type. For
244-
/// freshly broadcast funding txs the IS lock typically
245-
/// arrives within seconds; for aged stuck locks (catch-up
246-
/// case) the IS quorum has rotated so only a ChainLock
247-
/// can resolve them. Either path lands as
248-
/// `statusRaw = 2` or `3`. SPV is running; the persister
249-
/// will flip the row when the event arrives, and
250-
/// SwiftData `@Query` re-renders the row into the
251-
/// actionable state without any extra wiring.
252-
/// - `2` / `3` InstantSendLocked / ChainLocked: Resume
253-
/// button. Tapping opens `CreateIdentityView` pre-configured
254-
/// for the `.unusedAssetLock` funding path with this lock
255-
/// pinned.
239+
/// Every row now renders a Resume button (see PR #4010): tapping
240+
/// opens `CreateIdentityView` pre-configured for the
241+
/// `.unusedAssetLock` funding path with this lock pinned.
242+
/// - `1` Broadcast: no proof yet — `resume_asset_lock` re-broadcasts
243+
/// the tx and re-enters the finality wait (indefinite ChainLock
244+
/// wait). Without a Resume path a broadcast-but-interrupted lock
245+
/// (app killed mid-wait, or an aged lock whose IS quorum has
246+
/// rotated) would sit forever with no in-app recovery.
247+
/// - `2` / `3` InstantSendLocked / ChainLocked: the lock is
248+
/// fund-ready; Resume submits IdentityCreate directly.
256249
///
257250
/// Empty when there are no orphan locks; collapses to nothing
258251
/// in that case so the rest of the screen isn't pushed down by
@@ -281,6 +274,9 @@ struct IdentitiesContentView: View {
281274

282275
/// Pure anti-join across all wallets. A lock is *visible* on the
283276
/// Resumable Registrations surface iff
277+
/// - `fundingTypeRaw` is in `0...3` (identity-funding variants:
278+
/// IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound /
279+
/// IdentityInvitation), AND
284280
/// - `statusRaw` is in `1...3` (Broadcast through ChainLocked), AND
285281
/// - no `(walletId, identityIndex)` slot is in `usedSlots`.
286282
///
@@ -296,14 +292,28 @@ struct IdentitiesContentView: View {
296292
/// the in-memory Pending Registrations list, letting the
297293
/// user race a duplicate Resume tap against the original.
298294
///
295+
/// Funding-type gate (`0...3` only) keeps non-identity asset locks
296+
/// off this surface. `fundingTypeRaw == 4` (AssetLockAddressTopUp)
297+
/// and `5` (AssetLockShieldedAddressTopUp) target the platform-
298+
/// address / shielded-address flows; they carry a `walletId` but
299+
/// their `identityIndexRaw` slot is unrelated to any identity, and
300+
/// tapping Resume would route them into `CreateIdentityView`'s
301+
/// `.unusedAssetLock` funding path where `resumeIdentityWithAssetLock`
302+
/// would try to register an identity against a lock whose script
303+
/// commits to an address-flow recipient — the Platform validation
304+
/// would reject it, but only after the user has taken an action
305+
/// they never intended. Address-flow locks are tracked in
306+
/// `PendingPlatformFundFromAssetLocksList` (their proper surface).
307+
///
299308
/// The status floor (`>= 1`, Broadcast) is intentionally low:
300309
/// a lock at Broadcast (1) is in mid-handoff — SPV will
301310
/// deliver the InstantSendLock shortly and the persister will
302-
/// flip it to (2), at which point the row's trailing affordance
303-
/// flips from a spinner to a Resume button automatically
304-
/// (SwiftData `@Query` is reactive). Hiding (1) entirely would
305-
/// create a UX asymmetry where the just-broadcast lock vanishes
306-
/// from the UI then reappears seconds later at (2).
311+
/// flip it to (2). Hiding (1) entirely would create a UX
312+
/// asymmetry where the just-broadcast lock vanishes from the UI
313+
/// then reappears seconds later at (2). Since PR #4010 the
314+
/// trailing affordance is a Resume button for every row —
315+
/// tapping Broadcast re-enters the finality wait via
316+
/// `resume_asset_lock`.
307317
///
308318
/// `statusRaw == 0` (Built but never broadcast) is filtered
309319
/// out: a tight crash window between TX build and broadcast
@@ -317,9 +327,9 @@ struct IdentitiesContentView: View {
317327
/// slot), but the local-only delete-identity action removes
318328
/// the identity row WITHOUT the asset-lock row, which frees the
319329
/// slot in the anti-join. Without this upper bound a Consumed
320-
/// lock would re-surface as a perpetual-spinner row whose
321-
/// "Resume" path can't advance — `resume_asset_lock` rejects
322-
/// Consumed entries with "already Consumed — nothing to resume".
330+
/// lock would re-surface as a Resume row that can't advance —
331+
/// `resume_asset_lock` rejects Consumed entries with
332+
/// "already Consumed — nothing to resume".
323333
///
324334
/// Generic over `AssetLockResumeRow` so the pure filter is
325335
/// unit-testable without a SwiftData container.
@@ -328,6 +338,7 @@ struct IdentitiesContentView: View {
328338
usedSlots: Set<UsedSlot>
329339
) -> [R] {
330340
locks.filter { lock in
341+
guard lock.fundingTypeRaw >= 0 && lock.fundingTypeRaw <= 3 else { return false }
331342
guard lock.statusRaw >= 1 && lock.statusRaw <= 3 else { return false }
332343
let slot = UInt32(bitPattern: lock.identityIndexRaw)
333344
return !usedSlots.contains(

packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,22 @@ import SwiftData
3737
/// can be unit-tested with lightweight structs instead of forcing
3838
/// tests to spin up a SwiftData `ModelContainer` just to construct
3939
/// `PersistentAssetLock` `@Model` instances. `PersistentAssetLock`
40-
/// conforms automatically because it already exposes all three
40+
/// conforms automatically because it already exposes all four
4141
/// properties on its public surface.
42+
///
43+
/// `fundingTypeRaw` is the `AssetLockFundingType` discriminator
44+
/// (0 IdentityRegistration, 1 IdentityTopUp, 2 IdentityTopUpNotBound,
45+
/// 3 IdentityInvitation, 4 AssetLockAddressTopUp,
46+
/// 5 AssetLockShieldedAddressTopUp). Only the identity-funding
47+
/// variants (0...3) belong on the identity-registration Resume
48+
/// surface — the address top-up variants target a platform-address
49+
/// or shielded-address flow and have their own tracker in
50+
/// `PendingPlatformFundFromAssetLocksList`.
4251
protocol AssetLockResumeRow {
4352
var walletId: Data { get }
4453
var statusRaw: Int { get }
4554
var identityIndexRaw: Int32 { get }
55+
var fundingTypeRaw: Int { get }
4656
}
4757

4858
extension PersistentAssetLock: AssetLockResumeRow {}

packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)