Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
///
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -434,16 +445,8 @@ struct IdentitiesContentView: View {
usedSlots: Set<UsedSlot>
) -> [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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}

Expand All @@ -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() {
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down
Loading