Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
465 changes: 465 additions & 0 deletions docs/PERSISTENCE_CONSUMER_BEHAVIOR_CHANGES.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,10 @@ class ShieldedService: ObservableObject {
/// `PersistentShieldedSyncState` rows would silently survive
/// (the symptom the user reported when "Clear" left a row
/// behind for a non-active wallet).
func clearLocalState(modelContext: ModelContext) async {
func clearLocalState(
modelContext: ModelContext,
resetRustStateForTesting: (() throws -> Void)? = nil
) async {
// Capture the manager before the soft-cleanup below
// touches anything, so we can stop the background loop
// first. (We used to capture `network` here too for the
Expand Down Expand Up @@ -713,8 +716,9 @@ class ShieldedService: ObservableObject {
// The single SQLite commitment-tree file stays open;
// the next `bindShielded` call repopulates the
// registries and the next sync re-saves notes via
// the changeset path. Best-effort — failure logs but
// doesn't abort the wipe.
// the changeset path. This reset is load-bearing: if it
// cannot run, abort the host-row wipe so the tree file and
// SwiftData rows cannot diverge.
//
// Re-binding scope after Clear: `clearShielded` drops
// EVERY wallet (not just the mirror's `firstWallet`)
Expand Down Expand Up @@ -743,14 +747,21 @@ class ShieldedService: ObservableObject {
// every wallet on any wallet-set change or network switch. We
// keep the WIPE scope global on purpose (see the class-level
// doc below) — this note is about the re-BIND scope.
if let managerForStop {
do {
do {
if let resetRustStateForTesting {
try resetRustStateForTesting()
} else {
guard let managerForStop else {
lastError = "Failed to reset shielded state: no wallet manager is bound."
SDKLogger.error(lastError ?? "")
return
}
try managerForStop.clearShielded()
} catch {
SDKLogger.error(
"ShieldedService.clearLocalState: clearShielded failed: \(error.localizedDescription)"
)
}
} catch {
lastError = "Failed to reset shielded state: \(error.localizedDescription)"
SDKLogger.error(lastError ?? "")
return
}

// 2) Delete every shielded SwiftData row across all
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -752,15 +752,11 @@ var body: some View {
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.mini)
// Gated on `isSyncing` to close the
// double-tap window where the user could
// hit Clear *while* a sync is in flight.
// `clearLocalState` calls
// `stopShieldedSync()` first, but stop is
// best-effort and the persister callback
// can still drain rows into SwiftData
// between our delete and the loop
// actually quiescing.
// Gated on `isSyncing` to avoid starting a
// redundant Clear while a pass is active.
// `clearLocalState` asks Rust to quiesce and
// reset first, then wipes SwiftData only if
// that reset succeeds.
.disabled(shieldedService.isSyncing)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@ import SwiftUI
/// (the invitee claimed it), the reclaim is rejected deterministically and the
/// row flips to Claimed with a neutral message instead.
///
/// Reclaimed is asserted ONLY on a successful consume observed by this attempt.
/// The persisted `reclaimInFlight` marker proves just that a local attempt saved
/// it before starting a consume — it is not tied to a submitted transition or
/// target, so it can never upgrade an "already consumed" rejection to Reclaimed
/// (the invitee may have claimed between the crash and the retry). Marker-set
/// failures therefore resolve to a conservative terminal Claimed with an
/// explicitly ambiguous message, or to an explanatory error — never Reclaimed.
/// Reclaimed is asserted on a successful consume observed by this attempt, or
/// when the wallet returns its typed `assetLockAlreadyConsumed` error from a
/// retained local tombstone. That tombstone is written only after this wallet
/// successfully consumed the lock, so it is stronger evidence than the
/// persisted `reclaimInFlight` marker. A consensus-message fallback has no such
/// attribution and remains Claimed or explicitly ambiguous.
struct ReclaimInvitationSheet: View {
let invitation: PersistentInvitation
let walletId: Data
Expand Down Expand Up @@ -274,6 +273,18 @@ struct ReclaimInvitationSheet: View {
error: error,
hadPriorReclaimInFlight: hadPriorReclaimInFlight
) {
case .reclaimed:
// The wallet retained a local consumed tombstone. It is
// written only after this wallet's consume succeeds, so a
// retry can safely recover the local row as Reclaimed.
invitation.statusRaw = 2
invitation.reclaimInFlight = false
invitation.updatedAt = Date()
try? modelContext.save()
infoMessage =
"This invitation was already reclaimed by this wallet. "
+ "The credits were delivered to the target selected "
+ "for that reclaim."
case .claimed:
// Someone else claimed the voucher first. Reflect the terminal
// state with a neutral message (the claimant is intentionally
Expand Down Expand Up @@ -360,13 +371,14 @@ struct ReclaimInvitationSheet: View {

/// The terminal state a reclaim attempt resolves to.
///
/// There is deliberately NO `.reclaimed` recovery outcome: the persisted
/// `reclaimInFlight` marker proves only that a local attempt saved it
/// before starting a consume. It is not tied to a submitted transition or
/// target, so it cannot attribute an "already consumed" rejection — the
/// invitee may have claimed the voucher between our crash and the retry.
/// Reclaimed is asserted only by the success path's own observed consume.
/// A typed local consumed-tombstone result can recover `.reclaimed`. The
/// persisted `reclaimInFlight` marker alone cannot: it is not tied to a
/// submitted transition or target, so consensus-message fallback remains
/// conservatively Claimed or ambiguous.
enum ReclaimOutcome: Equatable {
/// This wallet retained a tombstone written after its successful local
/// consume, so the interrupted reclaim can be recovered definitively.
case reclaimed
/// The voucher was consumed and no local attempt was in flight — a
/// foreign claim, unambiguously.
case claimed
Expand All @@ -383,18 +395,24 @@ struct ReclaimInvitationSheet: View {
case error
}

/// Pure decision for the reclaim `catch`: an "already consumed" rejection is
/// split by whether *our own* reclaim was already in flight when this
/// attempt started (persisted `reclaimInFlight` marker) — into a provable
/// Pure decision for the reclaim `catch`. A typed wallet
/// `assetLockAlreadyConsumed` error comes from a retained local tombstone
/// written after a successful consume, and therefore recovers Reclaimed.
/// Consensus wording is only proof that the lock is consumed, not who
/// consumed it, so the prior in-flight marker splits that fallback into a
/// foreign claim vs an explicitly ambiguous consumption. Kept
/// side-effect-free and `nonisolated` so it is the unit-tested seam for all
/// outcomes; the view maps the outcome to `statusRaw`/message/save.
nonisolated static func classifyReclaimFailure(
error: Error,
hadPriorReclaimInFlight: Bool
) -> ReclaimOutcome {
if isAlreadyConsumed(error) {
// Platform deterministically rejected the consume as already-spent.
if isLocallyConsumedTombstone(error) {
return .reclaimed
}
if isAlreadyConsumed(message: error.localizedDescription) {
// Platform deterministically rejected the consume as already-spent,
// but this compatibility fallback cannot attribute the consumer.
// With no local attempt in flight that is a foreign claim; with one
// in flight, attribution is unknowable from the marker alone.
return hadPriorReclaimInFlight ? .consumedAmbiguous : .claimed
Expand Down Expand Up @@ -455,11 +473,18 @@ struct ReclaimInvitationSheet: View {
/// primary signal, with the consensus wording retained as a compatibility
/// fallback for errors originating below the typed wallet boundary.
nonisolated static func isAlreadyConsumed(_ error: Error) -> Bool {
if let walletError = error as? PlatformWalletError,
case .assetLockAlreadyConsumed = walletError {
return true
}
return isAlreadyConsumed(message: error.localizedDescription)
isLocallyConsumedTombstone(error)
|| isAlreadyConsumed(message: error.localizedDescription)
}

/// Whether the wallet rejected a retry from its retained local consumed
/// tombstone. Unlike consensus wording, this identifies a prior successful
/// consume by this wallet and can safely recover the row as Reclaimed.
nonisolated static func isLocallyConsumedTombstone(_ error: Error) -> Bool {
guard let walletError = error as? PlatformWalletError,
case .assetLockAlreadyConsumed = walletError
else { return false }
return true
}

/// Pure classifier over the surfaced error message — the testable seam for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ final class ReclaimInvitationClassifierTests: XCTestCase {
func test_typedAlreadyConsumed_classifiedTrue() {
let error = PlatformWalletError.assetLockAlreadyConsumed("deadbeef:0")
XCTAssertTrue(ReclaimInvitationSheet.isAlreadyConsumed(error))
XCTAssertTrue(ReclaimInvitationSheet.isLocallyConsumedTombstone(error))
}

func test_typedNotTracked_classifiedFalse() {
let error = PlatformWalletError.assetLockNotTracked("deadbeef:0")
XCTAssertFalse(ReclaimInvitationSheet.isAlreadyConsumed(error))
XCTAssertFalse(ReclaimInvitationSheet.isLocallyConsumedTombstone(error))
}

/// The real already-consumed rejection, as surfaced to Swift.
Expand Down Expand Up @@ -83,27 +85,43 @@ final class ReclaimInvitationClassifierTests: XCTestCase {
+ "output 0 already completely used"
)

/// Already-consumed + our own reclaim was in flight ⇒ explicitly ambiguous,
/// NEVER `.reclaimed`: the marker only proves a local attempt started a
/// consume, not that it landed — the invitee can claim between our crash
/// and the retry, and a Reclaimed recovery would misattribute that claim.
func test_classify_alreadyConsumed_priorInFlight_isAmbiguous() {
/// Consensus already-consumed wording + our own reclaim was in flight is
/// explicitly ambiguous: the marker only proves that a local attempt
/// started, while the consensus error cannot attribute who consumed it.
func test_classify_consensusAlreadyConsumed_priorInFlight_isAmbiguous() {
XCTAssertEqual(
ReclaimInvitationSheet.classifyReclaimFailure(
error: Self.alreadyConsumed, hadPriorReclaimInFlight: true),
.consumedAmbiguous
)
}

/// Already-consumed + no prior reclaim ⇒ the invitee claimed it first (Claimed).
func test_classify_alreadyConsumed_noPrior_isClaimed() {
/// Consensus already-consumed wording + no prior reclaim resolves to Claimed.
func test_classify_consensusAlreadyConsumed_noPrior_isClaimed() {
XCTAssertEqual(
ReclaimInvitationSheet.classifyReclaimFailure(
error: Self.alreadyConsumed, hadPriorReclaimInFlight: false),
.claimed
)
}

/// Typed code 24 is emitted from the wallet's retained local consumed
/// tombstone, which is written only after this wallet successfully consumed
/// the lock. It therefore recovers Reclaimed regardless of the UI marker.
func test_classify_typedConsumedTombstone_isReclaimed_regardlessOfMarker() {
let error = PlatformWalletError.assetLockAlreadyConsumed("deadbeef:0")
XCTAssertEqual(
ReclaimInvitationSheet.classifyReclaimFailure(
error: error, hadPriorReclaimInFlight: true),
.reclaimed
)
XCTAssertEqual(
ReclaimInvitationSheet.classifyReclaimFailure(
error: error, hadPriorReclaimInFlight: false),
.reclaimed
)
}

/// A non-already-consumed failure is `.error` regardless of the marker — the
/// row is left as-is. This is the safety net behind the S3 marker-placement
/// fix: a pre-broadcast local failure (never already-consumed, and with the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import SwiftData
import XCTest
@testable import SwiftDashSDK
@testable import SwiftExampleApp

@MainActor
final class ShieldedServiceClearTests: XCTestCase {

private struct ResetFailure: LocalizedError {
var errorDescription: String? { "injected Rust reset failure" }
}

/// Without a manager there is no way to clear the Rust coordinator/tree.
/// The host rows must survive so a failed reset cannot create split-brain
/// persistence where SwiftData is empty but the tree file is not.
func testClearLocalState_withoutWalletManager_preservesRows() async throws {
let container = try DashModelContainer.createInMemory()
let context = try contextWithSyncState(in: container)
let service = ShieldedService()

await service.clearLocalState(modelContext: context)

XCTAssertEqual(try fetchSyncStates(in: container).count, 1)
XCTAssertEqual(
service.lastError,
"Failed to reset shielded state: no wallet manager is bound."
)
}

/// A throwing `clearShielded()` equivalent is also load-bearing: surface
/// the error and leave every host row intact for a safe retry.
func testClearLocalState_whenRustResetThrows_preservesRows() async throws {
let container = try DashModelContainer.createInMemory()
let context = try contextWithSyncState(in: container)
let service = ShieldedService()

await service.clearLocalState(
modelContext: context,
resetRustStateForTesting: { throw ResetFailure() }
)

XCTAssertEqual(try fetchSyncStates(in: container).count, 1)
XCTAssertEqual(
service.lastError,
"Failed to reset shielded state: injected Rust reset failure"
)
}

/// Once the Rust reset succeeds, the host wipe proceeds normally.
func testClearLocalState_afterRustResetSucceeds_deletesRows() async throws {
let container = try DashModelContainer.createInMemory()
let context = try contextWithSyncState(in: container)
let service = ShieldedService()
var didResetRustState = false

await service.clearLocalState(
modelContext: context,
resetRustStateForTesting: { didResetRustState = true }
)

XCTAssertTrue(didResetRustState)
XCTAssertTrue(try fetchSyncStates(in: container).isEmpty)
XCTAssertNil(service.lastError)
}

private func contextWithSyncState(in container: ModelContainer) throws -> ModelContext {
let context = ModelContext(container)
context.insert(
PersistentShieldedSyncState(
walletId: Data(repeating: 0x42, count: 32),
accountIndex: 0,
lastSyncedIndex: 123
)
)
try context.save()
return context
}

private func fetchSyncStates(
in container: ModelContainer
) throws -> [PersistentShieldedSyncState] {
try ModelContext(container).fetch(FetchDescriptor<PersistentShieldedSyncState>())
}
}
Loading