diff --git a/.claude/plans/2026-05-07-package-restructure.md b/.claude/plans/2026-05-07-package-restructure.md new file mode 100644 index 000000000..822489807 --- /dev/null +++ b/.claude/plans/2026-05-07-package-restructure.md @@ -0,0 +1,229 @@ +# Package Restructure — Migration Plan + +**Started:** 2026-05-07 +**Last updated:** 2026-05-07 +**Status:** Step 1 shipped via PR #255. Steps 2–4 pending. + +--- + +## Why + +Two pains motivated this work: + +1. **Manual `@MainActor` boilerplate.** 41 files in the app target carried explicit `@MainActor` annotations — Session, Controllers, Database, Screens, ViewModels, Navigation. They were added during a rolled-back Swift 6 attempt and never collapsed because the app target lacked a default-isolation setting. +2. **Slow build / preview pipeline.** `#Preview` consistently times out. The app target compiles 162 files for any preview, and `FlipcashCore` is a 201-file god module that mixes Solana wire types, gRPC clients, logging, hashes, and currency models in one compile graph. + +Secondary goal: improve testability by separating "domain logic that runs anywhere" from "stateful, observable, UI-driven" code. + +--- + +## Target end state + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Flipcash (.xcodeproj app target) │ +│ Swift 6 • default isolation = MainActor (build settings) │ +│ AppDelegate, FlipcashApp, Container, Session, Controllers, │ +│ Database, Navigation, Screens, ViewModels, Operations │ +│ → no manual @MainActor anywhere │ +└──────────┬──────────────────────────────┬───────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────┐ ┌─────────────────────────────────────────┐ +│ FlipcashUI (SPM) │ │ FlipcashClient (SPM, NEW) │ +│ Swift 6 │ │ Swift 6 • no default isolation │ +│ defaultIsolation │ │ Stateful clients, Sendable services, │ +│ (MainActor) │ │ Intents, Actions, VerifiedProtoService │ +│ Views, Theme, │ │ ~59 files moved from Core/Clients/ │ +│ Camera, Dialog, │ │ + FlipcashAPI + FlipcashCoreAPI as │ +│ Haptics, Containers │ │ internal deps │ +└──────────┬───────────┘ └────────────┬────────────────────────────┘ + │ │ + └──────────────┬──────────────┘ + ▼ + ┌────────────────────────────────┐ + │ FlipcashCore (SPM, slimmed) │ + │ Swift 6 • no default │ + │ isolation │ + │ Models, Solana, Hashes, │ + │ Logging, Formatters, │ + │ Utilities, Extensions, Vendor │ + │ ~142 files (was 201) │ + │ No grpc-swift dep anymore │ + └────────────────────────────────┘ +``` + +### Out of scope + +- No feature-package split (Onramp, Onboarding, Settings as own packages). +- No splitting Solana out of Core. +- No converting `Client` / `FlipClient` from `@MainActor` to `actor`. +- No moving Database into a package. + +These can be revisited after Step 4. + +--- + +## Status + +- [x] **Step 0 — Cleanup** — empty package skeletons (`CodeAPI/`, `FlipchatAPI/`, etc.) deleted from working tree. They were never git-tracked, so no commit was needed; the Xcode 26 pbxproj cleanup that landed alongside Step 1 (commit `adab4ccd`) covered the actual project-file detritus. +- [x] **Step 1 — App target → Swift 6 + `defaultIsolation = MainActor` + strip 41 `@MainActor` annotations** — shipped via PR #255 (39 commits). All 5 concurrency stress baselines green under TSan + Main Thread Checker. Full smoke + soak passed clean. +- [ ] **Step 2 — `FlipcashUI` → `defaultIsolation(MainActor.self)`** + strip 5 manual `@MainActor`. **Risk: low.** Estimated size: small PR, ~10 lines + 5 annotation strips. +- [ ] **Step 3 — Extract `FlipcashClient`** — move ~59 files from `FlipcashCore/Clients/`; FlipcashAPI / FlipcashCoreAPI become its dependencies; FlipcashCore loses the `grpc-swift` dep. **Risk: medium.** Estimated size: medium PR, mostly mechanical file moves. +- [ ] **Step 4 — Re-evaluate** — measure build time delta, preview behavior, outstanding annotations; decide whether further splits (feature packages, Solana extract) are warranted. + +--- + +## Step 1 retrospective (what surfaced) + +Useful context for Step 2 and Step 3 — most of these were "previously masked" by the rolled-back attempt's manual `@MainActor` sprinkles. + +- **Layered Swift 6 diagnostics.** Flipping `SWIFT_VERSION = 6.0` then `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` surfaced four waves of source-isolation issues. Each fix unblocked the next wave because the compiler halts at the first failing batch. Plan more rounds than estimated; budget for ~12 commits of source fixes per Swift-mode flip. +- **Tests, not production, were the broken side in several places.** `WalletProcessingState` had to keep `nonisolated` until `WalletProcessingStateTests` was annotated `@MainActor`. `GradientStop.init(from: Color)` needed `@MainActor` because it touches `UIColor`, but tests reached it from non-main contexts. The pattern: when the strip exposes "this needs `nonisolated(unsafe)` to compile," check whether the real fix is on the test side first. +- **Test target intentionally NOT default-MainActor.** Applying `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` to test targets broke `BaseUITestCase: XCTestCase`. Test targets stay on the legacy default; tests opt into `@MainActor` explicitly via `@Suite @MainActor` per file as needed. +- **The TestSupport `@MainActor` ripple is small and mechanical.** When stripping production explicit `@MainActor`, test-target extensions of those types lose isolation inheritance and need explicit `@MainActor` added. Expect ~3-5 TestSupport sites per equivalent-sized future strip. +- **`@unchecked Sendable` / `nonisolated(unsafe)` use should always pair with a `// SAFETY:` comment naming an invariant + a `// FOLLOW-UP:` comment naming the upstream change that unblocks removal.** Any escape hatch without both is a code-review reject. +- **The 5 concurrency stress baselines are reusable infrastructure for future steps.** They run under TSan + Main Thread Checker on every PR via the `Tag.concurrency` tag. Tag any new stress tests for Steps 2/3 the same way. + +### Files / decisions to carry forward + +- The `runCancellationStress` helper in `FlipcashTests/Concurrency/StressTestSupport.swift` — reuse for any new actor stress tests. +- The `// SAFETY:` + `// FOLLOW-UP:` comment pattern around `@preconcurrency import …`, `@unchecked Sendable`, etc. +- The `@retroactive @unchecked Sendable` pattern for upstream classes that aren't yet Sendable (used for `JSONRPCAPIClient`). +- App target build settings now: `SWIFT_VERSION = 6.0`, `SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES`, `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` on 4 app-target configs. + +--- + +## Step 2 — How to execute (next up) + +**Goal:** drop the 5 manual `@MainActor` annotations from `FlipcashUI` by setting `defaultIsolation(MainActor.self)` on the package. + +**Files:** + +- Modify: `FlipcashUI/Package.swift` — add the default-isolation setting. +- Modify: 5 `FlipcashUI` source files that carry manual `@MainActor`. Confirm via `grep -rln '@MainActor' FlipcashUI/Sources/` at start time. As of 2026-05-07 they were: + - `FlipcashUI/Sources/FlipcashUI/Camera/CameraSession.swift` + - `FlipcashUI/Sources/FlipcashUI/Camera/CameraAuthorizer.swift` + - `FlipcashUI/Sources/FlipcashUI/Haptics/Haptics.swift` + - `FlipcashUI/Sources/FlipcashUI/Modifiers/Separator.swift` + - `FlipcashUI/Sources/FlipcashUI/Transitions/Animations.swift` + +**Sub-steps (each its own commit):** + +1. Update `FlipcashUI/Package.swift`: + - Add `defaultIsolation(MainActor.self)` to the package or each target. + - Enable `NonisolatedNonsendingByDefault` upcoming feature on the target. +2. Build all consumers (`./Scripts/build.sh`). Fix any new diagnostics — same root-cause-not-patch rule as Step 1. +3. Strip the 5 type-level `@MainActor` annotations. Preserve `Task { @MainActor in ... }` closure isolation specifiers. +4. Run the 5 baseline concurrency suites + a manual smoke of the camera (scanner flow) + dialog presentation. + +**Verification gates** — same as Step 1: compile gate, test gate (TSan + MTC), smoke gate, soak gate. + +**Rollback:** `git revert` of the `Package.swift` change + the 5 stripped annotations. + +**Branch name suggestion:** `refactor/flipcash-ui-default-isolation` + +--- + +## Step 3 — How to execute + +**Goal:** Extract a new `FlipcashClient` SwiftPM package containing the gRPC client layer (currently `FlipcashCore/Sources/FlipcashCore/Clients/`). FlipcashAPI and FlipcashCoreAPI become FlipcashClient's dependencies. FlipcashCore loses its `grpc-swift` dep. + +**Files to move (~59 files):** + +Everything under `FlipcashCore/Sources/FlipcashCore/Clients/`: + +- `Flip API/` — FlipClient + service wrappers +- `Payments API/` — Client + Intents + Actions + Services + Utilities + +**Sub-steps (each its own commit):** + +1. Create `FlipcashClient/Package.swift` with deps: `FlipcashCore`, `FlipcashAPI`, `FlipcashCoreAPI`. +2. Move files in batches by sub-folder (Flip API, Payments API/Services, Payments API/Intents, Payments API/Utilities, Payments API root). Use `git mv` so blame survives. One commit per batch. Build after each. +3. Update `FlipcashCore/Package.swift` — drop `FlipcashAPI` / `FlipcashCoreAPI` deps and the transitive `grpc-swift` dep. +4. Update `Code.xcodeproj/project.pbxproj` — link `FlipcashClient` to the app target. +5. Update imports across the app target and FlipcashUI in a dedicated commit (after all files have moved). +6. Build the full project. Run all baseline concurrency suites + targeted client tests. + +**Verification gates** — same four (compile / test / smoke / soak). Smoke covers every gRPC-touching flow because import paths can silently mask wrong dependencies. + +**Rollback:** Each batched commit is revertable. Full rollback may need multiple reverts in reverse order. + +**Branch name suggestion:** `refactor/extract-flipcash-client` + +--- + +## Step 4 — Re-evaluate + +**Goal:** Decision-only step. Capture metrics, decide what's next. + +**Inputs to collect:** + +- Build time before vs after Steps 2 + 3 (use `xcode-build-benchmark` skill). +- `#Preview` compile time on a screen with no network — does it still time out? +- `@MainActor` annotation count app-wide. +- Outstanding `@unchecked Sendable` / `nonisolated(unsafe)` / `@preconcurrency` annotations (with their FOLLOW-UP triggers). + +**Decision points:** + +- Continue with feature-package splits (Onramp, Settings, etc.)? Cost vs benefit. +- Extract Solana from Core? 73 self-contained files; same shape as `FlipcashClient`. +- Convert `Client` / `FlipClient` from `@MainActor` to `actor`? Affects every callsite. +- Move Database into its own package? Currently in the app target. + +Document the decision in this file under a new `## Step 4 outcome` section, then close the migration. + +--- + +## Verification gates (apply at every step) + +1. **Compile gate** — clean build with `SWIFT_TREAT_WARNINGS_AS_ERRORS = YES` for the touched module. +2. **Test gate** — relevant test suites pass with **TSan + Main Thread Checker** enabled in the test scheme. Run the 5 baseline concurrency suites explicitly: + + ``` + ./Scripts/test.sh \ + FlipcashTests/AppRouterStressTests \ + FlipcashTests/LiveMintDataStreamerStressTests \ + FlipcashTests/MessagingServiceFanInStressTests \ + FlipcashTests/RatesControllerStressTests \ + FlipcashTests/VerifiedProtoServiceStressTests + ``` + +3. **Smoke gate** — manual exercise of high-risk flows (≥ 5 min per flow), same sanitizers on, on a Debug build. For Step 2 the high-risk flow is the **scanner** (camera path is what FlipcashUI's `@MainActor` strip touches). For Step 3 it's every **gRPC-touching flow**: send cash, receive cash, swap, currency creation, onramp, message stream. +4. **Soak gate** — run the build for ≥ 30 min mixing flows; tail logs for warnings, dispatch-assertion crashes, TSan reports. (Replaces a dogfood window — solo developer.) + +If smoke surfaces a crash, treat it as a **find** (a bug previously masked by manual annotations), not a regression. Add a regression test under `FlipcashTests/Regressions/Regression_.swift` per CLAUDE.md before fixing. Do not silence with `@unchecked Sendable`, `nonisolated(unsafe)`, or `@preconcurrency` without a `// SAFETY:` comment + `// FOLLOW-UP:` removal trigger. + +--- + +## How to continue + +This plan lives at `.claude/plans/2026-05-07-package-restructure.md` on `main` once PR #255 merges. There is no automation that resumes the work — a human (or a Claude session a human invokes) picks it up. + +**To resume Step 2:** + +1. Start a new Claude Code session at the repo root. +2. Say: "Continue the package restructure plan — Step 2 (FlipcashUI default isolation)." +3. The session reads this file and follows the **Step 2 — How to execute** section above. Same verification gates. Same root-cause-not-patch rule on any new Swift 6 diagnostics. + +**To resume Step 3:** same pattern, swap "Step 2" for "Step 3." + +**To resume Step 4:** "Run the package-restructure migration's Step 4 — re-evaluate." That session collects metrics and writes the outcome back into this file. + +**Order:** Steps are sequential. Step 2 must land cleanly before Step 3. Don't pipeline. + +**Don't:** + +- Skip the verification gates "because Step 1 was clean." Each step's strip can expose its own previously-masked bugs. +- Add `@unchecked` / `nonisolated(unsafe)` / `@preconcurrency` to make a build pass without a SAFETY + FOLLOW-UP comment. +- Move tests to be `@MainActor` to satisfy production isolation. Production isolation should drive the test side, not the other way around. + +--- + +## References + +- PR #255 — Step 1 implementation (this PR). +- CLAUDE.md — Hard rules: Swift Testing, Sendable in metadata, no `@unchecked` without a SAFETY comment. +- `swift-concurrency` skill — guardrails on `@MainActor` blanket usage, escape-hatch policy. +- `swift-testing-expert` skill — `confirmation` over `Task.sleep`, tag-based filtering, isolation justification. +- `simplify` skill — review for reuse, quality, efficiency on each step. diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 59cb44115..255e88944 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -624,8 +624,10 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Development; @@ -676,8 +678,10 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -723,8 +727,10 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -770,8 +776,10 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; }; name = "Release Development"; @@ -799,7 +807,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Flipcash.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Flipcash"; }; name = Development; @@ -827,7 +836,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Flipcash.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Flipcash"; }; name = Debug; @@ -850,7 +860,8 @@ PRODUCT_BUNDLE_IDENTIFIER = com.code.FlipcashTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Flipcash.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Flipcash"; }; name = Release; @@ -873,7 +884,8 @@ PRODUCT_BUNDLE_IDENTIFIER = com.code.FlipcashTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Flipcash.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Flipcash"; }; name = "Release Development"; @@ -901,7 +913,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TEST_TARGET_NAME = Flipcash; }; name = Development; @@ -929,7 +942,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TEST_TARGET_NAME = Flipcash; }; name = Debug; @@ -952,7 +966,8 @@ PRODUCT_BUNDLE_IDENTIFIER = com.code.FlipcashUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TEST_TARGET_NAME = Flipcash; }; name = Release; @@ -975,7 +990,8 @@ PRODUCT_BUNDLE_IDENTIFIER = com.code.FlipcashUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES; + SWIFT_VERSION = 6.0; TEST_TARGET_NAME = Flipcash; }; name = "Release Development"; diff --git a/Code.xcodeproj/xcshareddata/xcschemes/Flipcash.xcscheme b/Code.xcodeproj/xcshareddata/xcschemes/Flipcash.xcscheme index 6ed59deba..b490173d2 100644 --- a/Code.xcodeproj/xcshareddata/xcschemes/Flipcash.xcscheme +++ b/Code.xcodeproj/xcshareddata/xcschemes/Flipcash.xcscheme @@ -45,6 +45,9 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + enableThreadSanitizer = "YES" + enableMainThreadChecker = "YES" + disableMainThreadChecker = "NO" shouldUseLaunchSchemeArgsEnv = "YES"> URL { URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58).sqlite") } - + static func storeWAL(owner: PublicKey) -> URL { URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58).sqlite-wal") } - + static func storeSHM(owner: PublicKey) -> URL { URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58).sqlite-shm") } - + static func versionFile(owner: PublicKey) -> URL { URL.applicationSupportDirectory.appendingPathComponent("flipcash-\(owner.base58)version") } } -extension Notification.Name { +nonisolated extension Notification.Name { static let databaseDidChange = Notification.Name("databaseDidChange") } diff --git a/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift b/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift index 203766b92..421b80719 100644 --- a/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift +++ b/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift @@ -8,7 +8,7 @@ import Foundation import FlipcashCore -struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { +nonisolated struct StoredBalance: Identifiable, Sendable, Equatable, Hashable { let quarks: UInt64 let symbol: String let name: String @@ -101,6 +101,6 @@ extension StoredBalance { } } -extension StoredBalance { +nonisolated extension StoredBalance { private static let bondingCurve = DiscreteBondingCurve() } diff --git a/Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift b/Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift index 78bfbadbb..937b5a720 100644 --- a/Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift +++ b/Flipcash/Core/Controllers/Database/Models/StoredMintMetadata.swift @@ -8,7 +8,7 @@ import Foundation import FlipcashCore -struct StoredMintMetadata: Identifiable, Sendable, Equatable, Hashable { +nonisolated struct StoredMintMetadata: Identifiable, Sendable, Equatable, Hashable { let mint: PublicKey let name: String diff --git a/Flipcash/Core/Controllers/Database/Schema.swift b/Flipcash/Core/Controllers/Database/Schema.swift index cc585948b..c196805a5 100644 --- a/Flipcash/Core/Controllers/Database/Schema.swift +++ b/Flipcash/Core/Controllers/Database/Schema.swift @@ -9,7 +9,7 @@ import Foundation import FlipcashCore @preconcurrency import SQLite -struct BalanceTable: Sendable { +nonisolated struct BalanceTable: Sendable { static let name = "balance" let table = Table(Self.name) @@ -19,7 +19,7 @@ struct BalanceTable: Sendable { let updatedAt = Expression ("updatedAt") } -struct MintTable: Sendable { +nonisolated struct MintTable: Sendable { static let name = "mint" let table = Table(Self.name) @@ -54,7 +54,7 @@ struct MintTable: Sendable { } -struct ActivityTable: Sendable { +nonisolated struct ActivityTable: Sendable { static let name = "activity" let table = Table(Self.name) @@ -69,7 +69,7 @@ struct ActivityTable: Sendable { let date = Expression ("date") } -struct CashLinkMetadataTable: Sendable { +nonisolated struct CashLinkMetadataTable: Sendable { static let name = "cashLinkMetadata" let table = Table(Self.name) @@ -78,7 +78,7 @@ struct CashLinkMetadataTable: Sendable { let canCancel = Expression ("canCancel") } -struct LimitsTable: Sendable { +nonisolated struct LimitsTable: Sendable { static let name = "limits" let table = Table(Self.name) @@ -86,7 +86,7 @@ struct LimitsTable: Sendable { let data = Expression ("data") } -struct RateTable: Sendable { +nonisolated struct RateTable: Sendable { static let name = "rate" let table = Table(Self.name) @@ -95,7 +95,7 @@ struct RateTable: Sendable { } // Verified exchange-rate proofs, one per fiat currency. -struct VerifiedRateTable: Sendable { +nonisolated struct VerifiedRateTable: Sendable { static let name = "verified_rate" let table = Table(Self.name) @@ -104,7 +104,7 @@ struct VerifiedRateTable: Sendable { } // Verified reserve-state proofs, one per mint. -struct VerifiedReserveTable: Sendable { +nonisolated struct VerifiedReserveTable: Sendable { static let name = "verified_reserve" let table = Table(Self.name) @@ -112,11 +112,11 @@ struct VerifiedReserveTable: Sendable { let reserveProto = Expression ("reserveProto") } -extension Expression { +nonisolated extension Expression { func alias(_ alias: String) -> Expression { Expression(alias) } - + func casting(to type: T.Type) -> Expression { Expression(template) } @@ -124,7 +124,7 @@ extension Expression { // MARK: - Tables - -extension Database { +nonisolated extension Database { func createTablesIfNeeded() throws { let balanceTable = BalanceTable() let mintTable = MintTable() @@ -243,7 +243,7 @@ extension Database { // MARK: - Value - -extension UInt64: @retroactive Value { +nonisolated extension UInt64: @retroactive Value { public static var declaredDatatype: String { Int64.declaredDatatype } @@ -257,7 +257,7 @@ extension UInt64: @retroactive Value { } } -extension Key32: @retroactive Value { +nonisolated extension Key32: @retroactive Value { public static var declaredDatatype: String { Blob.declaredDatatype } @@ -271,7 +271,7 @@ extension Key32: @retroactive Value { } } -extension CurrencyCode: @retroactive Value { +nonisolated extension CurrencyCode: @retroactive Value { public static var declaredDatatype: String { String.declaredDatatype } diff --git a/Flipcash/Core/Controllers/Database/Updateable.swift b/Flipcash/Core/Controllers/Database/Updateable.swift index 34fd2b21e..65047ce74 100644 --- a/Flipcash/Core/Controllers/Database/Updateable.swift +++ b/Flipcash/Core/Controllers/Database/Updateable.swift @@ -13,7 +13,7 @@ import SwiftUI /// Use this to keep a view or model in sync with the local database without /// manual refresh calls. The ``value`` property is tracked by `@Observable`, /// so SwiftUI views reading it will update automatically. -@MainActor @Observable +@Observable class Updateable { private(set) var value: T @@ -39,7 +39,7 @@ class Updateable { } } - deinit { + isolated deinit { if let observer { NotificationCenter.default.removeObserver(observer) } diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 6afe22636..0a3d2accb 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -10,7 +10,6 @@ import FlipcashCore private let logger = Logger(label: "flipcash.deeplink") -@MainActor final class DeepLinkController { private let sessionAuthenticator: SessionAuthenticator @@ -146,7 +145,6 @@ extension DeepLinkController { } } -@MainActor struct DeepLinkAction { let kind: Kind diff --git a/Flipcash/Core/Controllers/Deep Links/Route.swift b/Flipcash/Core/Controllers/Deep Links/Route.swift index 10d2043ab..589e26af0 100644 --- a/Flipcash/Core/Controllers/Deep Links/Route.swift +++ b/Flipcash/Core/Controllers/Deep Links/Route.swift @@ -8,7 +8,7 @@ import Foundation import FlipcashCore -struct Route { +nonisolated struct Route { let path: Path let properties: [String: String] @@ -78,7 +78,7 @@ struct Route { // MARK: - Path - -extension Route { +nonisolated extension Route { enum Path { case login @@ -120,7 +120,7 @@ extension Route { // MARK: - Fragment - -extension Route { +nonisolated extension Route { struct Fragment { let key: Key @@ -149,7 +149,7 @@ extension Route { } } -extension Route.Fragment { +nonisolated extension Route.Fragment { enum Key: String, CaseIterable { case entropy = "e" case payload = "p" diff --git a/Flipcash/Core/Controllers/Deep Links/Wallet/ExternalLaunchProcessing.swift b/Flipcash/Core/Controllers/Deep Links/Wallet/ExternalLaunchProcessing.swift index 00a382c2d..11dd12882 100644 --- a/Flipcash/Core/Controllers/Deep Links/Wallet/ExternalLaunchProcessing.swift +++ b/Flipcash/Core/Controllers/Deep Links/Wallet/ExternalLaunchProcessing.swift @@ -9,7 +9,7 @@ import FlipcashCore /// wallet (Phantom) signs a currency-launch funding transaction. Distinct from /// `ExternalSwapProcessing` so buy-existing and launch flows don't share a /// hybrid shape. -struct ExternalLaunchProcessing: Identifiable, Hashable { +nonisolated struct ExternalLaunchProcessing: Identifiable, Hashable { let swapId: SwapId let launchedMint: PublicKey let currencyName: String diff --git a/Flipcash/Core/Controllers/Deep Links/Wallet/ExternalSwapProcessing.swift b/Flipcash/Core/Controllers/Deep Links/Wallet/ExternalSwapProcessing.swift index 990059c16..655b94680 100644 --- a/Flipcash/Core/Controllers/Deep Links/Wallet/ExternalSwapProcessing.swift +++ b/Flipcash/Core/Controllers/Deep Links/Wallet/ExternalSwapProcessing.swift @@ -8,7 +8,7 @@ import FlipcashCore /// Data required to render the processing screen for an external wallet swap /// that funds a buy of an existing launchpad currency. Currency *launch* flows /// use `ExternalLaunchProcessing` instead. -struct ExternalSwapProcessing: Identifiable, Hashable { +nonisolated struct ExternalSwapProcessing: Identifiable, Hashable { let swapId: SwapId let currencyName: String let amount: ExchangedFiat diff --git a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift index 94ed792b7..6d0788b1e 100644 --- a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift +++ b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift @@ -10,11 +10,15 @@ import UIKit import FlipcashUI import FlipcashCore import TweetNacl -import SolanaSwift +// SAFETY: SolanaSwift is not yet Swift 6 / Sendable-audited at the pinned +// version. RequestConfiguration is an Encodable value type built per call +// from local literals, and SimulationResult is a Decodable value type +// returned only to the caller — neither carries shared mutable state across +// the actor hop, so demoting the Sendable diagnostics here is sound. +@preconcurrency import SolanaSwift private let logger = Logger(label: "flipcash.wallet-connection") -@MainActor @Observable public final class WalletConnection { @@ -833,7 +837,6 @@ struct ConnectedWalletSession: Codable { public let phantomEncryptionPublicKey: Data } -@MainActor private extension FlipcashCore.Keychain { @SecureCodable(.connectedWalletSession) static var connectedWalletSession: ConnectedWalletSession? @@ -850,10 +853,18 @@ extension WalletConnection { /// The slice of Solana RPC that `WalletConnection` depends on. A narrow /// protocol (instead of the full `SolanaAPIClient` surface) keeps test stubs /// small and makes the dependency honest at the call site. -protocol WalletRPC { +protocol WalletRPC: Sendable { func getLatestBlockhash(commitment: Commitment?) async throws -> String func sendTransaction(transaction: String, configs: RequestConfiguration) async throws -> TransactionID func simulateTransaction(transaction: String, configs: RequestConfiguration) async throws -> SimulationResult } +// SAFETY: JSONRPCAPIClient holds only an immutable APIEndPoint and an +// immutable NetworkManager (URLSession is documented thread-safe); each +// request constructs a fresh URLRequest with no shared mutable state. +// Verified against SolanaSwift's source at the pinned version (see +// Package.resolved). +// FOLLOW-UP: Remove when SolanaSwift adopts Sendable on JSONRPCAPIClient upstream. +extension JSONRPCAPIClient: @retroactive @unchecked Sendable {} + extension JSONRPCAPIClient: WalletRPC {} diff --git a/Flipcash/Core/Controllers/HistoryController.swift b/Flipcash/Core/Controllers/HistoryController.swift index e8700479b..a143ef2b7 100644 --- a/Flipcash/Core/Controllers/HistoryController.swift +++ b/Flipcash/Core/Controllers/HistoryController.swift @@ -23,7 +23,7 @@ private let logger = Logger(label: "flipcash.history-controller") /// then reloads the active slice so observers see the new state. /// /// Inject via `@Environment(HistoryController.self)`. -@MainActor @Observable +@Observable class HistoryController { enum LoadingState: Equatable { diff --git a/Flipcash/Core/Controllers/MarketCapController.swift b/Flipcash/Core/Controllers/MarketCapController.swift index 5161480e1..f96fc77de 100644 --- a/Flipcash/Core/Controllers/MarketCapController.swift +++ b/Flipcash/Core/Controllers/MarketCapController.swift @@ -32,7 +32,6 @@ import FlipcashUI /// /// The controller does not manage any UI state - it only provides processed data. /// The caller is responsible for managing the `ChartViewModel` state (loading, error, loaded). -@MainActor final class MarketCapController { // MARK: - Constants diff --git a/Flipcash/Core/Controllers/NotificationController.swift b/Flipcash/Core/Controllers/NotificationController.swift index 3e7a09c0c..e067a90d4 100644 --- a/Flipcash/Core/Controllers/NotificationController.swift +++ b/Flipcash/Core/Controllers/NotificationController.swift @@ -13,7 +13,7 @@ import UIKit /// active or receives a push notification. /// /// Inject via `@Environment(NotificationController.self)`. -@MainActor @Observable +@Observable class NotificationController { /// Incremented each time the app becomes active. @@ -43,13 +43,13 @@ class NotificationController { observe(.messageNotificationReceived) { $0.messageReceived += 1 } } - deinit { + isolated deinit { for observer in observers { NotificationCenter.default.removeObserver(observer) } } - private func observe(_ name: Notification.Name, handler: @escaping (NotificationController) -> Void) { + private func observe(_ name: Notification.Name, handler: @escaping @MainActor (NotificationController) -> Void) { let token = NotificationCenter.default.addObserver( forName: name, object: nil, diff --git a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift index 807294c84..2d644e128 100644 --- a/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift +++ b/Flipcash/Core/Controllers/Onramp/OnrampCoordinator.swift @@ -10,7 +10,6 @@ import FlipcashCore private let logger = Logger(label: "flipcash.onramp-coordinator") -@MainActor @Observable final class OnrampCoordinator { diff --git a/Flipcash/Core/Controllers/Preferences.swift b/Flipcash/Core/Controllers/Preferences.swift index b128aa852..f9979430f 100644 --- a/Flipcash/Core/Controllers/Preferences.swift +++ b/Flipcash/Core/Controllers/Preferences.swift @@ -16,7 +16,7 @@ import FlipcashUI /// disabled on background entry to avoid unexpected activation. /// /// Inject via `@Environment(Preferences.self)`. -@MainActor @Observable +@Observable class Preferences { /// Whether the camera feed is currently active. @@ -54,7 +54,6 @@ class Preferences { } } -@MainActor extension UserDefaults { @Defaults(.cameraAutoStartDisabled) static var cameraAutoStartDisabled: Bool? diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index 5102f3ada..5b89dfc06 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -22,7 +22,7 @@ private let logger = Logger(label: "flipcash.push-controller") /// becomes active (e.g. after the user changes permissions in Settings). /// /// Inject via `@Environment(PushController.self)`. -@MainActor @Observable +@Observable class PushController { /// The current notification authorization status, refreshed on app activation. @@ -74,7 +74,7 @@ class PushController { } } - deinit { + isolated deinit { if let activeObserver { NotificationCenter.default.removeObserver(activeObserver) } @@ -172,7 +172,6 @@ extension PushController { // MARK: - UNUserNotificationCenterDelegate - -@MainActor private class NotificationDelegate: NSObject, @preconcurrency UNUserNotificationCenterDelegate, @preconcurrency MessagingDelegate { var didReceiveFCMToken: (@MainActor (String?) async throws -> Void)? diff --git a/Flipcash/Core/Controllers/RatesController.swift b/Flipcash/Core/Controllers/RatesController.swift index 31447f45d..009ecdfe5 100644 --- a/Flipcash/Core/Controllers/RatesController.swift +++ b/Flipcash/Core/Controllers/RatesController.swift @@ -6,7 +6,13 @@ // import Foundation -import Combine +// SAFETY: PassthroughSubject (used for ratesPublisher and +// reserveStatesPublisher) is not yet Sendable upstream. Publishers +// route through .receive(on: DispatchQueue.main) before any state +// mutation, so cross-isolation reads are sound today. +// FOLLOW-UP: Remove when Combine adopts Sendable on PassthroughSubject +// or these are migrated to AsyncSequence. +@preconcurrency import Combine import FlipcashCore private let logger = Logger(label: "flipcash.rates-controller") @@ -18,7 +24,7 @@ private let logger = Logger(label: "flipcash.rates-controller") /// are persisted to `UserDefaults` via `LocalDefaults`. /// /// Inject via `@Environment(RatesController.self)`. -@MainActor @Observable +@Observable class RatesController { /// The currency used for displaying balances. /// Persisted to `UserDefaults` on change. diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 7a5209b37..135c1572d 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -12,7 +12,7 @@ extension AppRouter { /// A type-erased push target. Every screen reachable via a NavigationStack /// push (anywhere in the app) is a case here. - enum Destination: Hashable, Sendable, CustomStringConvertible { + nonisolated enum Destination: Hashable, Sendable, CustomStringConvertible { // Wallet flow case currencyInfo(PublicKey) diff --git a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift index 014d93309..295cbaff3 100644 --- a/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift +++ b/Flipcash/Core/Navigation/AppRouter+SheetPresentation.swift @@ -11,7 +11,7 @@ extension AppRouter { /// Identifies the top-level modal sheet currently overlaying `ScanScreen`. /// One sheet at a time; switching sheets dismisses the previous. - enum SheetPresentation: Identifiable, Hashable, Sendable, CustomStringConvertible { + nonisolated enum SheetPresentation: Identifiable, Hashable, Sendable, CustomStringConvertible { case balance case settings case give diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 8f1c755d0..259e3e745 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -26,7 +26,6 @@ private let logger = Logger(label: "flipcash.router") /// All mutators log at INFO via `flipcash.router`. The bindable subscript /// funnels SwiftUI's automatic writes (e.g., swipe-back) through `setPath`, /// so every observable state change produces exactly one log line. -@MainActor @Observable final class AppRouter { diff --git a/Flipcash/Core/Screens/Main/Bill/CashCode.Payload+Encoding.swift b/Flipcash/Core/Screens/Main/Bill/CashCode.Payload+Encoding.swift index c2656f0c8..2da6f1de2 100644 --- a/Flipcash/Core/Screens/Main/Bill/CashCode.Payload+Encoding.swift +++ b/Flipcash/Core/Screens/Main/Bill/CashCode.Payload+Encoding.swift @@ -9,8 +9,8 @@ import Foundation import FlipcashCore import CodeScanner -extension CashCode.Payload { - +nonisolated extension CashCode.Payload { + static let length: Int = 20 init(data: Data) throws { diff --git a/Flipcash/Core/Screens/Main/Bill/CashCode.Payload.swift b/Flipcash/Core/Screens/Main/Bill/CashCode.Payload.swift index 096856d45..5e74e9e7a 100644 --- a/Flipcash/Core/Screens/Main/Bill/CashCode.Payload.swift +++ b/Flipcash/Core/Screens/Main/Bill/CashCode.Payload.swift @@ -8,9 +8,9 @@ import Foundation import FlipcashCore -enum CashCode {} +nonisolated enum CashCode {} -extension CashCode { +nonisolated extension CashCode { struct Payload: Equatable { /// Decimal precision used by the on-the-wire `fiat` UInt64 field, @@ -53,7 +53,7 @@ extension CashCode { // MARK: - Value - -extension CashCode.Payload { +nonisolated extension CashCode.Payload { enum Value: Equatable { case fiat(FiatAmount) } @@ -61,17 +61,17 @@ extension CashCode.Payload { // MARK: - Kind - -extension CashCode.Payload { +nonisolated extension CashCode.Payload { enum Kind: UInt8 { case cash = 0 case cashMulticurrency = 1 } } -extension Data { - +nonisolated extension Data { + static let nonceLength: Int = 10 - + static var nonce: Data { do { return try secRandom(nonceLength) @@ -98,7 +98,7 @@ extension Data { } } -extension Data { +nonisolated extension Data { enum Error: Swift.Error { case randomBytesUnavailable } diff --git a/Flipcash/Core/Screens/Main/Color Editor/ColorEditorControl.swift b/Flipcash/Core/Screens/Main/Color Editor/ColorEditorControl.swift index 3fab93500..7a65b5762 100644 --- a/Flipcash/Core/Screens/Main/Color Editor/ColorEditorControl.swift +++ b/Flipcash/Core/Screens/Main/Color Editor/ColorEditorControl.swift @@ -74,7 +74,7 @@ private enum PickerMode { case custom } -public struct GradientStop: Identifiable, Equatable { +public nonisolated struct GradientStop: Identifiable, Equatable, Sendable { public let id = UUID() @@ -94,6 +94,7 @@ public struct GradientStop: Identifiable, Equatable { self.alpha = alpha } + @MainActor init(from color: Color) { // Convert Color to HSB - this is a simplified approach // In production, you might want more precise color space conversion @@ -103,7 +104,7 @@ public struct GradientStop: Identifiable, Equatable { var b: CGFloat = 0 var a: CGFloat = 0 uiColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) - + self.hue = h self.saturation = s self.brightness = b diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationScreen.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationScreen.swift index 31e09d8a0..5a18c3dfe 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyCreationScreen.swift @@ -30,7 +30,7 @@ enum CurrencyCreationStep: Hashable { // MARK: - CurrencyCreationState -@MainActor @Observable +@Observable final class CurrencyCreationState { var currencyName: String = "" { didSet { if currencyName != oldValue { nameAttestation = nil } } diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingViewModel.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingViewModel.swift index ac0989077..8d9f12646 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingViewModel.swift @@ -8,7 +8,6 @@ import FlipcashCore private let logger = Logger(label: "flipcash.currency-launch-processing") -@MainActor @Observable class CurrencyLaunchProcessingViewModel { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift index 605b6fc88..a7bc05f7b 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift @@ -8,7 +8,7 @@ import SwiftUI import FlipcashCore -@MainActor @Observable +@Observable class CurrencyInfoViewModel { enum LoadingState { diff --git a/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionViewModel.swift b/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionViewModel.swift index a90bdd803..094e2c1d8 100644 --- a/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Selection/CurrencySelectionViewModel.swift @@ -9,7 +9,7 @@ import SwiftUI import FlipcashUI import FlipcashCore -@MainActor @Observable +@Observable class CurrencySelectionViewModel { var availableCurrencies: [CurrencyDescription] = [] diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift index fe0d00415..26c33bc63 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/CurrencyBuyViewModel.swift @@ -12,7 +12,7 @@ import Logging private let logger = Logger(label: "flipcash.swap-service") -@MainActor @Observable +@Observable class CurrencyBuyViewModel: Identifiable { var actionButtonState: ButtonState = .normal var enteredAmount: String = "" diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift index cb9c9a62e..1eed336ca 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellConfirmationViewModel.swift @@ -9,7 +9,7 @@ import SwiftUI import FlipcashUI import FlipcashCore -@MainActor @Observable +@Observable class CurrencySellConfirmationViewModel { @ObservationIgnored let mint: PublicKey @ObservationIgnored let amount: ExchangedFiat diff --git a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellViewModel.swift index 35abe716f..d411b0ea1 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/CurrencySellViewModel.swift @@ -9,7 +9,7 @@ import SwiftUI import FlipcashCore import FlipcashUI -@MainActor @Observable +@Observable class CurrencySellViewModel: Identifiable { var enteredAmount: String = "" var path: [CurrencySellPath] = [] diff --git a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift index 62fde91e7..1d700b2c2 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/SwapProcessingViewModel.swift @@ -9,7 +9,6 @@ import SwiftUI import FlipcashCore -@MainActor @Observable class SwapProcessingViewModel { diff --git a/Flipcash/Core/Screens/Main/DialogWindow.swift b/Flipcash/Core/Screens/Main/DialogWindow.swift index 731f0f6ac..d43503b27 100644 --- a/Flipcash/Core/Screens/Main/DialogWindow.swift +++ b/Flipcash/Core/Screens/Main/DialogWindow.swift @@ -11,7 +11,6 @@ import FlipcashUI /// A window that presents `session.dialogItem` above all other /// UI, including sheets. Uses `UIWindow.Level.alert` so it sits /// on top of the main window regardless of SwiftUI's sheet queue. -@MainActor final class DialogWindow { private var window: PassthroughWindow? diff --git a/Flipcash/Core/Screens/Main/GiveViewModel.swift b/Flipcash/Core/Screens/Main/GiveViewModel.swift index b9a9b979f..0bbf9b221 100644 --- a/Flipcash/Core/Screens/Main/GiveViewModel.swift +++ b/Flipcash/Core/Screens/Main/GiveViewModel.swift @@ -12,7 +12,7 @@ import Logging private let logger = Logger(label: "flipcash.send-cash") -@MainActor @Observable +@Observable class GiveViewModel { var enteredAmount: String = "" diff --git a/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift b/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift index b1747bc5c..d23c6998f 100644 --- a/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift +++ b/Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift @@ -36,7 +36,6 @@ private let logger = Logger(label: "flipcash.scan-cash") /// Both `VerifiedState` and `MintMetadata` come from the **sender's message** /// (step 1), not from local caches. This means the scan path works even /// when Device B has never synced this currency. -@MainActor class ScanCashOperation { private let client: Client @@ -58,7 +57,7 @@ class ScanCashOperation { logger.info("ScanCashOperation opened", metadata: ["rendezvous": "\(payload.rendezvous.publicKey.base58)"]) } - deinit { + isolated deinit { logger.info("ScanCashOperation closed", metadata: ["rendezvous": "\(payload.rendezvous.publicKey.base58)"]) runTask?.cancel() } diff --git a/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift b/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift index bc1e29ae8..c117769b5 100644 --- a/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift +++ b/Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift @@ -47,7 +47,6 @@ private let logger = Logger(label: "flipcash.send-cash") /// `Session` creates, stores (`sendOperation`), and tears down this /// operation. The `ignoresStream` flag is toggled by Session when presenting /// a share sheet to suppress grab-request processing underneath it. -@MainActor class SendCashOperation { /// Submitting a proof older than this is rejected as stale, so we pre-flight the check @@ -93,7 +92,7 @@ class SendCashOperation { logger.info("SendCashOperation opened", metadata: ["rendezvous": "\(payload.rendezvous.publicKey.base58)"]) } - deinit { + isolated deinit { logger.info("SendCashOperation closed", metadata: ["rendezvous": "\(payload.rendezvous.publicKey.base58)"]) runTask?.cancel() } diff --git a/Flipcash/Core/Screens/Main/Operations/VerifiedStateResolution.swift b/Flipcash/Core/Screens/Main/Operations/VerifiedStateResolution.swift index 495a3ceb8..3cf07d75f 100644 --- a/Flipcash/Core/Screens/Main/Operations/VerifiedStateResolution.swift +++ b/Flipcash/Core/Screens/Main/Operations/VerifiedStateResolution.swift @@ -11,7 +11,7 @@ import FlipcashCore /// Encoded as an enum rather than `(VerifiedState?, source: Source)` so the /// "state is nil iff cacheMiss" invariant holds at the type level — callers /// can't accidentally consume `.cacheMiss` as if it had a value. -enum VerifiedStateResolution: Equatable, Sendable { +nonisolated enum VerifiedStateResolution: Equatable, Sendable { case provided(VerifiedState) case cacheHit(VerifiedState) case cacheMiss @@ -41,7 +41,7 @@ func resolveVerifiedState( provided: VerifiedState?, currency: CurrencyCode, mint: PublicKey, - cacheLookup: (CurrencyCode, PublicKey) async -> VerifiedState? + cacheLookup: sending (CurrencyCode, PublicKey) async -> VerifiedState? ) async -> VerifiedStateResolution { if let provided { return .provided(provided) diff --git a/Flipcash/Core/Screens/Main/ScanViewModel.swift b/Flipcash/Core/Screens/Main/ScanViewModel.swift index bc4779444..931848c10 100644 --- a/Flipcash/Core/Screens/Main/ScanViewModel.swift +++ b/Flipcash/Core/Screens/Main/ScanViewModel.swift @@ -12,7 +12,7 @@ import Combine private let logger = Logger(label: "flipcash.scan") -@MainActor @Observable +@Observable class ScanViewModel { private static let qrCooldownInterval: TimeInterval = 5.0 diff --git a/Flipcash/Core/Screens/Onboarding/AccountSelectionScreen.swift b/Flipcash/Core/Screens/Onboarding/AccountSelectionScreen.swift index 88687b39a..93169397b 100644 --- a/Flipcash/Core/Screens/Onboarding/AccountSelectionScreen.swift +++ b/Flipcash/Core/Screens/Onboarding/AccountSelectionScreen.swift @@ -247,7 +247,6 @@ struct AccountSelectionScreen: View { } } - @MainActor private func update(owner: PublicKey, handler: @MainActor (inout HistoricalAccount) -> Void) { let index = accounts.firstIndex { $0.details.account.ownerPublicKey == owner } @@ -261,7 +260,6 @@ struct AccountSelectionScreen: View { // MARK: - HistoricalAccount - -@MainActor class HistoricalAccount: Identifiable { nonisolated diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index 4485f9aa0..2c1fb2037 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -10,7 +10,7 @@ import UserNotifications import FlipcashUI import FlipcashCore -@MainActor @Observable +@Observable class OnboardingViewModel { var path: [OnboardingPath] = [] diff --git a/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift b/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift index 539af577f..122ff2351 100644 --- a/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift +++ b/Flipcash/Core/Screens/Onramp/OnrampViewModel.swift @@ -17,12 +17,12 @@ private let logger = Logger(label: "flipcash.onramp") /// whether the link arrived before or after the sheet opened the verification /// is picked up through the same entry point. Lives on `SessionContainer` so /// it survives sheet dismissal but not logout. -@MainActor @Observable +@Observable final class OnrampDeeplinkInbox { var pendingEmailVerification: VerificationDescription? } -@MainActor @Observable +@Observable class OnrampViewModel { var enteredAmount: String = "" diff --git a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawAmountScreen.swift b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawAmountScreen.swift index d27b3304b..4e7fd78af 100644 --- a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawAmountScreen.swift +++ b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawAmountScreen.swift @@ -33,7 +33,9 @@ struct WithdrawAmountScreen: View { actionState: .constant(.normal), actionEnabled: { _ in canProceed }, action: onProceed, - currencySelectionAction: showsCurrencySelection ? showCurrencySelection : nil + // Wrapped in a closure literal — partial-applying a @MainActor method + // in a ternary against `nil` trips the Swift 6 type-checker. + currencySelectionAction: showsCurrencySelection ? { showCurrencySelection() } : nil ) .foregroundStyle(Color.textMain) .padding(20) diff --git a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawViewModel.swift b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawViewModel.swift index e98cd8239..aad37d25a 100644 --- a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawViewModel.swift +++ b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawViewModel.swift @@ -9,7 +9,7 @@ import SwiftUI import FlipcashCore import FlipcashUI -@MainActor @Observable +@Observable class WithdrawViewModel { /// Pushes a sub-step onto the parent NavigationStack. Wired by /// `WithdrawScreen` to call `router.pushAny(_:on: .settings)`. diff --git a/Flipcash/Core/Session/AccountManager.swift b/Flipcash/Core/Session/AccountManager.swift index 474e4b720..3af0648ec 100644 --- a/Flipcash/Core/Session/AccountManager.swift +++ b/Flipcash/Core/Session/AccountManager.swift @@ -10,7 +10,6 @@ import FlipcashCore private let logger = Logger(label: "flipcash.account-manager") -@MainActor class AccountManager { enum SortCritieria { @@ -155,10 +154,10 @@ class AccountManager { private extension Keychain { @SecureCodable(.keyAccount) static var keyAccount: KeyAccount? - + @SecureCodable(.currentUserAccount) static var userAccount: UserAccount? - + @SecureCodable(.historicalAccounts, sync: true) static var historicalAccounts: [String: AccountDescription]? } diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 1eb6229d0..87ac80610 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -11,7 +11,6 @@ import FlipcashCore private let logger = Logger(label: "flipcash.session") -@MainActor protocol SessionDelegate: AnyObject { func didDetectUnlockedAccount() } @@ -25,7 +24,7 @@ protocol SessionDelegate: AnyObject { /// /// Inject via `@Environment(Session.self)`. Use `@Bindable` when bindings /// are needed (e.g. `$session.dialogItem` for sheets). -@MainActor @Observable +@Observable class Session { // MARK: - Database-Driven State - diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 607b5ca47..61080efef 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -18,7 +18,7 @@ private let logger = Logger(label: "flipcash.session-auth") /// top-level view hierarchy (intro → login → scan screen). /// /// Inject via `@Environment(SessionAuthenticator.self)`. -@MainActor @Observable +@Observable final class SessionAuthenticator { @ObservationIgnored let accountManager: AccountManager @@ -419,7 +419,6 @@ struct SessionContainer { let onrampCoordinator: OnrampCoordinator let appRouter: AppRouter - @MainActor init( session: Session, database: Database, @@ -464,10 +463,10 @@ extension View { // MARK: - UserDefaults - extension UserDefaults { - + @Defaults(.launchCount) fileprivate static var launchCount: Int? - + @Defaults(.wasLoggedIn) fileprivate static var wasLoggedIn: Bool? } diff --git a/Flipcash/Extensions/RandomAccessCollection+Indexed.swift b/Flipcash/Extensions/RandomAccessCollection+Indexed.swift index b2440cb43..03fa1c374 100644 --- a/Flipcash/Extensions/RandomAccessCollection+Indexed.swift +++ b/Flipcash/Extensions/RandomAccessCollection+Indexed.swift @@ -20,7 +20,7 @@ import Foundation /// A pair of a zero-based index and its corresponding collection element. -struct IndexedElement { +nonisolated struct IndexedElement { let index: Int let element: Element } @@ -28,7 +28,7 @@ struct IndexedElement { /// A `RandomAccessCollection` that pairs each element of a base collection /// with its zero-based integer index — equivalent to `enumerated()` but /// usable directly in `ForEach`. -struct IndexedCollection: RandomAccessCollection { +nonisolated struct IndexedCollection: RandomAccessCollection { let base: Base var startIndex: Base.Index { base.startIndex } @@ -48,7 +48,7 @@ struct IndexedCollection: RandomAccessCollection { } } -extension RandomAccessCollection { +nonisolated extension RandomAccessCollection { /// Returns an `IndexedCollection` that pairs each element with its /// zero-based index, conforming to `RandomAccessCollection`. /// diff --git a/Flipcash/Keychain/Defaults.swift b/Flipcash/Keychain/Defaults.swift index a69069567..6da0d37c5 100644 --- a/Flipcash/Keychain/Defaults.swift +++ b/Flipcash/Keychain/Defaults.swift @@ -48,9 +48,18 @@ enum DefaultsKey: String { // case lastSeenInviteCount = "com.code.lastSeenInviteCount" } +private let defaultsEncoder = JSONEncoder() +private let defaultsDecoder = JSONDecoder() + +/// `Defaults` is a Sendable property wrapper around `UserDefaults.standard`. +/// Static-var holders that use `@Defaults(...)` are concurrency-safe ONLY when +/// the holder is `@MainActor`-isolated — typically inherited via the +/// app target's `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. Adding +/// `nonisolated` to such holders is a regression: it would expose the +/// static var as global mutable state under Swift 6 strict-concurrency. @propertyWrapper -struct Defaults where T: Codable { - +struct Defaults: Sendable where T: Codable & Sendable { + var wrappedValue: T? { get { decode(UserDefaults.standard.data(forKey: key.rawValue)) @@ -63,33 +72,30 @@ struct Defaults where T: Codable { } } } - + private let key: DefaultsKey - - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - + // MARK: - Init - - + init(_ key: DefaultsKey) { self.key = key } - + // MARK: - Codable - - + private func encode(_ value: T?) -> Data? { guard let value = value else { return nil } - - return try? encoder.encode(value) + + return try? defaultsEncoder.encode(value) } - + private func decode(_ data: Data?) -> T? { guard let data = data else { return nil } - - return try? decoder.decode(T.self, from: data) + + return try? defaultsDecoder.decode(T.self, from: data) } } diff --git a/Flipcash/Share/ShareSheet.swift b/Flipcash/Share/ShareSheet.swift index 2d3fde650..261271762 100644 --- a/Flipcash/Share/ShareSheet.swift +++ b/Flipcash/Share/ShareSheet.swift @@ -8,7 +8,6 @@ import SwiftUI import FlipcashUI -@MainActor struct ShareSheet: UIViewControllerRepresentable { let activityItem: UIActivityItemSource diff --git a/Flipcash/UI/ApplePayWebView.swift b/Flipcash/UI/ApplePayWebView.swift index d5ce9bd5f..d02064f0a 100644 --- a/Flipcash/UI/ApplePayWebView.swift +++ b/Flipcash/UI/ApplePayWebView.swift @@ -178,6 +178,6 @@ public struct ApplePayEvent: Codable, Sendable { } } -private extension String { +private nonisolated extension String { static let messageHandlerName = "coinbasepayment" } diff --git a/Flipcash/UI/EnterAmountCalculator.swift b/Flipcash/UI/EnterAmountCalculator.swift index 9505107fe..429b62f66 100644 --- a/Flipcash/UI/EnterAmountCalculator.swift +++ b/Flipcash/UI/EnterAmountCalculator.swift @@ -8,7 +8,7 @@ import Foundation import FlipcashCore -struct EnterAmountCalculator { +nonisolated struct EnterAmountCalculator { /// Provides the full SendLimit for a given currency typealias SendLimitProvider = (CurrencyCode) -> SendLimit? diff --git a/Flipcash/Utilities/ErrorReporting.swift b/Flipcash/Utilities/ErrorReporting.swift index 3ca6e406f..032027e22 100644 --- a/Flipcash/Utilities/ErrorReporting.swift +++ b/Flipcash/Utilities/ErrorReporting.swift @@ -162,7 +162,7 @@ enum ErrorReporting { } } -class Fault: NSError, @unchecked Sendable {} +nonisolated class Fault: NSError, @unchecked Sendable {} enum Breadcrumb: String { case placeholder = "Placeholder" diff --git a/Flipcash/Utilities/ImageCompressor.swift b/Flipcash/Utilities/ImageCompressor.swift index 569017300..1c51ea247 100644 --- a/Flipcash/Utilities/ImageCompressor.swift +++ b/Flipcash/Utilities/ImageCompressor.swift @@ -5,7 +5,7 @@ import UIKit -enum ImageCompressor { +nonisolated enum ImageCompressor { /// Normalizes EXIF orientation and caps the image to `maxDimension` on its /// longest side. Returns the original image unchanged when already within bounds. diff --git a/Flipcash/Utilities/ImageEncoder.swift b/Flipcash/Utilities/ImageEncoder.swift index 366435248..f8df317bf 100644 --- a/Flipcash/Utilities/ImageEncoder.swift +++ b/Flipcash/Utilities/ImageEncoder.swift @@ -10,7 +10,7 @@ enum ImageEncoderError: Error { case cannotFitBudget } -enum ImageEncoder { +nonisolated enum ImageEncoder { /// Encodes `image` as JPEG data, guaranteeing the result is <= `maxBytes`. /// Progressively lowers quality and, if needed, downsizes the image until diff --git a/Flipcash/Utilities/PhotoLibrary.swift b/Flipcash/Utilities/PhotoLibrary.swift index 715dec53e..b52466091 100644 --- a/Flipcash/Utilities/PhotoLibrary.swift +++ b/Flipcash/Utilities/PhotoLibrary.swift @@ -30,7 +30,6 @@ enum PhotoLibrary { } } - @MainActor private static func createSnapshotImage(mnemonic: MnemonicPhrase) -> UIImage { let controller = UIHostingController(rootView: Snapshot(mnemonic: mnemonic)) diff --git a/Flipcash/Utilities/URL+Links.swift b/Flipcash/Utilities/URL+Links.swift index 844195a68..4ea3ded18 100644 --- a/Flipcash/Utilities/URL+Links.swift +++ b/Flipcash/Utilities/URL+Links.swift @@ -41,25 +41,21 @@ extension URL { extension URL { @available(iOSApplicationExtension, unavailable) - @MainActor static func openSettings() { URL.settings.openWithApplication() } - + @available(iOSApplicationExtension, unavailable) - @MainActor static func openMail() { URL.mail.openWithApplication() } - + @available(iOSApplicationExtension, unavailable) - @MainActor func canOpen() -> Bool { UIApplication.shared.canOpenURL(self) } - + @available(iOSApplicationExtension, unavailable) - @MainActor func openWithApplication() { if canOpen() { UIApplication.shared.open(self, options: [:], completionHandler: nil) diff --git a/FlipcashCore/Sources/FlipcashCore/Extensions/Task+Retry.swift b/FlipcashCore/Sources/FlipcashCore/Extensions/Task+Retry.swift index f985f583c..934d50a70 100644 --- a/FlipcashCore/Sources/FlipcashCore/Extensions/Task+Retry.swift +++ b/FlipcashCore/Sources/FlipcashCore/Extensions/Task+Retry.swift @@ -23,8 +23,8 @@ extension Task where Success == Never, Failure == Never { public static func retry( maxAttempts: Int, delay: Duration, - shouldRetry: (Error) -> Bool = { _ in true }, - body: () async throws -> T + shouldRetry: sending (Error) -> Bool = { _ in true }, + body: sending () async throws -> T ) async throws -> T { precondition(maxAttempts >= 1, "maxAttempts must be at least 1") var attempt = 0 diff --git a/FlipcashTests/BillDesignerColorsTests.swift b/FlipcashTests/BillDesignerColorsTests.swift index 67c34356c..6d58e8d81 100644 --- a/FlipcashTests/BillDesignerColorsTests.swift +++ b/FlipcashTests/BillDesignerColorsTests.swift @@ -13,6 +13,7 @@ import UIKit import FlipcashUI @testable import Flipcash +@MainActor @Suite("Bill Designer color derivation") struct BillDesignerColorsTests { diff --git a/FlipcashTests/ColorEditorControlTests.swift b/FlipcashTests/ColorEditorControlTests.swift index fa519232e..805071afa 100644 --- a/FlipcashTests/ColorEditorControlTests.swift +++ b/FlipcashTests/ColorEditorControlTests.swift @@ -12,6 +12,7 @@ import SwiftUI import FlipcashUI @testable import Flipcash +@MainActor @Suite("ColorEditorControl") struct ColorEditorControlTests { diff --git a/FlipcashTests/Concurrency/AppRouterStressTests.swift b/FlipcashTests/Concurrency/AppRouterStressTests.swift new file mode 100644 index 000000000..1ebedc610 --- /dev/null +++ b/FlipcashTests/Concurrency/AppRouterStressTests.swift @@ -0,0 +1,66 @@ +// +// AppRouterStressTests.swift +// FlipcashTests +// +// Observable-state sentinel for `AppRouter`. `AppRouter` is +// `@MainActor @Observable` — its mutators are serialized by main-actor +// isolation, so this suite cannot manufacture cross-actor pressure. Its +// purpose is to lock in observable-state correctness across the +// present/dismiss cycle so that the present/dismiss bookkeeping +// (`presentedSheet`, `dismissedSheets`, per-stack `paths`) converges on +// a consistent state after repeated user-flow shapes regardless of +// whether `AppRouter` is explicitly or implicitly main-actor isolated. +// +// Scope: state-consistency only. The router emits one INFO log per +// mutation under `flipcash.router`, but there's no in-suite log handler +// exposed to count them — log-count assertion would require additional +// production seams. +// + +import Foundation +import Testing +@testable import Flipcash + +@Suite( + "AppRouter present/dismiss cycle", + .timeLimit(.minutes(1)), + .tags(.concurrency, .stress) +) +@MainActor +struct AppRouterStressTests { + + @Test("100 alternating present/dismiss leave router in consistent state") + func alternatingPresentDismiss_isConsistent() { + let router = AppRouter() + + for _ in 0..<100 { + router.present(.balance) + router.dismissSheet() + } + + #expect(router.presentedSheet == nil) + #expect(router[.balance].isEmpty) + } + + /// Cycling through every `SheetPresentation` case mirrors the real + /// "swap between top-level sheets" flow — the user opens Balance, + /// then Settings, then Give, etc., dismissing each in turn. After 100 + /// rounds the router must be back at no presented sheet with every + /// per-stack path empty. + @Test("100 rounds across all sheet cases converge on empty state") + func cyclingAllSheets_convergesOnEmptyState() { + let router = AppRouter() + let sheets = AppRouter.Stack.allCases.map(\.sheet) + + for i in 0..<100 { + let sheet = sheets[i % sheets.count] + router.present(sheet) + router.dismissSheet() + } + + #expect(router.presentedSheet == nil) + for stack in AppRouter.Stack.allCases { + #expect(router[stack].isEmpty) + } + } +} diff --git a/FlipcashTests/Concurrency/LiveMintDataStreamerStressTests.swift b/FlipcashTests/Concurrency/LiveMintDataStreamerStressTests.swift new file mode 100644 index 000000000..9be284878 --- /dev/null +++ b/FlipcashTests/Concurrency/LiveMintDataStreamerStressTests.swift @@ -0,0 +1,73 @@ +// +// LiveMintDataStreamerStressTests.swift +// FlipcashTests +// +// TSan + Main Thread Checker sentinel for `LiveMintDataStreamer`. +// `LiveMintDataStreamer` is the actor that bridges the bidirectional +// gRPC stream for rates + reserves into the rest of the app. Its public +// mutators (`start`, `stop`, `updateMints`, `ensureConnected`) are +// dispatched as fire-and-forget Tasks from `RatesController`, so they +// can race in production. With TSan and Main Thread Checker both enabled +// on the test scheme, a data race surfaces as a TSan warning, while an +// actor-isolation violation surfaces as a Swift runtime assertion (via +// the actor-data-race-checks frontend flag). +// +// The test uses empty mint sets so `openStream()` early-returns without +// dialing a real gRPC stream — we're stressing the actor's serialization +// model under contention, not the network path. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite( + "LiveMintDataStreamer concurrent access", + .timeLimit(.minutes(1)), + .tags(.concurrency, .stress) +) +struct LiveMintDataStreamerStressTests { + + @Test("Concurrent start/stop/updateMints/ensureConnected do not crash") + @MainActor + func concurrentMutators_doNotCrash() async { + let streamer = Client.mock.createLiveMintDataStreamer( + verifiedProtoService: VerifiedProtoService(store: InMemoryVerifiedProtoStore()) + ) + + await withTaskGroup(of: Void.self) { group in + for i in 0..<50 { + group.addTask { + await streamer.start(mints: []) + await streamer.updateMints([]) + if i.isMultiple(of: 2) { + await streamer.ensureConnected() + } + await streamer.stop() + } + } + } + + // If we got here without TSan warnings or actor-isolation + // assertions, the actor's isolation holds under the burst pattern + // that `RatesController` produces during foreground/background and + // mint-subscription churn. + } + + /// Empty mints early-return out of `openStream()`, so the actor never owns + /// a `pingTimeoutTask` or `reconnectTask` to cancel here. This test only + /// asserts that rapid `start`/`stop` teardown does not crash. + @Test("Cancellation tears down cleanly") + @MainActor + func cancellation_doesNotLeakOrCrash() async { + let streamer = Client.mock.createLiveMintDataStreamer( + verifiedProtoService: VerifiedProtoService(store: InMemoryVerifiedProtoStore()) + ) + + await runCancellationStress { + await streamer.start(mints: []) + await streamer.stop() + } + } +} diff --git a/FlipcashTests/Concurrency/MessagingServiceFanInStressTests.swift b/FlipcashTests/Concurrency/MessagingServiceFanInStressTests.swift new file mode 100644 index 000000000..361d3f6a2 --- /dev/null +++ b/FlipcashTests/Concurrency/MessagingServiceFanInStressTests.swift @@ -0,0 +1,136 @@ +// +// MessagingServiceFanInStressTests.swift +// FlipcashTests +// +// TSan + Main Thread Checker sentinel for the `MessagingService` fan-in +// consumers. `MessagingService` opens the long-lived gRPC message stream +// and fans batches into a `@MainActor`-hopping completion handler. The +// service itself has no test seam (its public methods all dial real gRPC), +// so this suite stresses the hermetic seam directly above it: the +// free-standing `firstPaymentRequest(in:shouldIgnore:)` and +// `pollForGiveRequest(maxAttempts:pollInterval:fetch:)` consumers in +// `Client+Messaging.swift`. Those are the layer that actually decides what +// callers see when batches arrive, so they're where double-delivery or +// stalls would surface in production. +// +// With TSan and Main Thread Checker both enabled on the test scheme, races +// in either consumer surface as TSan warnings. Single-delivery is +// structural in `firstPaymentRequest` (it returns on first match), so +// these tests target crash/leak/hang under contention rather than +// asserting an exactly-once property the helper already guarantees. +// + +import Foundation +import Testing +@testable import FlipcashCore + +@Suite( + "MessagingService fan-in", + .timeLimit(.minutes(1)), + .tags(.concurrency, .stress) +) +struct MessagingServiceFanInStressTests { + + private let testAccount = PublicKey.jeffy + private let testSignature: Signature = .mock + + // MARK: - firstPaymentRequest - + + /// Many producers yield into the same `AsyncThrowingStream` while one + /// consumer awaits via `firstPaymentRequest`. The consumer must return + /// the first matching request without crashing or hanging when the + /// producers all push payment-request batches at once. + @Test("50 concurrent producers — first-match returns once") + func firstPaymentRequest_concurrentYields_returnsFirstMatchUnderContention() async throws { + let (stream, continuation) = AsyncThrowingStream<[StreamMessage], Error>.makeStream() + + let consumer = Task { + try await firstPaymentRequest(in: stream, shouldIgnore: { false }) + } + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<50 { + group.addTask { + continuation.yield([ + StreamMessage( + id: ID(data: Data([UInt8.random(in: 0...255)])), + kind: .paymentRequest( + PaymentRequest( + account: self.testAccount, + signature: self.testSignature + ) + ) + ) + ]) + } + } + } + + // The helper returns on the first matching batch (`for try await ... + // return`), so single-delivery is structural rather than asserted + // here — we're just checking the returned account survives contention. + // Remaining yields are absorbed by the stream buffer and discarded + // when we finish below. + let request = try await consumer.value + #expect(request.account == testAccount) + + continuation.finish() + } + + /// Cancelling the consuming task while producers are still yielding + /// must not crash, leak, or block the producers. + @Test("Cancellation during concurrent yields tears down cleanly") + func firstPaymentRequest_cancellationDuringYields_doesNotCrash() async throws { + let (stream, continuation) = AsyncThrowingStream<[StreamMessage], Error>.makeStream() + + let producers = Task { + await withTaskGroup(of: Void.self) { group in + for _ in 0..<200 { + group.addTask { + continuation.yield([ + StreamMessage( + id: ID(data: Data([UInt8.random(in: 0...255)])), + kind: .paymentRequest( + PaymentRequest( + account: self.testAccount, + signature: self.testSignature + ) + ) + ) + ]) + } + } + } + } + + // Single-iteration stress: the consumer is a one-shot await, so we + // run it once inside the helper which adds the warmup before cancel. + await runThrowingCancellationStress(iterations: 1) { [stream] in + _ = try await firstPaymentRequest(in: stream, shouldIgnore: { true }) + } + + continuation.finish() + await producers.value + } + + // MARK: - pollForGiveRequest - + + /// Cancelling a poller mid-flight must abort its `Task.sleep` and exit + /// without crashing. `pollForGiveRequest` calls `Task.checkCancellation` + /// between attempts, so cancellation should propagate as a thrown + /// `CancellationError` (or as a `Task.sleep` cancellation) rather than + /// silently completing or hanging. + @Test("Cancellation tears down cleanly") + func pollForGiveRequest_cancellation_doesNotLeakOrCrash() async { + // Single-iteration stress: the poller is one long-running call that + // sleeps between attempts, so we run it once inside the helper which + // adds the warmup before cancel. + await runThrowingCancellationStress(iterations: 1) { + _ = try await pollForGiveRequest( + maxAttempts: 1_000, + pollInterval: .milliseconds(10), + fetch: { [] } + ) + } + } +} diff --git a/FlipcashTests/Concurrency/RatesControllerStressTests.swift b/FlipcashTests/Concurrency/RatesControllerStressTests.swift new file mode 100644 index 000000000..f61e4d224 --- /dev/null +++ b/FlipcashTests/Concurrency/RatesControllerStressTests.swift @@ -0,0 +1,75 @@ +// +// RatesControllerStressTests.swift +// FlipcashTests +// +// TSan + Main Thread Checker sentinel for `RatesController`. +// `RatesController` is `@MainActor @Observable` and drives `updateRates` +// from the `verifiedProtoService.ratesPublisher` chain. +// +// The race shape stresses two paths: (1) main-actor `updateRates(_:)` +// writes interleaving with main-actor `cachedRates` reads, and (2) the +// off-main `rateWriteQueue.async` block inside `updateRates` capturing +// `database` concurrent with the main-actor reader loop. TSan flags any +// unsafe access; `@Observable` registrar machinery is enforced at compile +// time, not exercised here. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite( + "RatesController concurrent updates", + .timeLimit(.minutes(1)), + .tags(.concurrency, .stress) +) +@MainActor +struct RatesControllerStressTests { + + @Test("Concurrent rate updates do not tear when read on main") + func concurrentUpdates_doNotTearValues() async { + let controller = RatesController.mock + + let updater = Task.detached { + for i in 0..<100 { + let rate = Rate(fx: Decimal(i + 1) / 100, currency: .usd) + await MainActor.run { + controller.updateRates([rate]) + } + } + } + + for _ in 0..<100 { + _ = controller.rate(for: .usd) + _ = controller.cachedRates.count + await Task.yield() + } + + await updater.value + + // Drain the background SQLite write queue so the controller can + // tear down without a write outliving the test. + await controller.awaitPendingRateWrites() + } + + /// Cancelling the writer mid-flight while a reader is still iterating + /// must not crash, leak, or strand work on the SQLite write queue. + @Test("Cancellation tears down cleanly") + func cancellation_doesNotLeakOrCrash() async { + let controller = RatesController.mock + + await runCancellationStress { + // Detach to keep the off-main → main hop shape: writes originate + // off-main and rendezvous on the controller's MainActor isolation. + await Task.detached { + let rate = Rate(fx: 0.01, currency: .usd) + await MainActor.run { + controller.updateRates([rate]) + } + }.value + } + + await controller.awaitPendingRateWrites() + } +} diff --git a/FlipcashTests/Concurrency/StressTestSupport.swift b/FlipcashTests/Concurrency/StressTestSupport.swift new file mode 100644 index 000000000..d985446be --- /dev/null +++ b/FlipcashTests/Concurrency/StressTestSupport.swift @@ -0,0 +1,49 @@ +// +// StressTestSupport.swift +// FlipcashTests +// + +import Testing +import Foundation + +/// Run a body in a Task that cancels after a brief warmup, so cancellation +/// can land mid-flight rather than before the runtime has scheduled the +/// first iteration. Returns once the task completes (or its cancellation +/// propagates through whatever its body awaits). +/// +/// The warmup window is short enough that fast machines still observe at +/// least one iteration before cancel, and slow machines still serialize. +@Sendable +func runCancellationStress( + iterations: Int = 1_000, + warmup: Duration = .milliseconds(5), + body: @escaping @Sendable () async -> Void +) async { + let task = Task { + for _ in 0.. Void +) async { + let task = Task { + for _ in 0.. PublicKey { + var bytes = [Byte](repeating: 0, count: 32) + let value = UInt32(truncatingIfNeeded: index) + bytes[28] = Byte(truncatingIfNeeded: value) + bytes[29] = Byte(truncatingIfNeeded: value >> 8) + bytes[30] = Byte(truncatingIfNeeded: value >> 16) + bytes[31] = Byte(truncatingIfNeeded: value >> 24) + return try! PublicKey(bytes) + } } diff --git a/FlipcashTests/TestSupport/RatesController+TestSupport.swift b/FlipcashTests/TestSupport/RatesController+TestSupport.swift index 7763082e8..89ab125cb 100644 --- a/FlipcashTests/TestSupport/RatesController+TestSupport.swift +++ b/FlipcashTests/TestSupport/RatesController+TestSupport.swift @@ -11,6 +11,7 @@ import FlipcashCore extension RatesController { /// Configure balance currency and inject rates for tests. + @MainActor func configureTestRates(balanceCurrency: CurrencyCode? = nil, rates: [Rate]) { if let balanceCurrency { self.balanceCurrency = balanceCurrency @@ -24,6 +25,7 @@ extension RatesController { /// to avoid blocking the main thread on I/O, so tests that read from /// the database immediately after calling `updateRates` need to drain /// the queue first. + @MainActor func awaitPendingRateWrites() async { await withCheckedContinuation { continuation in rateWriteQueue.async { diff --git a/FlipcashTests/TestSupport/Tags+TestSupport.swift b/FlipcashTests/TestSupport/Tags+TestSupport.swift new file mode 100644 index 000000000..e7bdf9253 --- /dev/null +++ b/FlipcashTests/TestSupport/Tags+TestSupport.swift @@ -0,0 +1,17 @@ +// +// Tags+TestSupport.swift +// FlipcashTests +// + +import Testing + +extension Tag { + /// Marks a test as exercising actor isolation, race detection, or + /// other concurrency-correctness properties. Useful for filtering + /// these tests under TSan + Main Thread Checker in CI. + @Tag static var concurrency: Self + + /// Marks a stress test that runs a large number of operations to + /// exercise contention. Pair with `.concurrency` when applicable. + @Tag static var stress: Self +} diff --git a/FlipcashTests/VerifiedProtoServiceTests.swift b/FlipcashTests/VerifiedProtoServiceTests.swift index a033bde0d..2f61f34e4 100644 --- a/FlipcashTests/VerifiedProtoServiceTests.swift +++ b/FlipcashTests/VerifiedProtoServiceTests.swift @@ -7,7 +7,10 @@ import Foundation import Testing -import Combine +// SAFETY: See RatesController.swift's import for the rationale — +// PassthroughSubject isn't Sendable upstream, and publishers route +// through .receive(on: DispatchQueue.main) before any state mutation. +@preconcurrency import Combine import FlipcashCore import FlipcashAPI diff --git a/FlipcashTests/WalletConnectionStateTests.swift b/FlipcashTests/WalletConnectionStateTests.swift index 47f329473..15fd049ea 100644 --- a/FlipcashTests/WalletConnectionStateTests.swift +++ b/FlipcashTests/WalletConnectionStateTests.swift @@ -13,13 +13,13 @@ import SolanaSwift @Suite("WalletConnection state machine") struct WalletConnectionStateTests { - private static let buyingContext = ExternalSwapProcessing( + nonisolated private static let buyingContext = ExternalSwapProcessing( swapId: .generate(), currencyName: "Test Coin", amount: ExchangedFiat.mockOne ) - private static let launchingContext = ExternalLaunchProcessing( + nonisolated private static let launchingContext = ExternalLaunchProcessing( swapId: .generate(), launchedMint: .jeffy, currencyName: "New Coin", diff --git a/FlipcashTests/WalletProcessingStateTests.swift b/FlipcashTests/WalletProcessingStateTests.swift index 53730176a..37924a5ea 100644 --- a/FlipcashTests/WalletProcessingStateTests.swift +++ b/FlipcashTests/WalletProcessingStateTests.swift @@ -7,16 +7,21 @@ import Testing import FlipcashCore @testable import Flipcash +@MainActor @Suite("WalletProcessingState") struct WalletProcessingStateTests { - private static let buyingContext = ExternalSwapProcessing( + // nonisolated: referenced from @Test(arguments:) array literals which + // must evaluate at type init, before MainActor context is available. + nonisolated private static let buyingContext = ExternalSwapProcessing( swapId: .generate(), currencyName: "Test Coin", amount: ExchangedFiat.mockOne ) - private static let launchingContext = ExternalLaunchProcessing( + // nonisolated: referenced from @Test(arguments:) array literals which + // must evaluate at type init, before MainActor context is available. + nonisolated private static let launchingContext = ExternalLaunchProcessing( swapId: .generate(), launchedMint: .jeffy, currencyName: "New Coin",