iOS App
Overview
The LightChallenge iOS app is a full-featured SwiftUI fitness challenge client targeting iOS 17+. Users can discover challenges, join with an on-chain stake, have proof submitted automatically during the proof window, and claim rewards — all from their iPhone.
Key capabilities:
- Fitness challenges — full mobile participation: join, auto-proof (Apple Health / Strava / Fitbit / Garmin), claim rewards.
- Gaming challenges — discovery and browsing only. Gaming cards display a “Desktop Only” badge; tapping opens a desktop-handoff modal.
- 4-tab layout — Explore, Challenges, Achievements, Profile.
- Wallet integration — Reown (WalletConnect v2) for on-chain transactions.
- HealthKit — reads 7 fitness metrics for the exact challenge period and submits them as evidence.
- OAuth account linking — Strava, Fitbit, and Garmin via
ASWebAuthenticationSession. - Deep links — challenge invites and OAuth callbacks.
- Notifications — APNS push registration with server-synced notification feed.
- Offline support — file-based JSON cache for challenge data.
Architecture
Source Tree
Sources/
├── LightChallengeApp.swift # @main entry point, deep link routing, service init
├── Info.plist # App transport, HealthKit usage descriptions
├── LightChallengeApp.entitlements # HealthKit, app groups
│
├── Models/
│ ├── Challenge.swift # ChallengeCategory, ChallengeMeta, MyChallenge
│ ├── Contracts.swift # LightChain config, contract addresses, ABI constants
│ ├── Models.swift # HealthKit data models (DailySteps, DailyDistance, etc.), ServerConfig
│ └── Templates.swift # Fitness challenge templates (steps, running, cycling, etc.)
│
├── Services/
│ ├── ABIEncoder.swift # Minimal Solidity ABI encoder for contract calls
│ ├── APIClient.swift # Network layer — challenge list, meta, activity, evidence
│ ├── AppState.swift # Persisted prefs (UserDefaults), navigation state, deep link target
│ ├── AutoProofService.swift # Automatic proof collection + submission during proof window
│ ├── AvatarService.swift # Avatar local cache + server sync
│ ├── CacheService.swift # File-based JSON cache for offline challenge data
│ ├── ContractService.swift # Typed contract interactions (create, join, claim, finalize)
│ ├── HealthKitService.swift # HealthKit authorization, data collection, evidence submission
│ ├── NotificationService.swift # APNS registration, notification feed management
│ ├── OAuthService.swift # Strava/Fitbit/Garmin OAuth linking via ASWebAuthenticationSession
│ └── WalletManager.swift # Reown AppKit wallet connection (WalletConnect v2)
│
├── Theme/
│ └── DesignTokens.swift # LC design system: colors, spacing, typography, adaptive surfaces
│
└── Views/
├── MainTabView.swift # Root TabView — Explore | Challenges | Achievements | Profile
├── ContentView.swift # Legacy root (pre-tab migration)
│
├── Onboarding/
│ ├── OnboardingView.swift # First-run onboarding flow
│ └── SplashPortal.swift # Animated splash screen
│
├── Explore/
│ ├── ExploreView.swift # Challenge discovery — sectioned: Featured, Fitness, Trending, Gaming
│ └── ChallengeRow.swift # Individual challenge card in explore lists
│
├── Challenges/
│ └── ChallengesView.swift # "My Challenges" — joined/created challenges
│
├── Detail/
│ ├── ChallengeDetailView.swift # Full challenge detail with lifecycle-aware CTAs
│ ├── FitnessProofView.swift # Fitness proof submission UI
│ ├── GamingHandoffView.swift # Desktop-only modal for gaming challenges
│ └── VictoryCelebrationView.swift # Animated celebration on challenge pass
│
├── Achievements/
│ ├── AchievementsView.swift # Trophy/badge collection
│ ├── AchievementShareCard.swift # Shareable achievement card
│ └── ChallengeShareCard.swift # Shareable challenge completion card
│
├── Activity/
│ └── MyActivityView.swift # Activity feed / history
│
├── Claims/
│ └── ClaimsView.swift # Claim reward interface (winner/loser/refund)
│
├── Create/
│ └── CreateChallengeView.swift # On-chain challenge creation (fitness templates)
│
├── Leaderboard/
│ └── LeaderboardView.swift # Per-challenge leaderboard
│
├── Library/
│ └── ProofSelectionView.swift # Proof method picker (Apple Health, Strava, manual)
│
├── Notifications/
│ └── NotificationsView.swift # Notification center
│
├── Profile/
│ └── ProfileView.swift # User profile, linked accounts, settings entry
│
├── Settings/
│ ├── SettingsView.swift # App settings (server URL, lookback, preferences)
│ ├── AvatarView.swift # Avatar display component
│ └── AvatarPickerView.swift # Avatar selection / upload
│
└── Wallet/
└── WalletSheet.swift # Wallet connection sheet (Reown AppKit)Key Services
| Service | Role |
|---|---|
| AppState | Singleton @MainActor ObservableObject. Stores wallet address, server URL, onboarding flag, tab selection, and deep-link targets. Persisted via @AppStorage (UserDefaults). |
| WalletManager | Wraps Reown AppKit (WalletConnect v2). Handles wallet pairing, transaction signing, deep-link callbacks. Uses a native URLSessionWebSocketTask factory (no Starscream dependency). Constructs raw WalletConnect Request objects with explicit gasPrice and array-wrapped params for custom chain compatibility. |
| HealthKitService | Requests read-only authorization for 7 HealthKit quantity types. Collects daily aggregates for a specific date range and submits them as multipart/form-data to /api/aivm/intake. |
| AutoProofService | Orchestrates automatic proof submission during the proof window. Tries server-side auto-proof first (Strava/Fitbit), then Apple Health local collection, then prompts for HealthKit access. |
| ContractService | Encodes ABI calldata via ABIEncoder and sends transactions through WalletManager. Supports createChallenge, joinChallengeNative, claim eligibility checks (winner/loser/refund/treasury), and claim execution. After on-chain creation, saves challenge metadata to the API using tx-receipt-based authentication. |
| OAuthService | Manages Strava, Fitbit, and Garmin account linking via ASWebAuthenticationSession. Detects installed fitness apps for native deep-link OAuth flows. |
| APIClient | Swift actor for network requests. Fetches challenge lists, metadata, activity, and evidence. Configurable base URL via ServerConfig. |
| CacheService | File-based JSON caching in the app’s Caches directory (1-hour TTL). Enables offline browsing of previously loaded challenge data. |
| NotificationService | APNS push registration, permission management, server-side notification feed fetch, unread badge count. |
| AvatarService | Avatar image persistence (local file + server sync). Local cache for offline access; server is source of truth. |
Challenge Lifecycle (iOS)
The app guides users through the full challenge lifecycle with context-aware UI:
1. Join Window
The Explore tab shows fitness challenges in sectioned layout (Featured, Fitness, Trending, Gaming).
Each fitness challenge card displays a “Join” CTA. Tapping opens ChallengeDetailView, where the user
stakes LCAI tokens via ContractService.joinChallengeNative().
Gaming challenges appear with a “Desktop Only” badge. Tapping opens GamingHandoffView with
instructions to complete the challenge on the web app.
2. Challenge Period (Active Progress Sync)
After joining, the detail view shows “Challenge In Progress” with a countdown timer to the end date.
The user completes the fitness activity (steps, running, cycling, etc.) during this period.
The Challenges tab (ChallengesView) lists all joined/created challenges with live status.
Apple Health progress is pushed automatically every ~15 minutes while the app is open.
AutoProofService.syncActiveProgress() collects HealthKit data from the challenge start to the current time
and uploads it to /api/aivm/intake. This keeps the progress ring on the challenge detail view up to date
without waiting for the proof window.
Server-side providers (Strava, Fitbit, OpenDota, Riot, FACEIT) are synced by the backend progressSyncWorker
every 15 minutes — the iOS app does not need to take any action for these.
3. Proof Window
Once the challenge period ends (endTs < now < proofDeadlineTs), AutoProofService triggers automatically:
| Priority | Strategy | Mechanism |
|---|---|---|
| 1 | Server-side auto-proof | POST /api/challenge/{id}/auto-proof — works for Strava/Fitbit accounts linked via OAuth. Server fetches activity data for the challenge period. |
| 2 | Apple Health local collection | HealthKit reads data for exactly the challenge period (startDate to endDate), packages it as JSON, and uploads via multipart/form-data to POST /api/aivm/intake. |
| 3 | Manual fallback | If no platform is available, the user is prompted to grant HealthKit access or connect a fitness platform. |
4. Verdict and Claim
After evidence submission, the detail view shows phase-aware status:
| Phase | Evidence Status | User State | UI Display |
|---|---|---|---|
| Active / Proof Window | Submitted, no verdict | awaitingVerdict | ”Verifying — AI verification in progress” (pulsing hourglass) |
| Ended / Finalized | Submitted, no verdict | submitted | ”Evidence Submitted — Awaiting on-chain finalization” (blue checkmark) |
| Any | Verdict: pass | completed | ”Challenge Passed” + Claim Reward button |
| Any | Verdict: fail | failed | ”Challenge Failed” + verdict reasons (bullet list) |
On pass, a “Claim Reward” button appears, which calls ContractService to execute the on-chain claim.
A VictoryCelebrationView animation plays on successful claims.
When a challenge fails, the detail view displays up to 3 verdict reasons from the evaluator (e.g. “Did not meet step threshold”, “Insufficient activity days”) so users understand why.
Auto-Proof System
AutoProofService is a @MainActor singleton that manages both active-period progress sync and proof window auto-submission.
ProofStatus Enum
The service tracks per-challenge status via @Published var status: [String: ProofStatus]:
| Status | Description | UI Icon |
|---|---|---|
syncing | Active-period progress sync in progress | Rotating arrows |
synced | Progress pushed to server | Cloud checkmark |
pending | Queued for auto-submission | Rotating arrows |
collectingHealth | Reading Apple Health data | Rotating arrows |
submitting | Uploading evidence to server | Rotating arrows |
submitted | Evidence successfully submitted | Hourglass |
evaluating | Server-side evaluation in progress | Hourglass |
passed | Verdict: challenge passed | Green checkmark seal |
failed | Verdict: challenge failed | Red X seal |
waitingForWindow | Challenge period has not ended yet | Clock badge |
error(String) | Submission failed with message | Warning triangle |
Two Modes of Operation
1. Active-Period Progress Sync (during challenge: startTs <= now < endTs)
syncActiveProgress()pushes Apple Health data every ~15 minutes per challengesyncActiveChallenges()batch-syncs all joined active challenges on app launch / foreground- Collects HealthKit data from challenge start to NOW (partial period)
- Uploads to
/api/aivm/intake— server upserts evidence for the(challenge, subject, apple)triple - Throttled to once per 15 minutes per challenge to avoid excessive uploads
- Non-critical: failures silently fall back to
waitingForWindow
2. Proof Window Auto-Submission (after challenge ends: endTs <= now < proofDeadlineTs)
triggerAutoProof()is called when the user views a challenge in the proof windowcheckPendingChallenges()batch-checks all joined challenges on app launch / foreground- Only triggers for fitness challenges (or unknown category). Gaming challenges are skipped.
- Skips challenges that already have evidence or a verdict.
- Allows retry from
errororwaitingForWindowstates.
Submission Flow
triggerAutoProof(challengeId, challenge, appState, healthService)
│
├─ isInActivePeriod? (startTs <= now < endTs)
│ └─ Yes → syncActiveProgress() → push HealthKit data (start → now)
│
├─ isInProofWindow? (endTs <= now < proofDeadlineTs)
│ └─ No → status = .waitingForWindow
│
└─ Yes → autoSubmit()
│
├─ Strategy 1: tryServerAutoProof()
│ POST /api/challenge/{id}/auto-proof { subject: wallet }
│ └─ "collected" or "already-submitted" → status = .submitted ✓
│
├─ Strategy 2: submitViaAppleHealth()
│ HealthKit collectEvidence(from: startDate, to: endDate)
│ POST /api/aivm/intake (multipart/form-data)
│ └─ ok=true → status = .submitted ✓
│
└─ Strategy 3: Request HealthKit auth → retry Strategy 2
└─ No platform → status = .error("Connect a fitness platform")HealthKit Integration
Supported Metrics
| Metric | HKQuantityType | Unit | Model Field |
|---|---|---|---|
| Steps | stepCount | count | DailySteps.steps |
| Walking/Running Distance | distanceWalkingRunning | meters | DailyDistance.distanceMeters |
| Cycling Distance | distanceCycling | meters | DailyCyclingDistance.distanceMeters |
| Swimming Distance | distanceSwimming | meters | DailySwimmingDistance.distanceMeters |
| Active Energy | activeEnergyBurned | kcal | DailyActiveEnergy.kilocalories |
| Heart Rate | heartRate | bpm | DailyHeartRate.avgBpm / minBpm / maxBpm |
| Flights Climbed | flightsClimbed | count | DailyFlightsClimbed |
Wearable Integration via HealthKit
Many fitness wearables sync their data to Apple Health automatically via their companion iOS app:
| Wearable | Companion App | HealthKit Sync |
|---|---|---|
| Garmin | Garmin Connect | Enable in Garmin Connect → Settings → Health → Apple Health |
| Fitbit | Fitbit app | Enable in Fitbit → Account → Connected Apps → Apple Health |
| Whoop | Whoop app | Enable in Whoop → Settings → Health → Apple Health |
| Samsung Galaxy Watch | Samsung Health | Enable in Samsung Health → Settings → Apple Health |
| Polar | Polar Flow | Enable in Polar Flow → Settings → Apple Health |
Once HealthKit sync is enabled, the data flows automatically:
Wearable → Companion App → Apple Health → AutoProofService → LightChallenge ServerNo manual export needed. The app shows an onboarding tip on the challenge detail view reminding users to enable HealthKit sync in their wearable’s companion app.
Access Model
- Read-only — no write permissions are requested.
- No clinical data — only fitness quantity types.
- Daily aggregates only — no raw samples, timestamps, or source device identifiers leave the device.
Date-Range Collection
HealthKitService provides two collection modes:
- Challenge-period collection (primary):
collectEvidence(from: challengeStart, to: challengeEnd)— collects data for exactly the challenge duration. - Lookback collection (legacy fallback):
collectEvidence(days: 90)— used only when challenge dates are unavailable.
All 7 metrics are fetched concurrently via async let.
Submission Format
Evidence is submitted as multipart/form-data to POST /api/aivm/intake with:
challengeId— the challenge identifiersubject— the user’s wallet addressmodelHash— the AIVM model hash for the challenge typejson— JSON payload containing daily aggregate arrays
Wallet Integration
Reown AppKit (WalletConnect v2)
WalletManager wraps the Reown AppKit SDK for wallet pairing and transaction signing:
- Project ID: Shared with the webapp (
LightChain.walletConnectProjectId) - Chain: LightChain Testnet v2 (chain ID 8200, RPC
https://rpc.testnet.lightchain.ai) - Native WebSocket: Uses
URLSessionWebSocketTaskdirectly (no Starscream dependency) - Deep link handling: Processes
wc:protocol links for wallet pairing callbacks
On-Chain Operations
ContractService + ABIEncoder handle all on-chain interactions:
| Operation | Contract | Function |
|---|---|---|
| Create challenge | ChallengePay | createChallenge(kind, currency, token, stakeAmount, ...) |
| Join challenge | ChallengePay | joinNative(challengeId) with stake value |
| Check claim eligibility | ChallengePay | claimWinner / claimLoser / claimRefund eligibility |
| Execute claim | ChallengePay / Treasury | Claim transaction via wallet |
Transaction Construction (Custom Chain Compatibility)
WalletManager.sendTransaction() bypasses the Reown SDK’s W3MJSONRPC.eth_sendTransaction helper
and constructs raw WalletConnect Request objects directly. This is required because:
- Array-wrapped params — MetaMask expects
eth_sendTransactionparams as[{txObj}](JSON-RPC spec), but the SDK sends them as a flat dictionary. - Explicit gasPrice — LightChain (chain ID 8200) uses legacy gas pricing (no EIP-1559). Without an
explicit
gasPrice, MetaMask fails with “Cannot convert undefined value to object”. - Gas estimation — Each transaction includes an
eth_estimateGasRPC call with a 20% buffer, plus aneth_gasPriceRPC call for the current gas price.
API Authentication (Mobile)
Mobile clients authenticate API writes using tx-receipt verification as a fallback
when EIP-191 signature auth is not available (WalletConnect doesn’t support personal_sign
after a transaction without a second wallet interaction).
The server verifies the on-chain transaction receipt:
- Fetches the receipt from LightChain RPC
- Confirms
status == 0x1(success) - Verifies
frommatches the claimed wallet address - Optionally verifies
tomatches the ChallengePay contract
This applies to POST /api/challenges (metadata save after creation) and
POST /api/challenge/{id}/participant (join recording).
Contract Addresses (Testnet)
| Contract | Address |
|---|---|
| ChallengePay | 0xE7Bf2F9ff9e3C8e45e9B0596a03a758509aF4F9E |
| Treasury | 0x08BA527C65FeD4653E8569fd26C582A72F4157d8 |
| MetadataRegistry | 0x05e6D576C22BaB04EdF57e29a996F1e056b5136a |
| ChallengeTaskRegistry | 0xc7C5F8f498158b2cdeaA9c91CBf79AE9d4251991 |
| PoI Verifier | 0xc138BD35B6c80E29b1171bC4d5DDB3853DE9F0F2 |
| EventRouter | 0x01dD50209139519B64A78D1de8afEaA121BFEeb2 |
| TrustedForwarder | 0x203aBbb9ed66fFAf868a50f4813d4A70B5519F96 |
| AIVM Inference V2 | 0x2d499C52312ca8F0AD3B7A53248113941650bA7E |
Platform Rules
| Category | iOS Support | Proof Submission | Notes |
|---|---|---|---|
| Fitness | Full participation | Auto-proof (Apple Health, Strava, Fitbit, Garmin) | Join, track, prove, claim — all on mobile |
| Gaming | Discovery only | Not available | Cards show “Desktop Only” badge |
| Social | Discovery only | Not available | Desktop handoff |
| Custom | Discovery only | Not available | Desktop handoff |
When a user taps a gaming challenge card, ExploreView presents GamingHandoffView — a modal
explaining that gaming challenges require a desktop browser and providing a link to the web app.
Deep Links
The app registers the lightchallengeapp:// URL scheme and handles two link patterns:
Challenge Invite
lightchallengeapp://challenge/{id}?subject={wallet}&token={token}&expires={expiry}| Parameter | Description |
|---|---|
id | Challenge ID |
subject | Wallet address (0x...) |
token | EIP-191 evidence signature from the webapp |
expires | Token expiry timestamp (Unix ms) |
On receipt, AppState.handleDeepLink() sets deepLinkChallengeId, and the app navigates to the
challenge detail view. The token and expiry are passed to HealthKitService for authenticated
evidence submission.
OAuth Callback
lightchallengeapp://auth/callback?provider={strava|fitbit}&status=okReturned after completing an OAuth flow in ASWebAuthenticationSession. OAuthService processes
the callback and refreshes linked account status.
Universal Links
The app also supports universal links:
https://uat.lightchallenge.app/challenge/{id}?subject={wallet}Setup and Build
Requirements
- Xcode 15.0 or later
- iOS 17.0+ deployment target
- Swift 5.9
- Physical iPhone — HealthKit is not available in the iOS Simulator
- Apple Developer account — free tier works for personal testing
Project Generation
The project uses XcodeGen with project.yml:
cd mobile/ios/LightChallengeApp
xcodegen generateThis regenerates LightChallengeApp.xcodeproj from the declarative spec. Key settings from project.yml:
- Bundle ID:
io.lightchallenge.app - Marketing version:
1.2.0 - Dependencies:
ReownAppKit(fromreown-swiftpackage) - Entitlements: HealthKit access, app groups (
group.io.lightchallenge.app)
Server URL Configuration
Edit Sources/Models/Models.swift and set the appropriate base URL in ServerConfig:
| Environment | URL |
|---|---|
| UAT (default) | https://uat.lightchallenge.app |
| Production | https://app.lightchallenge.app |
| Local dev | http://{YOUR_MAC_IP}:3000 |
The server URL can also be changed at runtime in Settings within the app.
Signing
- Open
LightChallengeApp.xcodeprojin Xcode - Select the project in the navigator
- Go to Signing & Capabilities
- Select your development team (automatic signing is preconfigured)
- Xcode manages provisioning profiles automatically
Build
From the command line:
xcodebuild \
-project mobile/ios/LightChallengeApp/LightChallengeApp.xcodeproj \
-scheme LightChallengeApp \
-destination 'generic/platform=iOS' \
buildOr in Xcode: select your iPhone as the target device and press Cmd+R.
Design System
The app uses a centralized design token system defined in Theme/DesignTokens.swift (the LC enum):
- Primary accent: Deep electric blue (
#2563EB) - Semantic colors: success (green), danger (red), warning (yellow), info (blue)
- Adaptive surfaces:
LC.pageBg(_:),LC.cardBg(_:), etc. respond to light/darkColorScheme - Dark mode: Slate-based palette (
0F172Apage,1E293Bcards) — follows system appearance
Troubleshooting
| Issue | Solution |
|---|---|
| App won’t install | Trust the developer certificate: Settings > General > VPN & Device Management |
| Network error on submit | Ensure iPhone and server are reachable; check server URL in Settings |
| HealthKit permission denied | Re-enable in Settings > Health > Data Access & Devices > LightChallenge |
| ”No auth token” warning | Open the app via a deep link (QR code from webapp), not directly |
| Deep link doesn’t open app | Rebuild and reinstall the app to re-register the URL scheme |
| Auto-proof shows “Waiting for challenge to end” | The challenge period has not ended yet; proof submission is only available after endTs |
| Auto-proof shows “Connect a fitness platform” | No linked Strava/Fitbit account and HealthKit not authorized; grant HealthKit access or link a platform in Profile |
| Auto-proof shows “Health data collection failed” | HealthKit returned an error; check that Health permissions are granted for all required data types |
| Auto-proof shows “Grant HealthKit access to auto-submit” | Server-side auto-proof requires manual upload but HealthKit is not authorized; open Health app and enable access |
| Wallet connection fails | Ensure you have a WalletConnect-compatible wallet installed (e.g., MetaMask, Rainbow); check network connectivity |
| Transaction fails on join/claim | Ensure your wallet is connected to LightChain Testnet v2 (chain ID 8200) and has sufficient LCAI balance |
| Strava/Fitbit OAuth callback not received | Ensure the lightchallengeapp:// URL scheme is registered; try relaunching the app and re-initiating the OAuth flow |
| Challenge data not loading offline | The cache has a 1-hour TTL; data must have been loaded online at least once |
| XcodeGen errors | Ensure XcodeGen is installed (brew install xcodegen) and run from the mobile/ios/LightChallengeApp/ directory |