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...3`), 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 @@ -236,23 +238,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 @@ -281,6 +276,9 @@ struct IdentitiesContentView: View {

/// Pure anti-join across all wallets. A lock is *visible* on the
/// Resumable Registrations surface iff
/// - `fundingTypeRaw` is in `0...3` (identity-funding variants:
/// IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound /
/// IdentityInvitation), AND
/// - `statusRaw` is in `1...3` (Broadcast through ChainLocked), AND
/// - no `(walletId, identityIndex)` slot is in `usedSlots`.
///
Expand All @@ -296,14 +294,28 @@ struct IdentitiesContentView: View {
/// the in-memory Pending Registrations list, letting the
/// user race a duplicate Resume tap against the original.
///
/// Funding-type gate (`0...3` only) keeps non-identity asset locks
/// off this surface. `fundingTypeRaw == 4` (AssetLockAddressTopUp)
/// and `5` (AssetLockShieldedAddressTopUp) target the platform-
/// address / shielded-address flows; they carry a `walletId` but
/// their `identityIndexRaw` slot is unrelated to any identity, and
/// tapping Resume would route them into `CreateIdentityView`'s
/// `.unusedAssetLock` funding path where `resumeIdentityWithAssetLock`
/// would try to register an identity against a lock whose script
/// commits to an address-flow recipient — the Platform validation
/// would reject it, but only after the user has taken an action
/// they never intended. Address-flow locks are tracked in
/// `PendingPlatformFundFromAssetLocksList` (their proper surface).
///
/// 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 @@ -317,9 +329,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 @@ -328,6 +340,7 @@ struct IdentitiesContentView: View {
usedSlots: Set<UsedSlot>
) -> [R] {
locks.filter { lock in
guard lock.fundingTypeRaw >= 0 && lock.fundingTypeRaw <= 3 else { return false }
guard lock.statusRaw >= 1 && lock.statusRaw <= 3 else { return false }
let slot = UInt32(bitPattern: lock.identityIndexRaw)
return !usedSlots.contains(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +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). Only the identity-funding
/// variants (0...3) belong on the identity-registration Resume
/// surface — the address top-up variants target a platform-address
/// or shielded-address flow and have their own tracker in
/// `PendingPlatformFundFromAssetLocksList`.
protocol AssetLockResumeRow {
var walletId: Data { get }
var statusRaw: Int { get }
var identityIndexRaw: Int32 { get }
var fundingTypeRaw: Int { get }
}

extension PersistentAssetLock: AssetLockResumeRow {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,32 @@ 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...3` — only identity-funding variants
/// (IdentityRegistration / IdentityTopUp / IdentityTopUpNotBound /
/// IdentityInvitation) reach this surface. `4` (AssetLockAddressTopUp)
/// and `5` (AssetLockShieldedAddressTopUp) belong to the address /
/// shielded-address flows tracked by
/// `PendingPlatformFundFromAssetLocksList` — routing them into the
/// identity Resume path would submit an IdentityCreate against an
/// address-flow lock.
/// 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,6 +47,10 @@ final class CreateIdentityResumableTests: XCTestCase {
let walletId: Data
let statusRaw: Int
let identityIndexRaw: Int32
/// 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
}

private let walletA = Data(repeating: 0xA1, count: 8)
Expand All @@ -59,10 +72,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 unstranded 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 @@ -93,7 +109,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 @@ -105,6 +121,82 @@ final class CreateIdentityResumableTests: XCTestCase {
XCTAssertEqual(result, [])
}

// MARK: - funding-type filter

/// Regression for the PR #4010 review finding: the resumable
/// surface's SwiftData-backed input includes every
/// `PersistentAssetLock` on the active network, and `walletId` /
/// `statusRaw` alone don't distinguish identity funding from
/// platform-address top-ups. Without a funding-type gate an
/// `AssetLockAddressTopUp` (`fundingTypeRaw == 4`) or
/// `AssetLockShieldedAddressTopUp` (`fundingTypeRaw == 5`) would
/// reach `CreateIdentityView(preselectedAssetLock:)`, at which
/// point tapping Resume would fire IdentityCreate against a lock
/// whose script commits to an address-flow recipient — bogus
/// registration attempt the user never intended. Address-flow
/// locks belong on `PendingPlatformFundFromAssetLocksList`.
func testFiltersOutAddressFundingLocks() {
let addressTopUp = FakeAssetLockRow(
walletId: walletA,
statusRaw: 2,
identityIndexRaw: 0,
fundingTypeRaw: 4 // AssetLockAddressTopUp
)
let shieldedTopUp = FakeAssetLockRow(
walletId: walletA,
statusRaw: 2,
identityIndexRaw: 1,
fundingTypeRaw: 5 // AssetLockShieldedAddressTopUp
)
let result = IdentitiesContentView.crossWalletResumableLocks(
in: [addressTopUp, shieldedTopUp],
usedSlots: []
)
XCTAssertTrue(
result.isEmpty,
"address / shielded-address top-up locks must not reach the identity-registration Resume surface"
)
}

/// Positive coverage: each of the four identity-funding
/// discriminators (0 IdentityRegistration, 1 IdentityTopUp,
/// 2 IdentityTopUpNotBound, 3 IdentityInvitation) is accepted.
/// If a future refactor narrows the filter to `== 0` only,
/// top-ups and invitations would silently disappear from the
/// orphan-recovery surface — this test catches that.
func testAcceptsAllIdentityFundingTypes() {
let locks: [FakeAssetLockRow] = (0...3).map { fundingType in
FakeAssetLockRow(
walletId: walletA,
statusRaw: 2,
identityIndexRaw: Int32(fundingType),
fundingTypeRaw: fundingType
)
}
let result = IdentitiesContentView.crossWalletResumableLocks(
in: locks,
usedSlots: []
)
XCTAssertEqual(result, locks)
}

/// Defensive: any unknown funding-type discriminator (negative or
/// >= 6) must be rejected. If a new address-flow variant lands on
/// the Rust side we don't want a Swift-side default of "accept
/// unknown" to route it into the identity Resume path.
func testRejectsUnknownFundingTypes() {
let locks = [
FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 0, fundingTypeRaw: -1),
FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 1, fundingTypeRaw: 6),
FakeAssetLockRow(walletId: walletA, statusRaw: 2, identityIndexRaw: 2, fundingTypeRaw: 99),
]
let result = IdentitiesContentView.crossWalletResumableLocks(
in: locks,
usedSlots: []
)
XCTAssertTrue(result.isEmpty)
}

// MARK: - anti-join

func testFiltersOutLocksWhoseOwnWalletSlotIsAlreadyUsed() {
Expand Down
Loading