iOS App

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

ServiceRole
AppStateSingleton @MainActor ObservableObject. Stores wallet address, server URL, onboarding flag, tab selection, and deep-link targets. Persisted via @AppStorage (UserDefaults).
WalletManagerWraps 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.
HealthKitServiceRequests 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.
AutoProofServiceOrchestrates 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.
ContractServiceEncodes 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.
OAuthServiceManages Strava, Fitbit, and Garmin account linking via ASWebAuthenticationSession. Detects installed fitness apps for native deep-link OAuth flows.
APIClientSwift actor for network requests. Fetches challenge lists, metadata, activity, and evidence. Configurable base URL via ServerConfig.
CacheServiceFile-based JSON caching in the app’s Caches directory (1-hour TTL). Enables offline browsing of previously loaded challenge data.
NotificationServiceAPNS push registration, permission management, server-side notification feed fetch, unread badge count.
AvatarServiceAvatar 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:

PriorityStrategyMechanism
1Server-side auto-proofPOST /api/challenge/{id}/auto-proof — works for Strava/Fitbit accounts linked via OAuth. Server fetches activity data for the challenge period.
2Apple Health local collectionHealthKit 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.
3Manual fallbackIf 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:

PhaseEvidence StatusUser StateUI Display
Active / Proof WindowSubmitted, no verdictawaitingVerdict”Verifying — AI verification in progress” (pulsing hourglass)
Ended / FinalizedSubmitted, no verdictsubmitted”Evidence Submitted — Awaiting on-chain finalization” (blue checkmark)
AnyVerdict: passcompleted”Challenge Passed” + Claim Reward button
AnyVerdict: failfailed”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]:

StatusDescriptionUI Icon
syncingActive-period progress sync in progressRotating arrows
syncedProgress pushed to serverCloud checkmark
pendingQueued for auto-submissionRotating arrows
collectingHealthReading Apple Health dataRotating arrows
submittingUploading evidence to serverRotating arrows
submittedEvidence successfully submittedHourglass
evaluatingServer-side evaluation in progressHourglass
passedVerdict: challenge passedGreen checkmark seal
failedVerdict: challenge failedRed X seal
waitingForWindowChallenge period has not ended yetClock badge
error(String)Submission failed with messageWarning triangle

Two Modes of Operation

1. Active-Period Progress Sync (during challenge: startTs <= now < endTs)

  • syncActiveProgress() pushes Apple Health data every ~15 minutes per challenge
  • syncActiveChallenges() 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 window
  • checkPendingChallenges() 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 error or waitingForWindow states.

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

MetricHKQuantityTypeUnitModel Field
StepsstepCountcountDailySteps.steps
Walking/Running DistancedistanceWalkingRunningmetersDailyDistance.distanceMeters
Cycling DistancedistanceCyclingmetersDailyCyclingDistance.distanceMeters
Swimming DistancedistanceSwimmingmetersDailySwimmingDistance.distanceMeters
Active EnergyactiveEnergyBurnedkcalDailyActiveEnergy.kilocalories
Heart RateheartRatebpmDailyHeartRate.avgBpm / minBpm / maxBpm
Flights ClimbedflightsClimbedcountDailyFlightsClimbed

Wearable Integration via HealthKit

Many fitness wearables sync their data to Apple Health automatically via their companion iOS app:

WearableCompanion AppHealthKit Sync
GarminGarmin ConnectEnable in Garmin Connect → Settings → Health → Apple Health
FitbitFitbit appEnable in Fitbit → Account → Connected Apps → Apple Health
WhoopWhoop appEnable in Whoop → Settings → Health → Apple Health
Samsung Galaxy WatchSamsung HealthEnable in Samsung Health → Settings → Apple Health
PolarPolar FlowEnable in Polar Flow → Settings → Apple Health

Once HealthKit sync is enabled, the data flows automatically:

Wearable → Companion App → Apple Health → AutoProofService → LightChallenge Server

No 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:

  1. Challenge-period collection (primary): collectEvidence(from: challengeStart, to: challengeEnd) — collects data for exactly the challenge duration.
  2. 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 identifier
  • subject — the user’s wallet address
  • modelHash — the AIVM model hash for the challenge type
  • json — 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 URLSessionWebSocketTask directly (no Starscream dependency)
  • Deep link handling: Processes wc: protocol links for wallet pairing callbacks

On-Chain Operations

ContractService + ABIEncoder handle all on-chain interactions:

OperationContractFunction
Create challengeChallengePaycreateChallenge(kind, currency, token, stakeAmount, ...)
Join challengeChallengePayjoinNative(challengeId) with stake value
Check claim eligibilityChallengePayclaimWinner / claimLoser / claimRefund eligibility
Execute claimChallengePay / TreasuryClaim 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:

  1. Array-wrapped params — MetaMask expects eth_sendTransaction params as [{txObj}] (JSON-RPC spec), but the SDK sends them as a flat dictionary.
  2. 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”.
  3. Gas estimation — Each transaction includes an eth_estimateGas RPC call with a 20% buffer, plus an eth_gasPrice RPC 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:

  1. Fetches the receipt from LightChain RPC
  2. Confirms status == 0x1 (success)
  3. Verifies from matches the claimed wallet address
  4. Optionally verifies to matches the ChallengePay contract

This applies to POST /api/challenges (metadata save after creation) and POST /api/challenge/{id}/participant (join recording).

Contract Addresses (Testnet)

ContractAddress
ChallengePay0xE7Bf2F9ff9e3C8e45e9B0596a03a758509aF4F9E
Treasury0x08BA527C65FeD4653E8569fd26C582A72F4157d8
MetadataRegistry0x05e6D576C22BaB04EdF57e29a996F1e056b5136a
ChallengeTaskRegistry0xc7C5F8f498158b2cdeaA9c91CBf79AE9d4251991
PoI Verifier0xc138BD35B6c80E29b1171bC4d5DDB3853DE9F0F2
EventRouter0x01dD50209139519B64A78D1de8afEaA121BFEeb2
TrustedForwarder0x203aBbb9ed66fFAf868a50f4813d4A70B5519F96
AIVM Inference V20x2d499C52312ca8F0AD3B7A53248113941650bA7E

Platform Rules

CategoryiOS SupportProof SubmissionNotes
FitnessFull participationAuto-proof (Apple Health, Strava, Fitbit, Garmin)Join, track, prove, claim — all on mobile
GamingDiscovery onlyNot availableCards show “Desktop Only” badge
SocialDiscovery onlyNot availableDesktop handoff
CustomDiscovery onlyNot availableDesktop 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.


The app registers the lightchallengeapp:// URL scheme and handles two link patterns:

Challenge Invite

lightchallengeapp://challenge/{id}?subject={wallet}&token={token}&expires={expiry}
ParameterDescription
idChallenge ID
subjectWallet address (0x...)
tokenEIP-191 evidence signature from the webapp
expiresToken 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=ok

Returned after completing an OAuth flow in ASWebAuthenticationSession. OAuthService processes the callback and refreshes linked account status.

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 generate

This regenerates LightChallengeApp.xcodeproj from the declarative spec. Key settings from project.yml:

  • Bundle ID: io.lightchallenge.app
  • Marketing version: 1.2.0
  • Dependencies: ReownAppKit (from reown-swift package)
  • 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:

EnvironmentURL
UAT (default)https://uat.lightchallenge.app
Productionhttps://app.lightchallenge.app
Local devhttp://{YOUR_MAC_IP}:3000

The server URL can also be changed at runtime in Settings within the app.

Signing

  1. Open LightChallengeApp.xcodeproj in Xcode
  2. Select the project in the navigator
  3. Go to Signing & Capabilities
  4. Select your development team (automatic signing is preconfigured)
  5. Xcode manages provisioning profiles automatically

Build

From the command line:

xcodebuild \
  -project mobile/ios/LightChallengeApp/LightChallengeApp.xcodeproj \
  -scheme LightChallengeApp \
  -destination 'generic/platform=iOS' \
  build

Or 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/dark ColorScheme
  • Dark mode: Slate-based palette (0F172A page, 1E293B cards) — follows system appearance

Troubleshooting

IssueSolution
App won’t installTrust the developer certificate: Settings > General > VPN & Device Management
Network error on submitEnsure iPhone and server are reachable; check server URL in Settings
HealthKit permission deniedRe-enable in Settings > Health > Data Access & Devices > LightChallenge
”No auth token” warningOpen the app via a deep link (QR code from webapp), not directly
Deep link doesn’t open appRebuild 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 failsEnsure you have a WalletConnect-compatible wallet installed (e.g., MetaMask, Rainbow); check network connectivity
Transaction fails on join/claimEnsure your wallet is connected to LightChain Testnet v2 (chain ID 8200) and has sufficient LCAI balance
Strava/Fitbit OAuth callback not receivedEnsure the lightchallengeapp:// URL scheme is registered; try relaunching the app and re-initiating the OAuth flow
Challenge data not loading offlineThe cache has a 1-hour TTL; data must have been loaded online at least once
XcodeGen errorsEnsure XcodeGen is installed (brew install xcodegen) and run from the mobile/ios/LightChallengeApp/ directory