Skip to content

Commit 638826f

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...2` (IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound) in `crossWalletResumableLocks` before the status and anti-join gates. Invitation vouchers (3) remain reserved for the explicit reclaim flow, while address-flow locks (4 and 5) stay on their proper surfaces. 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` accepts funding types 0...2 and rejects invitation, address, shielded- address, negative, and unknown discriminators fail-closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f744652 commit 638826f

3 files changed

Lines changed: 111 additions & 99 deletions

File tree

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

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -342,23 +342,16 @@ struct IdentitiesContentView: View {
342342
/// SwiftData, but the "Pending Registrations" section above
343343
/// only reflects the in-memory coordinator state.
344344
///
345-
/// Each row's trailing affordance is staged on the lock's
346-
/// `statusRaw`:
347-
/// - `1` Broadcast: spinner + "Waiting for InstantSendLock or
348-
/// ChainLock…" — the lock can't fund a Platform identity
349-
/// until the masternodes sign either lock type. For
350-
/// freshly broadcast funding txs the IS lock typically
351-
/// arrives within seconds; for aged stuck locks (catch-up
352-
/// case) the IS quorum has rotated so only a ChainLock
353-
/// can resolve them. Either path lands as
354-
/// `statusRaw = 2` or `3`. SPV is running; the persister
355-
/// will flip the row when the event arrives, and
356-
/// SwiftData `@Query` re-renders the row into the
357-
/// actionable state without any extra wiring.
358-
/// - `2` / `3` InstantSendLocked / ChainLocked: Resume
359-
/// button. Tapping opens `CreateIdentityView` pre-configured
360-
/// for the `.unusedAssetLock` funding path with this lock
361-
/// pinned.
345+
/// Every row now renders a Resume button (see PR #4010): tapping
346+
/// opens `CreateIdentityView` pre-configured for the
347+
/// `.unusedAssetLock` funding path with this lock pinned.
348+
/// - `1` Broadcast: no proof yet — `resume_asset_lock` re-broadcasts
349+
/// the tx and re-enters the finality wait (indefinite ChainLock
350+
/// wait). Without a Resume path a broadcast-but-interrupted lock
351+
/// (app killed mid-wait, or an aged lock whose IS quorum has
352+
/// rotated) would sit forever with no in-app recovery.
353+
/// - `2` / `3` InstantSendLocked / ChainLocked: the lock is
354+
/// fund-ready; Resume submits IdentityCreate directly.
362355
///
363356
/// Empty when there are no orphan locks; collapses to nothing
364357
/// in that case so the rest of the screen isn't pushed down by
@@ -387,6 +380,8 @@ struct IdentitiesContentView: View {
387380

388381
/// Pure anti-join across all wallets. A lock is *visible* on the
389382
/// Resumable Registrations surface iff
383+
/// - `fundingTypeRaw` is in `0...2` (generic identity-resume variants:
384+
/// IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound), AND
390385
/// - `statusRaw` is in `1...3` (Broadcast through ChainLocked), AND
391386
/// - no `(walletId, identityIndex)` slot is in `usedSlots`.
392387
///
@@ -402,14 +397,28 @@ struct IdentitiesContentView: View {
402397
/// the in-memory Pending Registrations list, letting the
403398
/// user race a duplicate Resume tap against the original.
404399
///
400+
/// The funding-type whitelist keeps every incompatible asset lock
401+
/// off this surface. IdentityInvitation (3) is a bearer voucher whose
402+
/// key is shared in the invitation link. Generic Resume must not
403+
/// consume it into an unrelated local identity and invalidate the
404+
/// invitee's claim; only the Invitations reclaim flow may authorize
405+
/// that consumption with `consumeInvitationVoucher: true`, and the
406+
/// Rust funding resolver enforces the same restriction.
407+
/// AssetLockAddressTopUp (4) and
408+
/// AssetLockShieldedAddressTopUp (5) target the platform-address and
409+
/// shielded-address flows and are tracked by their own surfaces.
410+
/// Explicitly whitelisting 0...2 also rejects negative and unknown
411+
/// future discriminators fail-closed.
412+
///
405413
/// The status floor (`>= 1`, Broadcast) is intentionally low:
406414
/// a lock at Broadcast (1) is in mid-handoff — SPV will
407415
/// deliver the InstantSendLock shortly and the persister will
408-
/// flip it to (2), at which point the row's trailing affordance
409-
/// flips from a spinner to a Resume button automatically
410-
/// (SwiftData `@Query` is reactive). Hiding (1) entirely would
411-
/// create a UX asymmetry where the just-broadcast lock vanishes
412-
/// from the UI then reappears seconds later at (2).
416+
/// flip it to (2). Hiding (1) entirely would create a UX
417+
/// asymmetry where the just-broadcast lock vanishes from the UI
418+
/// then reappears seconds later at (2). Since PR #4010 the
419+
/// trailing affordance is a Resume button for every row —
420+
/// tapping Broadcast re-enters the finality wait via
421+
/// `resume_asset_lock`.
413422
///
414423
/// `statusRaw == 0` (Built but never broadcast) is filtered
415424
/// out: a tight crash window between TX build and broadcast
@@ -423,9 +432,9 @@ struct IdentitiesContentView: View {
423432
/// slot), but the local-only delete-identity action removes
424433
/// the identity row WITHOUT the asset-lock row, which frees the
425434
/// slot in the anti-join. Without this upper bound a Consumed
426-
/// lock would re-surface as a perpetual-spinner row whose
427-
/// "Resume" path can't advance — `resume_asset_lock` rejects
428-
/// Consumed entries with "already Consumed — nothing to resume".
435+
/// lock would re-surface as a Resume row that can't advance —
436+
/// `resume_asset_lock` rejects Consumed entries with
437+
/// "already Consumed — nothing to resume".
429438
///
430439
/// Generic over `AssetLockResumeRow` so the pure filter is
431440
/// unit-testable without a SwiftData container.
@@ -434,16 +443,8 @@ struct IdentitiesContentView: View {
434443
usedSlots: Set<UsedSlot>
435444
) -> [R] {
436445
locks.filter { lock in
446+
guard lock.fundingTypeRaw >= 0 && lock.fundingTypeRaw <= 2 else { return false }
437447
guard lock.statusRaw >= 1 && lock.statusRaw <= 3 else { return false }
438-
// Invitation vouchers (fundingTypeRaw 3, IdentityInvitation) are
439-
// bearer locks whose key is shared in the invitation link — they
440-
// are reclaimed via the Invitations screen, never resumed as a
441-
// local registration (which would consume the voucher into an
442-
// unrelated identity and kill the invitee's claim). Their nominal
443-
// identity slot is 0, so without this exclusion an unclaimed
444-
// voucher surfaces here whenever slot 0 is free. The Rust funding
445-
// resolver refuses them too; this keeps the row from rendering.
446-
guard lock.fundingTypeRaw != 3 else { return false }
447448
let slot = UInt32(bitPattern: lock.identityIndexRaw)
448449
return !usedSlots.contains(
449450
UsedSlot(walletId: lock.walletId, slot: slot)

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +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). Generic identity Resume accepts
47+
/// only 0...2. IdentityInvitation (3) is a bearer voucher consumed
48+
/// only by the invitation reclaim flow with
49+
/// `consumeInvitationVoucher: true`; 4 and 5 belong to the platform-
50+
/// address and shielded-address trackers. The filter rejects every
51+
/// other discriminator fail-closed.
4252
protocol AssetLockResumeRow {
4353
var walletId: Data { get }
4454
var statusRaw: Int { get }
4555
var identityIndexRaw: Int32 { get }
46-
/// Funding-type discriminant (mirrors the Rust `AssetLockFundingType`).
47-
/// Carried through the row contract so the resumable-registrations
48-
/// anti-join can exclude `IdentityInvitation` (3) vouchers — a shared
49-
/// bearer lock that generic resume must never consume.
5056
var fundingTypeRaw: Int { get }
5157
}
5258

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

Lines changed: 65 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,30 @@ 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...2` — only generic identity-resume
16+
/// variants (IdentityRegistration / IdentityTopUp /
17+
/// IdentityTopUpNotBound) reach this surface. IdentityInvitation
18+
/// (3) is a bearer voucher restricted to the invitation reclaim
19+
/// flow; address and shielded-address top-ups (4 / 5) have their
20+
/// own surfaces; every other discriminator is rejected fail-closed.
21+
/// 2. `statusRaw` in `1...3` — Built (0) is rejected (tight
22+
/// crash window with no useful UX action), Broadcast (1),
23+
/// InstantSendLocked (2), and ChainLocked (3) are accepted.
24+
/// Since PR #4010 every row now renders a Resume button; the
25+
/// Broadcast row's Resume tap re-enters the finality wait via
26+
/// `resume_asset_lock`. Consumed (4) is rejected — see
27+
/// `testConsumedLocksAreHiddenFromResumableList`.
28+
/// 3. Anti-join on `(walletId, identityIndex)` — a slot taken
2229
/// by a `PersistentIdentity` row removes its lock from the
2330
/// surface even if the lock's own status would otherwise
2431
/// qualify. This is what makes the filter "orphan-only".
25-
/// 3. The anti-join is **per-wallet**: slot 0 used on wallet A
32+
/// 4. The anti-join is **per-wallet**: slot 0 used on wallet A
2633
/// must not block slot 0 on wallet B. The `UsedSlot` value
2734
/// key (walletId + slot) carries that scoping.
28-
/// 4. `identityIndexRaw` is stored as `Int32` but compared as
35+
/// 5. `identityIndexRaw` is stored as `Int32` but compared as
2936
/// `UInt32` via `UInt32(bitPattern:)`. A future cast change
3037
/// (e.g. `UInt32(lockIdentityIndexRaw)` — which would trap on
3138
/// negative inputs) must fail loudly here, not in production.
@@ -38,9 +45,9 @@ final class CreateIdentityResumableTests: XCTestCase {
3845
let walletId: Data
3946
let statusRaw: Int
4047
let identityIndexRaw: Int32
41-
/// Defaults to 0 (IdentityRegistration) so the pre-existing cases
42-
/// exercise the non-invitation path unchanged; the invitation
43-
/// exclusion tests pass 3 (IdentityInvitation) explicitly.
48+
/// Defaults to `0` (IdentityRegistration) so existing status /
49+
/// anti-join / bridge tests stay focused on their invariant.
50+
/// Funding-type coverage lives in its own MARK block below.
4451
var fundingTypeRaw: Int = 0
4552
}
4653

@@ -63,10 +70,13 @@ final class CreateIdentityResumableTests: XCTestCase {
6370
XCTAssertTrue(result.isEmpty)
6471
}
6572

66-
/// Broadcast (1) must surface even though it's not actionable —
67-
/// the row renders a spinner ("Waiting for InstantSendLock…")
68-
/// until SPV delivers the IS lock and the persister flips it
69-
/// to (2). Hiding (1) would create the UX asymmetry where a
73+
/// Broadcast (1) must surface. Since PR #4010 the trailing
74+
/// affordance is a Resume button for every row — tapping it
75+
/// on a Broadcast lock re-enters the finality wait via
76+
/// `resume_asset_lock` (re-broadcast + await ChainLock), which
77+
/// is what unstrands broadcast-but-interrupted locks (app
78+
/// killed mid-wait, or an aged lock whose IS quorum has
79+
/// rotated). Hiding (1) would create the UX asymmetry where a
7080
/// just-broadcast lock vanishes from the UI for ~10-30 seconds
7181
/// and then reappears at (2) — confusing rather than reassuring.
7282
func testAcceptsBroadcastForVisibility() {
@@ -97,7 +107,7 @@ final class CreateIdentityResumableTests: XCTestCase {
97107
/// delete-identity action removes the identity row WITHOUT the
98108
/// asset-lock row, which frees the slot in the anti-join. Without
99109
/// the upper bound (`<= 3`) on the filter, a Consumed lock would
100-
/// re-surface as a perpetual-spinner row that can't be advanced
110+
/// re-surface as a Resume row that can't advance
101111
/// (`resume_asset_lock` rejects Consumed entries with
102112
/// "already Consumed — nothing to resume").
103113
func testConsumedLocksAreHiddenFromResumableList() {
@@ -109,50 +119,45 @@ final class CreateIdentityResumableTests: XCTestCase {
109119
XCTAssertEqual(result, [])
110120
}
111121

112-
// MARK: - invitation-voucher exclusion
113-
114-
/// An `IdentityInvitation` (fundingTypeRaw 3) lock is a shared bearer
115-
/// voucher — it must NEVER surface as a resumable registration, even at
116-
/// a fully actionable status with its nominal slot 0 unused (the exact
117-
/// state of every unclaimed voucher). Resuming it would consume the
118-
/// voucher into an unrelated local identity and kill the invitee's claim;
119-
/// vouchers are recovered only via the Invitations screen's reclaim flow.
120-
func testInvitationVouchersAreExcludedFromResumableList() {
121-
for status in 1...3 {
122-
let voucher = FakeAssetLockRow(
123-
walletId: walletA,
124-
statusRaw: status,
125-
identityIndexRaw: 0,
126-
fundingTypeRaw: 3
127-
)
128-
let result = IdentitiesContentView.crossWalletResumableLocks(
129-
in: [voucher],
130-
usedSlots: []
131-
)
132-
XCTAssertEqual(
133-
result, [],
134-
"unclaimed invitation voucher at status \(status) must not be resumable"
135-
)
136-
}
137-
}
122+
// MARK: - funding-type filter
123+
124+
/// The generic Resume surface is a strict whitelist. Invitation (3)
125+
/// is tested at every otherwise-resumable status because it is a
126+
/// shared bearer voucher: only the invitation reclaim flow may opt in
127+
/// to consuming it. Address variants (4 / 5) and unknown values must
128+
/// also fail closed rather than reaching IdentityCreate.
129+
func testFundingTypeGateAcceptsOnlyGenericIdentityResumeTypes() {
130+
let cases: [(fundingType: Int, statuses: [Int], shouldSurface: Bool)] = [
131+
(0, [2], true),
132+
(1, [2], true),
133+
(2, [2], true),
134+
(3, Array(1...3), false), // IdentityInvitation
135+
(4, [2], false), // AssetLockAddressTopUp
136+
(5, [2], false), // AssetLockShieldedAddressTopUp
137+
(-1, [2], false),
138+
(6, [2], false),
139+
(99, [2], false),
140+
]
138141

139-
/// The exclusion is invitation-specific: the other identity funding
140-
/// types (0 registration / 1 top-up / 2 top-up-not-bound) keep
141-
/// surfacing exactly as before.
142-
func testNonInvitationFundingTypesStillSurface() {
143-
let locks = (0...2).map { fundingType in
144-
FakeAssetLockRow(
145-
walletId: walletA,
146-
statusRaw: 2,
147-
identityIndexRaw: Int32(fundingType), // distinct free slots
148-
fundingTypeRaw: fundingType
149-
)
142+
for testCase in cases {
143+
for status in testCase.statuses {
144+
let lock = FakeAssetLockRow(
145+
walletId: walletA,
146+
statusRaw: status,
147+
identityIndexRaw: 0,
148+
fundingTypeRaw: testCase.fundingType
149+
)
150+
let result = IdentitiesContentView.crossWalletResumableLocks(
151+
in: [lock],
152+
usedSlots: []
153+
)
154+
XCTAssertEqual(
155+
result,
156+
testCase.shouldSurface ? [lock] : [],
157+
"fundingTypeRaw \(testCase.fundingType) at status \(status)"
158+
)
159+
}
150160
}
151-
let result = IdentitiesContentView.crossWalletResumableLocks(
152-
in: locks,
153-
usedSlots: []
154-
)
155-
XCTAssertEqual(result, locks)
156161
}
157162

158163
// MARK: - anti-join

0 commit comments

Comments
 (0)