This file provides instructions for Claude when working on the Flipcash iOS codebase.
Claude should proactively update this file when discovering critical information that would prevent mistakes or save significant time in future sessions. This includes:
- New hard rules or constraints discovered through errors
- Critical patterns that aren't obvious from the code
- Module boundaries or dependencies that caused issues
- Non-obvious project conventions
Keep this document lean. Only add information that is:
- Not discoverable by reading the code directly
- Would cause errors or significant rework if unknown
- Applies broadly across the project (not one-off edge cases)
When adding new information, place it in the appropriate existing section. Remove outdated information when it no longer applies.
Record analyses and implementation plans in .claude/plans/ when:
- Performing deep-dive analysis of new features or RPC changes
- Planning multi-step implementations
- Documenting architectural decisions
- Investigating complex systems that span multiple files
File naming: YYYY-MM-DD-<topic>.md (e.g., 2025-11-27-swap-rpc-analysis.md)
Purpose: These records allow future sessions to reference prior analysis without re-exploring the codebase. Keep them detailed but focused on actionable information.
Review .claude/reflections/index.md before making changes. This log documents past situations where fixes went off track — over-engineering, breaking existing patterns, or introducing regressions. Reading it helps avoid repeating the same mistakes.
- Understand the context. Take your time to understand how the changes should fit into the complete project. Perhaps a refactor is required. Perhaps the current structure is not ideal. Take your time to indetify this.
- Double-check your work. Verify changes compile and don't break existing functionality.
- Ask clarifying questions. When requirements are ambiguous or something is unclear or can have multiple meanings, don't assume. Ask clarifying questions where needed but try to keep these as concise and as minimal as possible.
- Read the relevant files first - never propose changes to code you haven't read
- Understand the existing patterns and conventions in the current file but also any related or dependant files
- Check module boundaries (see Hard Rules below)
- Consider impact on other parts of the codebase
- Be direct and concise
- When uncertain, say so rather than guessing
- Provide file paths with line numbers when referencing code (e.g.,
Session.swift:326)
Flipcash must NEVER import CodeServices:
// ❌ NEVER do this in Flipcash code
import CodeServices
// ✅ ALWAYS use this instead
import FlipcashCoreFlipcashCore re-exports necessary types. Violating this creates tight coupling with legacy code.
Use Swift Testing, NOT XCTest:
// ❌ WRONG
import XCTest
class MyTests: XCTestCase { ... }
// ✅ CORRECT
import Testing
@Suite struct MyTests { ... }Always prefer switch over if case for enums:
// ❌ BAD: Silent failure if enum changes
guard case .sufficient(let amount) = result else {
showError()
return
}
// ✅ GOOD: Compiler error if enum changes
switch result {
case .sufficient(let amount):
handleSuccess(amount)
case .insufficient(let shortfall):
handleError(shortfall)
}When writing new code or touching isolated screens, prefer modern Swift/SwiftUI APIs. This is a gradual migration — don't refactor working code just to modernize it, but do use modern patterns in net-new or self-contained work.
| Legacy | Modern | Notes |
|---|---|---|
ObservableObject / @Published |
@Observable |
Use @State in views instead of @StateObject |
@EnvironmentObject |
@Environment |
For new dependencies; existing @EnvironmentObject stays until the injected type is migrated |
@AppStorage wrapping UserDefaults manually |
@AppStorage directly |
For simple per-screen preferences |
onChange(of:perform:) (deprecated) |
onChange(of:initial:_:) |
Use initial: true when the handler should also fire on appear |
Existing ObservableObject classes (Session, Client, controllers) stay as-is until their dependents are migrated. A single class must use one system — either ObservableObject with @Published, or @Observable. Mixing causes silent observation failures.
Never modify files generated by FlipcashGen - changes will be overwritten. Instead, update the service files that use the generated code.
Do not modify:
Code/- Legacy Code Wallet (inactive)Flipchat/- Legacy chat app (inactive)PoolControllerand pools-related code - feature is deprecated
Bump SQLiteVersion in Info.plist on every schema change. The app does not run migrations — when the version number increases, the database is deleted and rebuilt from server data on next login (SessionAuthenticator.initializeDatabase). This means:
- Adding/removing tables or columns → bump version
- Changing which table a query reads from → bump version if the old schema can't satisfy the new query
- No migration code needed, but all data must be recoverable from server
All variable data must go in structured metadata. The message string is a constant, free-form description. Two reasons, in order of importance:
- Privacy. The redactors (
PatternRedactor,SensitiveKeyRedactorinFlipcashCore/Sources/FlipcashCore/Logging/Middleware/) only scanentry.metadata. Anything interpolated into the message is written verbatim to the file export, the Bugsnag ring buffer attachment, and OSLog. Putting every variable in metadata means values that look innocent today get the redactor safety net automatically — instead of relying on developers to spot which ones are sensitive. - Queryability. Metadata is structured key=value, so you can
grep owner=or filter by key in a structured log viewer. Interpolated values get baked into a string and lose their key.
// ❌ BAD: leaks the public key in plaintext to every log sink
logger.info("New encryption box, public key: \(box.publicKey.base58)")
// ❌ BAD: even non-sensitive variables don't belong in the message
logger.info("Requested swap of \(amount) for \(token.symbol)", metadata: [
"swapId": "\(swapId.base58)",
])
// ✅ GOOD: message is a constant, every variable is in metadata
logger.info("New encryption box", metadata: ["publicKey": "\(box.publicKey.base58)"])
logger.info("Requested swap", metadata: [
"amount": "\(amount)",
"token": "\(token.symbol)",
"swapId": "\(swapId.base58)",
])Never log proto blobs whole. A naked \(response.tokenAccountInfos) or \(notification) recursively serializes every field, including the base58 ones. Extract the specific diagnostic values you actually need into metadata instead — usually a count, a type, or an error, not the whole record.
Always commit the workspace Package.resolved:
- ✅
Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved- MUST be committed - ❌ Individual package
Package.resolvedfiles - ignored by git
This ensures deterministic builds across all developers and CI systems while minimizing merge conflicts. The workspace Package.resolved is the single source of truth for all dependency versions.
Open Code.xcodeproj in Xcode 16.x. Swift packages resolve automatically on first open. Build and run the Flipcash scheme.
Swift gRPC bindings in FlipcashAPI/Sources/FlipcashAPI/Generated and FlipcashCoreAPI/Sources/FlipcashCoreAPI/Generated are generated from .proto files pulled from the server-protobuf repos. To regenerate:
cd Scripts
./run -a flipcashPayments
./run -a flipcashCore
Each invocation clones the latest .proto files from the upstream repo, replaces the local proto/ directory, and regenerates the Swift code in Generated/.
Required tools (checked by the script; aborts if missing):
protoc—brew install protobufprotoc-gen-swift—brew install swift-protobufprotoc-gen-grpc-swift(version 1.x, not 2.x) —./Scripts/install-grpc-swift-1-plugin.sh
The "Generate Flipcash Services" Xcode scheme wraps these same two commands — use either, they produce the same output.
Never modify files under Generated/ directly — changes will be overwritten on the next regen.
Container (DI)
├── Client (gRPC)
├── FlipClient (Flipcash APIs)
├── AccountManager (Keychain)
└── SessionContainer (when logged in)
├── Session (main state, ObservableObject)
├── RatesController
│ ├── VerifiedProtoService (actor – caches verified exchange rate + reserve state proofs)
│ └── LiveMintDataStreamer (actor – bidirectional streaming for rates/reserves)
├── HistoryController
└── Database (SQLite)
- ViewModels provide over multi-screen flows or complex navigation patterns but not necessary for standalone, self-contained, isolated screens.
- Session is the main state object after authentication
- Controllers handle business logic and data persistence
Two CallOptions presets exist in CodeService.swift — using the wrong one causes subtle bugs:
| Preset | Timeout | Use For | Example RPCs |
|---|---|---|---|
.default |
15 seconds | Unary RPCs (request → response) | fetchMessages, sendMessage, createAccounts |
.streaming |
None | Server-streaming and bidirectional RPCs | openMessageStream, submitIntent, streamLiveMintData, statefulSwap |
The defaultCallOptions on each gRPC client is .default, so unary RPCs get the 15s timeout automatically. Streaming RPCs must explicitly pass callOptions: .streaming — omitting it silently applies the 15s deadline, killing long-lived streams.
// ❌ BAD: Uses default 15s timeout — stream dies silently
let stream = service.openMessageStream(request) { response in ... }
// ✅ GOOD: No timeout — stream stays open, managed by keepalive
let stream = service.openMessageStream(request, callOptions: .streaming) { response in ... }- Quarks - Smallest unit of any currency (like cents for dollars)
- ExchangedFiat - Wraps underlying currency + converted display value
- BondingCurve - Pricing for custom currencies
- AccountCluster - Manages keys per mint
- VerifiedState - Bundles server-signed exchange rate proof (
rateProto) and optional reserve state proof (reserveProto). Required when submitting any payment intent. For launchpad currencies,reserveProtois mandatory — the server rejects intents without it. - SendCashOperation - Orchestrates peer-to-peer transfers via a rendezvous handshake. Has two concurrent paths: Path 1 (advertise bill with verified state) and Path 2 (listen for grab, then transfer). Both paths share a resolved
VerifiedState.
| Technology | Version/Notes |
|---|---|
| Swift | 6.0 (language mode); Xcode toolchain 16.x |
| iOS Minimum | 17.0 |
| UI Framework | SwiftUI (primary), UIKit (AppDelegate, navigation) |
| Testing | Swift Testing (import Testing) |
| Database | SQLite via SQLite.swift (fork, see below) |
| Networking | gRPC via grpc-swift |
| Crypto | Ed25519 via CodeCurves |
Flipcash/ # Main app - focus here
FlipcashCore/ # Business logic, models, clients
FlipcashUI/ # UI components, theme
FlipcashAPI/ # gRPC proto definitions
CodeServices/ # Shared Solana services (don't import in Flipcash)
CodeCurves/ # Ed25519 cryptography
CodeScanner/ # C++/OpenCV circular code scanning (see below)
We use a fork of SQLite.swift (dbart01/SQLite.swift), not the official stephencelis/SQLite.swift. The fork is pinned to master branch and adds two changes on top of the official 0.15.4 base:
- Upsert WHERE clause fix — moves
whereClauseafterDO UPDATE SET(the official repo places it beforeON CONFLICT, producing invalid SQL for filtered upserts liketable.filter(...).upsert(...)) - Custom dispatch queue injection — adds a
queue:parameter toConnection.initso callers can supply their ownDispatchQueue - Public
Setteraccess (pending) —Setter.columnandSetter(excluded:)need to be madepublicso callers can build custom ON CONFLICT SET clauses (e.g.,COALESCE(excluded.column, column)for conditional upserts). SeeDatabase+Balance.swiftTODO.
Do not switch to the official repo without verifying:
- Filtered upserts still generate valid SQL
Connection.init(queue:)is no longer needed- Custom SET clause building still compiles
C++ library for encoding, decoding, and scanning custom circular 2D codes ("Kik Codes"). Uses OpenCV 4.10.0 and a bundled ZXing Reed-Solomon subset.
- Location:
CodeScanner/ - Public API:
CodeScanner/CodeScanner/Code.h(KikCodesclass — encode, decode, scan) - Used by:
CodeExtractor.swift,CashCode.Payload+Encoding.swift - Full spec:
.claude/spec.md(API details, build docs, OpenCV upgrade history) - Updating OpenCV:
cd CodeScanner && ./Scripts/build_opencv.sh --version <version>
- Screens go in
Flipcash/Core/Screens/ - ViewModels are colocated with their screens
- Models go in
FlipcashCore/Sources/FlipcashCore/Models/ - Database models go in
Flipcash/Core/Controllers/Database/Models/
- ViewModels:
{Screen}ViewModel(e.g.,GiveViewModel) - Screens:
{Name}Screen(e.g.,ScanScreen) - Controllers:
{Domain}Controller(e.g.,RatesController)
import SwiftUI // System frameworks first
import FlipcashCore // Then internal packages
import FlipcashUI- Don't add features beyond what was asked
- Don't add error handling for impossible scenarios
- Don't create abstractions for one-time operations
- Don't add comments to code you didn't change
- Three similar lines of code is better than a premature abstraction
import Testing
@testable import Flipcash
@Suite("Session Tests")
struct SessionTests {
@Test("Sufficient funds returns correct amount")
func sufficientFunds() {
// Arrange
let session = makeTestSession()
// Act
let result = session.hasSufficientFunds(for: amount)
// Assert
#expect(result == .sufficient(amount))
}
}Simulator device names and OS versions in test commands change with Xcode releases. Update destinations as new simulators become available.
# Run all tests
xcodebuild test -scheme Flipcash -destination 'platform=iOS Simulator,name=iPhone 17' -testPlan AllTargets
# Run UI tests on current and previous OS
xcodebuild test -scheme Flipcash -only-testing:FlipcashUITests \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' \
-destination 'platform=iOS Simulator,name=iPhone 17'
# Run specific test suite
xcodebuild test -scheme Flipcash -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:FlipcashTests/SessionTests- Use descriptive names that explain the scenario
- Format:
func testMethodName_scenario_expectedResult()or use@Test("description")
NEVER recreate functionality in tests. Always test the actual implementation:
// ❌ BAD: Recreates the logic, proves nothing about the real code
@Test func testTotalBalance() {
let sum = balance1.converted.decimalValue + balance2.converted.decimalValue
let total = Quarks(fiatDecimal: sum, ...)
#expect(total.formatted() == "$8.10") // Tests nothing real
}
// ✅ GOOD: Tests the actual Session.totalBalance implementation
@Test func testTotalBalance() {
let session = makeTestSession(balances: [balance1, balance2])
let total = session.totalBalance
#expect(total.converted.formatted() == "$8.10")
}If the code under test is difficult to call directly, create test support extensions or mock dependencies rather than duplicating the logic.
Keep production code clean - test-only helpers belong in the test target:
// ❌ BAD: Adding #if DEBUG to production code
// Flipcash/Core/Controllers/RatesController.swift
#if DEBUG
func configureTestRates(...) { ... }
#endif
// ✅ GOOD: Extension in test target
// FlipcashTests/TestSupport/RatesController+TestSupport.swift
extension RatesController {
func configureTestRates(...) { ... }
}Place test support extensions in FlipcashTests/TestSupport/ using the naming pattern {Type}+TestSupport.swift.
All tests must work on both Xcode Cloud and locally. Never use APIs that are sandboxed or unavailable on Xcode Cloud:
- ❌
Process/ProcessInfofor shelling out (sandboxed on Xcode Cloud) - ❌
xcrun simctlfrom within tests - ❌ Host-only filesystem access
- ✅
UIPasteboard,XCUIApplication,XCUIElement— standard XCUITest APIs
Every crash fixed from Bugsnag (or similar) gets a dedicated regression test in FlipcashTests/Regressions/.
- One file per incident:
Regression_{bugsnag_id}.swift - Suite name includes the short ID:
@Suite("Regression: {short_id} – {brief description}") - Reproduce the crash path, not just the low-level fix. If the crash came through
EnterAmountCalculator, test throughEnterAmountCalculator.
// FlipcashTests/Regressions/Regression_698ef3b65e6cc4bb5554e13d.swift
@Suite("Regression: 698ef3b – Quarks comparison overflow for high-rate currencies")
struct Regression_698ef3b {
@Test("CLP quarks comparison across 6 and 10 decimal precisions does not overflow")
func quarksComparison_CLP_doesNotOverflow() { ... }
}<type>: <short description>
<optional body explaining why>
Types: feat, fix, refactor, test, docs, chore
- Code compiles without errors and no new warnings:
xcodebuild build -scheme Flipcash - Tests pass:
xcodebuild test -scheme Flipcash -destination 'platform=iOS Simulator,name=iPhone 17' -testPlan AllTargets - Review changes with
git diff - Module boundaries respected (no
CodeServicesin Flipcash) - Switch statements are exhaustive (no unnecessary
defaultcases) - Changes are minimal and focused on the task
| Pitfall | Solution |
|---|---|
| Modifying generated proto files | Update service files instead |
| Working on pools/betting code | Feature is deprecated, ignore it |
| Adding unnecessary abstractions | Keep it simple, solve the current problem |
| Completing a transaction without refreshing balances | Call session.updatePostTransaction() after any transaction completes |
Canceling/modifying SendCashOperation in dismissCashBill |
Never explicitly call cancel() or invalidateMessageStream() on SendCashOperation from dismissCashBill. After a grab, the received bill is a live SendCashOperation that others can scan ("quick give and grab" chain). Setting sendOperation = nil is fine (deinit cleans up), but explicit teardown kills a live bill. The operation's complete() method handles stream teardown on success/failure. |
Using default CallOptions for streaming RPCs |
Streaming RPCs (openMessageStream, submitIntent, streamLiveMintData, statefulSwap) must use callOptions: .streaming. The default 15s timeout silently kills long-lived streams. See gRPC Call Options. |
Showing a received bill without verifiedState |
Every call to showCashBill must pass verifiedState — even for received: true bills. The received bill creates a live SendCashOperation for the "quick give and grab" chain. Without verifiedState, launchpad currency transfers fail with "reserve state is required". Both receiveCash (scan) and receiveCashLink (deep link) must provide it. |
matchedGeometryEffect applied after .frame |
.matchedGeometryEffect must come BEFORE .frame in the modifier chain. Wrong order causes hero animations to fail silently: you see two separate views fading in/out at their own static positions instead of one morphing element. Paul Hudson's hackingwithswift example uses the wrong order and does not work on current iOS. Correct: Rectangle().fill(.red).matchedGeometryEffect(id:in:).frame(width:height:). Incorrect: Rectangle().fill(.red).frame(width:height:).matchedGeometryEffect(id:in:). Also note: .transition(.identity) on a parent containing matched views kills the animation entirely — matched geometry needs the parent view to remain in the tree briefly for interpolation, and .identity removes it instantly. |
Session & Auth:
- Flipcash/Core/Session/Session.swift
- Flipcash/Core/Session/SessionAuthenticator.swift
Payments & Operations:
- Flipcash/Core/Screens/Main/Operations/SendCashOperation.swift
- Flipcash/Core/Screens/Main/Operations/ScanCashOperation.swift
- FlipcashCore/Sources/FlipcashCore/Models/VerifiedState.swift
- FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/VerifiedProtoService.swift
Multi-Currency:
- FlipcashCore/Sources/FlipcashCore/Models/Fiat.swift (Quarks)
- FlipcashCore/Sources/FlipcashCore/Models/ExchangedFiat.swift
- FlipcashCore/Sources/FlipcashCore/Models/BondingCurve.swift
Rates & Streaming:
- Flipcash/Core/Controllers/RatesController.swift
- FlipcashCore/Sources/FlipcashCore/Clients/Payments API/Services/LiveMintDataStreamer.swift
Database:
- Flipcash/Core/Controllers/Database/Schema.swift
- Flipcash/Core/Controllers/Database/Database.swift
// USDC
PublicKey.usdc // Main stablecoin mint
PublicKey.usdc.mintDecimals // 6
// Bonding Curve
BondingCurve.startPrice // $0.01
BondingCurve.endPrice // $1,000,000
BondingCurve.maxSupply // 21,000,000 tokensPrefer Xcode MCP tools over xcodebuild shell commands when the Xcode MCP server is available. It provides direct integration with the open Xcode workspace for building, testing, reading/writing project files, rendering SwiftUI previews, and searching Apple documentation.
Fall back to xcodebuild when the MCP server is not connected or when you need CLI-specific options (e.g., -testPlan, -only-testing).
Fallback xcodebuild commands (when MCP is unavailable):
# Build
xcodebuild build -scheme Flipcash -destination 'generic/platform=iOS'
# Test
xcodebuild test -scheme Flipcash -destination 'platform=iOS Simulator,name=iPhone 17'
# Clean
xcodebuild clean -scheme Flipcash