Skip to content

TheJASSZ/PulseMesh

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PulseMesh — Smartphone Environmental DePIN

MIT Bitcoin Expo 2026 Virtual Hackathon Submission 🏆

PulseMesh is a Decentralized Physical Infrastructure Network (DePIN) that crowdsources hyper-local environmental data from smartphone sensors. Contributors run a background service that collects barometric pressure, ambient light, and noise levels. Data buyers (hedge funds, agriculture companies, weather services, urban planners, real estate platforms) pay for access to this street-level data via an L402-gated REST API using Bitcoin Lightning micropayments.

Zero hardware required. Every contributor node is a smartphone already in someone's pocket.


🚀 Hackathon Submission Overview (For Judges)

What does it do?

PulseMesh turns any Android smartphone into an environmental sensor node. The app silently samples the phone's built-in barometer, microphone (measuring noise SPL in decibels, not audio), and light sensor every minute. This data is batched, validated, and uploaded to my backend.

Data buyers query the PulseMesh Data Marketplace API to purchase this data. This API is secured behind an Aperture L402 proxy. To query data, buyers must pay an instant, low-fee Bitcoin Lightning invoice. In return, contributors are rewarded directly via their in-app Lightning wallet (powered by the nodeless Breez SDK).

Why does it matter?

Hyper-local environmental data is incredibly valuable for weather forecasting, precision agriculture, and quantitative financial modeling. Traditional DePIN projects require users to purchase expensive, proprietary hardware nodes ($500+). PulseMesh radically lowers the barrier to entry by effectively utilizing the dormant sensors in billions of smartphones globally, offering truly distributed, low-latency, resilient street-level data without friction.

What I built during the Hackathon?

Over the course of the hackathon, I built the Android client, the initial Ktor backend, and a complete Website UI dashboard from scratch:

  1. Website UI Dashboard: A robust web portal for interacting with contributor mobile devices. Through this dashboard, I can easily purchase environmental data from contributors and visualize the data dynamically on an interactive map.
  2. The PulseMesh Android App: Built natively with Kotlin and Jetpack Compose. Includes a custom Foreground Service for ambient sensor sampling at 1-minute intervals, background batch uploading via WorkManager, an integrated Lightning Wallet utilizing the Breez SDK, and an onboard automatic L402 HTTP Interceptor that transparently solves HTTP 402 payment challenges via Lightning when querying the marketplace!
  3. Ktor API Backend: Designed with PostgreSQL, TimescaleDB, and PostGIS for heavy geospatial time-series data aggregation via Uber H3 grid cells.
  4. Sensor Validation Pipeline: A robust 4-stage pipeline (Bounds Check -> Cross-Reference -> Proof of Location -> QoD Scorer) that assigns trust scores to node inputs.
  5. L402 Gateway: Configured and deployed Aperture tied to an LND node to monetize the REST API directly over the Lightning Network.

How do you run it?

See the full Development Setup section below.

  • Open the /android directory in Android Studio Ladybug to compile the app and test the integrated Breez SDK lightning wallet on testnet.
  • Open the /backend directory and deploy the backend environment utilizing docker compose up -d to spool PostgreSQL, TimescaleDB, Redis, LND, and Aperture.

Table of Contents

  1. Product Overview
  2. Target Users
  3. Tech Stack
  4. System Architecture
  5. Android App Architecture
  6. Backend Architecture
  7. L402 Payment Integration
  8. Sensor Data Specification
  9. Data Validation Pipeline
  10. API Contracts
  11. Database Schema
  12. Security and Anti-Gaming
  13. Project Structure
  14. Development Setup
  15. Testing Strategy
  16. Deployment

Product Overview

The Problem

Hyper-local environmental data (street-level barometric pressure, noise pollution, light conditions) is extremely valuable for weather forecasting, real estate valuation, urban planning, logistics routing, and financial modeling. Traditional approaches require deploying expensive dedicated weather stations ($500–$5000 each) or satellite infrastructure. Coverage is sparse — most cities have fewer than 10 official weather stations for millions of residents.

The Solution

PulseMesh turns every Android smartphone into an environmental sensor node. The app runs a lightweight foreground service that reads the phone's built-in barometer, light sensor, and microphone at configurable intervals (default: once per minute). Readings are batched locally, validated, and uploaded to the PulseMesh backend every 5 minutes. Contributors earn Bitcoin (satoshis) via Lightning Network for validated data contributions. Data buyers query the PulseMesh Data Marketplace API, which is gated by the L402 protocol — they pay satoshis per API call and receive a reusable authentication token.

Why These Three Sensors

After extensive research into Android device sensor availability across 2024–2026 flagship and mid-range devices:

  • Barometric pressure (TYPE_PRESSURE): Present on ~80%+ of flagships (Pixel, Samsung Galaxy S series, OnePlus, Xiaomi). Reports hPa with ±0.01 hPa resolution. Extremely low power (~0.006 mW). No runtime permission required. This is PulseMesh's highest-value data source.
  • Ambient light (TYPE_LIGHT): Present on ~99% of phones. Reports lux from 0 to 120,000+. No runtime permission required.
  • Noise level via microphone (AudioRecord): Present on 100% of phones. Requires RECORD_AUDIO runtime permission. I compute A-weighted SPL (dB) from PCM samples — I never record or transmit audio. Requires per-device-model calibration offsets for accuracy.

NOT included (and why):

  • TYPE_AMBIENT_TEMPERATURE: Removed from virtually all phones after Samsung Galaxy S4 (2013). <1% device availability.
  • TYPE_RELATIVE_HUMIDITY: Same as temperature — effectively nonexistent on modern phones.

Target Users

Contributors (Android App Users)

  • Anyone with an Android phone (API 28+ / Android 9+)
  • Earns satoshis for keeping the app running in the background
  • Views a dashboard showing their earnings, contribution history, and a live map of their data points

Data Buyers (API Consumers)

  • Weather forecasting companies
  • Hedge funds / quantitative trading firms (barometric pressure correlates with commodity prices)
  • Real estate platforms (noise pollution data for property valuation)
  • Urban planners and city governments
  • Agriculture / precision farming companies
  • Insurance companies (environmental risk assessment)
  • Autonomous vehicle / drone companies (hyper-local weather)

Tech Stack

Android App

  • Language: Kotlin
  • UI Framework: Jetpack Compose with Material 3
  • Min SDK: 28 (Android 9 Pie)
  • Target SDK: 35 (Android 15)
  • Architecture: MVVM + Clean Architecture (Domain / Data / Presentation layers)
  • DI: Hilt
  • Local Database: Room (SQLite) for sensor data caching and earnings tracking
  • Networking: Retrofit + OkHttp (with custom L402 interceptor)
  • Background Processing: Foreground Service (sensor collection) + WorkManager (data upload)
  • Lightning Wallet: Breez SDK Spark (breez_sdk_spark:bindings-android) — nodeless, non-custodial Lightning
  • Macaroon Handling: jmacaroons (com.github.nitram509:jmacaroons:0.5.0) for L402 token parsing
  • Location: Google Play Services FusedLocationProvider
  • Maps: OpenStreetMap via osmdroid (no Google Maps API key needed)
  • Charts: Vico (Jetpack Compose charting library)
  • Testing: JUnit 5, Mockk, Turbine (Flow testing), Espresso (UI)

Backend

  • Language: Kotlin
  • Framework: Ktor (async, coroutine-native)
  • Database: PostgreSQL 16 + PostGIS (geospatial) + TimescaleDB (time-series hypertables)
  • Cache: Redis 7 (rate limiting, session cache, leaderboard)
  • L402 Gateway: Aperture by Lightning Labs (L402 reverse proxy in front of Data Marketplace API)
  • Lightning Node: LND via Aperture's built-in connection (Aperture connects to LND to generate invoices)
  • Geospatial Indexing: Uber H3 (hexagonal hierarchical spatial index, Resolution 9 for ~0.1 km² cells)
  • Message Queue: Redis Streams (for async validation pipeline)
  • Containerization: Docker + Docker Compose
  • Testing: JUnit 5, Testcontainers, Ktor test host

Infrastructure

  • Reverse Proxy: Nginx (TLS termination, routes to Ktor app and Aperture)
  • CI/CD: GitHub Actions
  • Monitoring: Prometheus + Grafana (backend metrics), Aperture admin API (payment metrics)

System Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                         CONTRIBUTOR FLOW                                │
│                                                                         │
│  ┌──────────────┐    HTTPS/JSON     ┌──────────────────────────────┐   │
│  │  Android App  │ ───────────────> │         Ktor Backend          │   │
│  │              │                   │                                │   │
│  │ Sensors:     │  POST /v1/       │  ┌─────────────┐              │   │
│  │  Barometer   │  readings/batch  │  │  Validation  │              │   │
│  │  Light       │                   │  │  Pipeline    │              │   │
│  │  Microphone  │  GET /v1/        │  └──────┬──────┘              │   │
│  │  GPS         │  contributor/    │         │                      │   │
│  │              │  earnings        │  ┌──────▼──────┐              │   │
│  │ Breez SDK    │                   │  │ TimescaleDB  │              │   │
│  │ Spark        │ <─────────────── │  │ + PostGIS    │              │   │
│  │ (Lightning)  │  Lightning       │  └──────────────┘              │   │
│  └──────────────┘  Payments        │                                │   │
│                                     └──────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                         DATA BUYER FLOW                                 │
│                                                                         │
│  ┌──────────────┐                  ┌──────────┐    ┌───────────────┐   │
│  │  Data Buyer   │  1. Request     │          │    │               │   │
│  │  (API Client) │ ──────────────> │ Aperture │───>│  Ktor Backend │   │
│  │              │  2. 402 + invoice│ (L402    │    │  /v1/data/*   │   │
│  │              │ <────────────── │  Proxy)  │    │  (Marketplace │   │
│  │              │  3. Pay invoice  │          │    │   API)        │   │
│  │              │ ──────────────> │          │    │               │   │
│  │              │  4. Data response│          │    │               │   │
│  │              │ <────────────── │          │<───│               │   │
│  └──────────────┘                  └──────────┘    └───────────────┘   │
│                                         │                               │
│                                    ┌────▼────┐                         │
│                                    │   LND   │                         │
│                                    │  Node   │                         │
│                                    └─────────┘                         │
└─────────────────────────────────────────────────────────────────────────┘

Data Flow (Step by Step)

  1. Collection: Android foreground service reads barometer, light sensor, and microphone every 60 seconds. GPS location is sampled every 5 minutes (or on significant movement >100m).
  2. Local Storage: Each reading is stored in Room DB with timestamp, sensor type, value, lat/lng, accuracy, and device model.
  3. Batching: WorkManager job fires every 5 minutes, pulls un-uploaded readings from Room, packages them into a JSON batch (typically 5 readings per sensor × 3 sensors = 15 readings per batch).
  4. Upload: Batch is POSTed to POST /v1/readings/batch with the contributor's device ID and authentication token.
  5. Validation: Backend runs the validation pipeline (bounds check → cross-reference → PoL scoring → QoD scoring). Invalid readings are rejected; valid readings are stored in TimescaleDB.
  6. Reward Calculation: A scheduled job runs every epoch (1 hour). It calculates each contributor's reward based on: number of validated readings × QoD score × geographic scarcity bonus (cells with fewer nodes pay more).
  7. Reward Distribution: Accumulated rewards (in satoshis) are tracked in PostgreSQL. Contributors claim rewards on-demand from the app, which triggers a Lightning payment from the PulseMesh LND node to the contributor's Breez SDK Spark wallet.
  8. Data Marketplace: Data buyers hit the marketplace API (e.g., GET /v1/data/pressure?h3_cell=891f1d48a3bffff&from=...&to=...). Aperture intercepts, issues an L402 challenge (402 + macaroon + Lightning invoice). Buyer pays the invoice, gets the macaroon+preimage credential, and retries. Aperture verifies and proxies to the Ktor backend, which returns the data.

Android App Architecture

Module Structure

app/
├── src/main/java/com/pulsemesh/app/
│   ├── PulseMeshApp.kt                  # Application class, Hilt setup
│   ├── di/                              # Hilt modules
│   │   ├── AppModule.kt                 # Singletons (Room DB, Retrofit, etc.)
│   │   ├── SensorModule.kt              # Sensor-related bindings
│   │   └── LightningModule.kt           # Breez SDK bindings
│   ├── domain/                          # Pure Kotlin, no Android deps
│   │   ├── model/
│   │   │   ├── SensorReading.kt         # timestamp, type, value, lat, lng, accuracy
│   │   │   ├── SensorType.kt            # enum: PRESSURE, LIGHT, NOISE
│   │   │   ├── ContributorEarnings.kt   # totalSats, pendingSats, claimedSats
│   │   │   ├── DeviceCapabilities.kt    # which sensors are available
│   │   │   └── UploadBatch.kt           # list of readings + metadata
│   │   ├── repository/
│   │   │   ├── SensorRepository.kt      # Interface
│   │   │   ├── EarningsRepository.kt    # Interface
│   │   │   └── WalletRepository.kt      # Interface
│   │   └── usecase/
│   │       ├── GetEarningsUseCase.kt
│   │       ├── ClaimRewardsUseCase.kt
│   │       └── GetSensorHistoryUseCase.kt
│   ├── data/                            # Android + network deps
│   │   ├── local/
│   │   │   ├── PulseMeshDatabase.kt     # Room database
│   │   │   ├── dao/
│   │   │   │   ├── SensorReadingDao.kt
│   │   │   │   └── EarningsDao.kt
│   │   │   └── entity/
│   │   │       ├── SensorReadingEntity.kt
│   │   │       └── EarningsEntity.kt
│   │   ├── remote/
│   │   │   ├── PulseMeshApi.kt          # Retrofit interface
│   │   │   ├── dto/
│   │   │   │   ├── BatchUploadRequest.kt
│   │   │   │   ├── BatchUploadResponse.kt
│   │   │   │   ├── EarningsResponse.kt
│   │   │   │   └── ClaimRewardResponse.kt
│   │   │   └── L402Interceptor.kt       # OkHttp interceptor for L402
│   │   ├── repository/
│   │   │   ├── SensorRepositoryImpl.kt
│   │   │   ├── EarningsRepositoryImpl.kt
│   │   │   └── WalletRepositoryImpl.kt
│   │   └── sensor/
│   │       ├── SensorCollector.kt       # SensorManager wrapper
│   │       ├── PressureCollector.kt     # TYPE_PRESSURE handler
│   │       ├── LightCollector.kt        # TYPE_LIGHT handler
│   │       ├── NoiseCollector.kt        # AudioRecord + SPL calculation
│   │       └── LocationProvider.kt      # FusedLocationProvider wrapper
│   ├── service/
│   │   ├── SensorForegroundService.kt   # The main background collection service
│   │   └── DataUploadWorker.kt          # WorkManager periodic upload
│   ├── ui/
│   │   ├── theme/
│   │   │   ├── Theme.kt
│   │   │   ├── Color.kt
│   │   │   └── Type.kt
│   │   ├── navigation/
│   │   │   └── PulseMeshNavGraph.kt
│   │   ├── screen/
│   │   │   ├── dashboard/
│   │   │   │   ├── DashboardScreen.kt   # Main screen: earnings, status, toggle
│   │   │   │   └── DashboardViewModel.kt
│   │   │   ├── map/
│   │   │   │   ├── MapScreen.kt         # Live map of user's data points
│   │   │   │   └── MapViewModel.kt
│   │   │   ├── earnings/
│   │   │   │   ├── EarningsScreen.kt    # Detailed earnings history + claim
│   │   │   │   └── EarningsViewModel.kt
│   │   │   ├── wallet/
│   │   │   │   ├── WalletScreen.kt      # Lightning wallet: balance, receive, send
│   │   │   │   └── WalletViewModel.kt
│   │   │   └── settings/
│   │   │       ├── SettingsScreen.kt    # Sampling interval, permissions, about
│   │   │       └── SettingsViewModel.kt
│   │   └── component/
│   │       ├── SensorStatusCard.kt
│   │       ├── EarningsCard.kt
│   │       ├── ServiceToggleButton.kt
│   │       └── SensorReadingChart.kt
│   └── util/
│       ├── SplCalculator.kt            # A-weighted SPL from PCM buffer
│       ├── H3Utils.kt                  # lat/lng to H3 cell index
│       └── DeviceInfo.kt               # Device model, Android version

Key Android Components

Foreground Service (SensorForegroundService)

  • Declared in manifest with android:foregroundServiceType="microphone"
  • Shows persistent notification: "PulseMesh is collecting environmental data"
  • Registers sensor listeners on start, unregisters on stop
  • Sampling pattern: register listeners → collect for 2 seconds → unregister → sleep for configured interval (default 60s)
  • Uses SENSOR_DELAY_NORMAL (200ms reporting) for environmental sensors
  • For noise: creates AudioRecord at 44100Hz, MONO, PCM_16BIT, records 1 second of audio, computes RMS → dB SPL, releases AudioRecord
  • Writes each reading to Room DB via SensorReadingDao

Data Upload Worker (DataUploadWorker)

  • WorkManager PeriodicWorkRequest with 15-minute minimum interval (Android constraint)
  • On each run: queries Room for all readings where uploaded = false, groups into batches of 50, POSTs each batch to backend
  • On successful upload: marks readings as uploaded = true in Room
  • On failure: WorkManager's built-in exponential backoff retries automatically
  • Constraints: NetworkType.CONNECTED (only runs when network available)

L402 Interceptor (L402Interceptor)

  • OkHttp Interceptor implementation (~300 lines)
  • Intercepts any HTTP response with status 402
  • Parses WWW-Authenticate: L402 macaroon="...", invoice="..." header
  • Pays the BOLT11 invoice via Breez SDK Spark, obtains the preimage
  • Caches the macaroon+preimage credential keyed by (host, path)
  • Retries the original request with Authorization: L402 <base64(macaroon)>:<hex(preimage)>
  • Checks credential cache before making requests; reuses unexpired credentials
  • Thread-safe via Mutex

Breez SDK Spark Integration (WalletRepositoryImpl)

  • Initialize once in Application.onCreate with Breez API key
  • Core operations: getBalance(), receivePayment(amountSats), sendPayment(bolt11Invoice), paymentHistory()
  • Lightning Address support for receiving contributor rewards
  • Node info and connection status exposed to UI

Android Manifest Permissions

<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

Permission Flow (User Onboarding)

  1. App opens → Welcome screen explaining what PulseMesh does
  2. Screen 2: "I need access to your microphone to measure noise levels. I NEVER record conversations — only noise intensity (decibels)." → Request RECORD_AUDIO
  3. Screen 3: "I need your location to tag sensor data geographically." → Request ACCESS_FINE_LOCATION, then ACCESS_BACKGROUND_LOCATION
  4. Screen 4: "Allow notifications so I can show you the data collection status." → Request POST_NOTIFICATIONS
  5. Screen 5: Lightning wallet setup (Breez SDK initialization, optional: import existing wallet via mnemonic)
  6. → Dashboard screen, service starts automatically

Backend Architecture

Module Structure

backend/
├── src/main/kotlin/com/pulsemesh/backend/
│   ├── Application.kt                    # Ktor entry point
│   ├── plugins/
│   │   ├── Routing.kt                    # Route registration
│   │   ├── Serialization.kt             # kotlinx.serialization
│   │   ├── Authentication.kt            # Device token auth
│   │   ├── Database.kt                  # Exposed/JDBC setup
│   │   └── Monitoring.kt               # Prometheus metrics
│   ├── routes/
│   │   ├── ContributorRoutes.kt         # /v1/contributor/*
│   │   ├── ReadingsRoutes.kt            # /v1/readings/*
│   │   ├── DataMarketplaceRoutes.kt     # /v1/data/* (behind Aperture)
│   │   └── AdminRoutes.kt              # /v1/admin/* (internal)
│   ├── model/
│   │   ├── SensorReading.kt
│   │   ├── Contributor.kt
│   │   ├── H3Cell.kt
│   │   ├── RewardEpoch.kt
│   │   └── ValidationResult.kt
│   ├── repository/
│   │   ├── ReadingsRepository.kt
│   │   ├── ContributorRepository.kt
│   │   └── RewardRepository.kt
│   ├── service/
│   │   ├── ValidationService.kt         # The validation pipeline
│   │   ├── RewardService.kt             # Epoch-based reward calculation
│   │   ├── LightningService.kt          # LND gRPC client for payouts
│   │   └── H3Service.kt                # H3 cell operations
│   └── validation/
│       ├── BoundsChecker.kt             # Physical bounds validation
│       ├── CrossReferenceChecker.kt     # Neighbor consensus
│       ├── ProofOfLocationScorer.kt     # GPS consistency scoring
│       └── QualityOfDataScorer.kt       # Final QoD score [0.0, 1.0]
├── src/main/resources/
│   ├── application.conf                 # Ktor HOCON config
│   └── db/migration/                    # Flyway SQL migrations
│       ├── V001__create_contributors.sql
│       ├── V002__create_readings.sql
│       ├── V003__create_rewards.sql
│       └── V004__create_hypertable.sql
├── src/test/kotlin/com/pulsemesh/backend/
│   ├── routes/                          # Route integration tests
│   ├── service/                         # Service unit tests
│   └── validation/                      # Validation logic tests
├── build.gradle.kts
├── Dockerfile
└── docker-compose.yml

Ktor Application Configuration

# application.conf
ktor {
    deployment {
        port = 8080
    }
    application {
        modules = [ com.pulsemesh.backend.ApplicationKt.module ]
    }
}

database {
    url = "jdbc:postgresql://localhost:5432/pulsemesh"
    url = ${?DATABASE_URL}
    driver = "org.postgresql.Driver"
    user = "pulsemesh"
    user = ${?DATABASE_USER}
    password = "pulsemesh_dev"
    password = ${?DATABASE_PASSWORD}
}

redis {
    url = "redis://localhost:6379"
    url = ${?REDIS_URL}
}

lightning {
    lnd_host = "localhost"
    lnd_host = ${?LND_HOST}
    lnd_port = 10009
    lnd_port = ${?LND_PORT}
    macaroon_path = "/lnd/data/chain/bitcoin/mainnet/admin.macaroon"
    macaroon_path = ${?LND_MACAROON_PATH}
    tls_cert_path = "/lnd/tls.cert"
    tls_cert_path = ${?LND_TLS_CERT_PATH}
}

pulsemesh {
    epoch_duration_minutes = 60
    min_readings_per_epoch = 10
    base_reward_sats_per_reading = 1
    scarcity_multiplier_max = 5.0
    max_nodes_per_h3_cell = 50
    h3_resolution = 9
}

L402 Payment Integration

How L402 Works in PulseMesh

L402 is used only for the Data Marketplace API (data buyers paying for sensor data access). It is NOT used for contributor rewards (those are direct Lightning payments from LND to the contributor's wallet).

The Flow

  1. Data buyer sends: GET /v1/data/pressure?h3_cell=891f1d48a3bffff&from=2026-04-01&to=2026-04-10
  2. Request hits Nginx → routed to Aperture (listening on port 8082)
  3. Aperture checks for Authorization: L402 ... header. Not present → Aperture asks LND to create a BOLT11 invoice for the configured price (e.g., 50 sats)
  4. Aperture responds: HTTP 402 Payment Required with header:
    WWW-Authenticate: L402 macaroon="<base64>", invoice="lnbc500n1p..."
    
  5. Data buyer's client pays the Lightning invoice (50 sats), receives the 32-byte preimage
  6. Data buyer retries with: Authorization: L402 <base64(macaroon)>:<hex(preimage)>
  7. Aperture verifies: sha256(preimage) == payment_hash embedded in macaroon → valid
  8. Aperture proxies request to Ktor backend on port 8080
  9. Ktor backend returns the sensor data JSON
  10. Aperture forwards response to data buyer

Aperture Configuration

# aperture.yaml
listenaddr: "0.0.0.0:8082"
debuglevel: "info"

authenticator:
  passthrough_enabled: false

lnd:
  host: "lnd:10009"
  tlspath: "/lnd/tls.cert"
  macpath: "/lnd/data/chain/bitcoin/mainnet/admin.macaroon"

dbbackend: "sqlite"

services:
  - name: "pulsemesh-data-marketplace"
    hostregexp: ".*"
    pathregexp: "^/v1/data/.*"
    address: "backend:8080"
    protocol: "https"
    auth: "on"
    price: 50           # 50 sats per API call
    duration: 86400     # macaroon valid for 24 hours
    capabilities:
      - "data-read"

Macaroon Caveats for Data Access Tiers

Aperture's macaroon caveats enable tiered pricing:

  • Basic (50 sats): Single H3 cell, 24-hour data window, 100 requests/day
  • Pro (500 sats): City-wide data (multiple cells), 30-day window, 1000 requests/day
  • Enterprise (5000 sats): Global data, 90-day window, unlimited requests

Caveats are added as first-party caveats to the macaroon before issuance. The Ktor backend validates caveats on each proxied request and enforces access scope.

Android L402 Interceptor (For Contributor App Accessing Own Data)

The contributor app also uses L402 when viewing marketplace data (e.g., seeing what their area's data looks like to buyers). The L402 interceptor handles this transparently:

// Pseudocode for L402Interceptor.kt
class L402Interceptor(
    private val walletRepository: WalletRepository,
    private val tokenCache: L402TokenCache
) : Interceptor {

    override fun intercept(chain: Chain): Response {
        val request = chain.request()

        // Check cache for valid credential
        val cachedToken = tokenCache.get(request.url.host, request.url.encodedPath)
        if (cachedToken != null && !cachedToken.isExpired()) {
            val authedRequest = request.newBuilder()
                .header("Authorization", "L402 ${cachedToken.encode()}")
                .build()
            return chain.proceed(authedRequest)
        }

        // Make the request
        val response = chain.proceed(request)
        if (response.code != 402) return response

        // Parse L402 challenge from WWW-Authenticate header
        val challenge = parseL402Challenge(response.header("WWW-Authenticate"))
        response.close()

        // Pay the Lightning invoice via Breez SDK
        val preimage = walletRepository.payInvoice(challenge.invoice)

        // Cache the credential
        val token = L402Token(challenge.macaroon, preimage)
        tokenCache.put(request.url.host, request.url.encodedPath, token)

        // Retry with credential
        val authedRequest = request.newBuilder()
            .header("Authorization", "L402 ${token.encode()}")
            .build()
        return chain.proceed(authedRequest)
    }
}

Sensor Data Specification

Reading Format

Each sensor reading captured by the Android app contains:

data class SensorReading(
    val id: String,                // UUID v4
    val deviceId: String,          // Unique device identifier (Android ID or generated UUID)
    val sensorType: SensorType,    // PRESSURE, LIGHT, NOISE
    val value: Double,             // The sensor value (hPa, lux, or dB SPL)
    val unit: String,              // "hPa", "lux", "dB_SPL"
    val latitude: Double,          // WGS84 latitude
    val longitude: Double,         // WGS84 longitude
    val locationAccuracy: Float,   // GPS accuracy in meters
    val h3CellIndex: String,       // H3 cell index at Resolution 9
    val timestamp: Long,           // Unix epoch milliseconds (UTC)
    val deviceModel: String,       // e.g., "Pixel 9 Pro"
    val androidVersion: Int,       // API level, e.g., 35
    val calibrationOffset: Double  // Per-device-model correction (mainly for noise)
)

Physical Bounds for Validation

These are the absolute physical bounds. Any reading outside these ranges is immediately rejected as sensor malfunction or spoofing.

Sensor Unit Min Max Typical Range Notes
Pressure hPa 870.0 1084.0 980–1040 870 hPa = extreme typhoon; 1084 = highest recorded on Earth
Light lux 0.0 150000.0 0–80000 120k+ = direct sunlight; sensor max varies
Noise dB SPL 20.0 120.0 30–90 Below 20 is mic noise floor; above 120 clips

Noise Level (SPL) Calculation

// SplCalculator.kt — A-weighted Sound Pressure Level from AudioRecord PCM buffer

fun calculateSplFromPcm(buffer: ShortArray, sampleRate: Int = 44100): Double {
    // 1. Calculate RMS of PCM samples
    val sumOfSquares = buffer.fold(0.0) { acc, sample ->
        acc + (sample.toDouble() * sample.toDouble())
    }
    val rms = sqrt(sumOfSquares / buffer.size)

    // 2. Convert to dB relative to full scale
    val dbFs = 20 * log10(rms / Short.MAX_VALUE)

    // 3. Add device-specific calibration offset to convert dBFS → dB SPL
    // Reference: 94 dB SPL = typical phone mic max undistorted level
    // This offset must be determined per device model via calibration
    val calibrationOffset = getCalibrationOffset(Build.MODEL)
    return dbFs + calibrationOffset
}

// Calibration offsets per device model (crowdsourced or lab-measured)
// These values convert dBFS to approximate dB SPL
fun getCalibrationOffset(model: String): Double = when {
    model.startsWith("Pixel 9") -> 91.0
    model.startsWith("Pixel 8") -> 90.5
    model.startsWith("SM-S926") -> 92.0  // Galaxy S24+
    model.startsWith("SM-S928") -> 92.5  // Galaxy S25 Ultra
    else -> 90.0  // Conservative default
}

Data Validation Pipeline

Every batch of readings passes through a four-stage validation pipeline on the backend. Each stage produces a score; readings with a combined QoD score below 0.3 are rejected.

Stage 1: Bounds Check (BoundsChecker)

  • Reject any reading outside the physical bounds table above
  • Reject readings with locationAccuracy > 100.0 meters (too imprecise)
  • Reject readings with timestamps more than 10 minutes in the future or more than 1 hour in the past
  • Reject readings where lat/lng is (0.0, 0.0) or null island
  • Score: 1.0 if all bounds pass, 0.0 if any fail (binary)

Stage 2: Cross-Reference Check (CrossReferenceChecker)

  • For each reading, query the database for readings from OTHER devices in the same H3 cell (Resolution 9) within ±10 minutes
  • If ≥3 other devices reported values, calculate the median and standard deviation
  • If this reading is within 2σ of the median → score 1.0
  • If within 3σ → score 0.7
  • If outside 3σ → score 0.3 (suspicious but not rejected — could be a real micro-event)
  • If <3 other devices in the cell (sparse coverage) → score 0.8 (benefit of the doubt, but lower confidence)

Stage 3: Proof of Location (ProofOfLocationScorer)

  • Track each device's location history over rolling 7-day window
  • Score based on GPS consistency:
    • Device reports from a consistent set of locations (home, work, commute) → high PoL (0.9–1.0)
    • Device suddenly teleports to a distant location → PoL drops to 0.3 for 24 hours, then recovers if readings continue from new location
    • Device reports from an impossibly fast-moving trajectory (>200 km/h without plausible transport) → PoL = 0.1
  • Relocation penalty: if device moves to a completely new H3 cell (different from any cell in the last 7 days), PoL resets to 0.5 and recovers over 48 hours of consistent reporting

Stage 4: Quality of Data Score (QualityOfDataScorer)

  • Final QoD score = bounds_score × cross_ref_score × pol_score
  • Readings with QoD < 0.3 → rejected, not stored
  • Readings with QoD 0.3–0.7 → stored with quality_tier = "LOW"
  • Readings with QoD 0.7–0.9 → stored with quality_tier = "MEDIUM"
  • Readings with QoD ≥ 0.9 → stored with quality_tier = "HIGH"

Data buyers can filter by quality tier in marketplace queries.


API Contracts

Contributor API (Direct, No L402)

Authentication: Device token in Authorization: Bearer <device_token> header. Device token is issued on first registration.

POST /v1/contributor/register

Register a new contributor device.

Request:

{
    "device_model": "Pixel 9 Pro",
    "android_version": 35,
    "sensors_available": ["PRESSURE", "LIGHT", "NOISE"],
    "app_version": "1.0.0",
    "lightning_address": "user123@pulsemesh.app"
}

Response (201):

{
    "device_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
    "device_token": "pm_tok_abc123...",
    "calibration_offsets": {
        "NOISE": 91.0
    }
}

POST /v1/readings/batch

Upload a batch of sensor readings.

Request:

{
    "device_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
    "readings": [
        {
            "id": "a1b2c3d4-...",
            "sensor_type": "PRESSURE",
            "value": 1013.25,
            "unit": "hPa",
            "latitude": 42.3601,
            "longitude": -71.0589,
            "location_accuracy": 8.5,
            "timestamp": 1744300800000,
            "device_model": "Pixel 9 Pro",
            "android_version": 35,
            "calibration_offset": 0.0
        },
        {
            "id": "e5f6g7h8-...",
            "sensor_type": "NOISE",
            "value": 62.3,
            "unit": "dB_SPL",
            "latitude": 42.3601,
            "longitude": -71.0589,
            "location_accuracy": 8.5,
            "timestamp": 1744300800000,
            "device_model": "Pixel 9 Pro",
            "android_version": 35,
            "calibration_offset": 91.0
        }
    ]
}

Response (200):

{
    "accepted": 14,
    "rejected": 1,
    "rejections": [
        {
            "reading_id": "z9y8x7w6-...",
            "reason": "BOUNDS_CHECK_FAILED",
            "detail": "Pressure value 2000.0 exceeds maximum 1084.0 hPa"
        }
    ]
}

GET /v1/contributor/earnings

Get contributor's earnings summary.

Response (200):

{
    "device_id": "d290f1ee-...",
    "total_earned_sats": 12450,
    "pending_sats": 350,
    "claimed_sats": 12100,
    "current_epoch": {
        "epoch_id": 48291,
        "start": "2026-04-11T14:00:00Z",
        "end": "2026-04-11T15:00:00Z",
        "readings_submitted": 42,
        "readings_validated": 40,
        "estimated_reward_sats": 52
    },
    "qod_score_avg_7d": 0.87,
    "pol_score": 0.95
}

POST /v1/contributor/claim

Claim pending rewards via Lightning payment.

Request:

{
    "bolt11_invoice": "lnbc12450n1p..."
}

Response (200):

{
    "payment_hash": "abc123...",
    "amount_sats": 350,
    "status": "COMPLETED"
}

Data Marketplace API (L402-Gated via Aperture)

These endpoints sit behind Aperture. First request returns 402; after paying, the L402 credential grants access.

GET /v1/data/pressure

Query barometric pressure readings.

Parameters:

  • h3_cell (required): H3 cell index at Resolution 9. Use h3_cells (comma-separated) for multiple cells.
  • from (required): ISO 8601 start timestamp
  • to (required): ISO 8601 end timestamp
  • quality_min (optional): Minimum QoD score, default 0.5
  • aggregation (optional): raw (default), hourly_avg, daily_avg
  • limit (optional): Max results, default 1000, max 10000
  • offset (optional): Pagination offset

Response (200):

{
    "h3_cell": "891f1d48a3bffff",
    "sensor_type": "PRESSURE",
    "from": "2026-04-01T00:00:00Z",
    "to": "2026-04-10T23:59:59Z",
    "aggregation": "hourly_avg",
    "count": 240,
    "data": [
        {
            "timestamp": "2026-04-01T00:00:00Z",
            "value": 1013.25,
            "unit": "hPa",
            "num_contributors": 8,
            "quality_avg": 0.92,
            "std_dev": 0.3
        }
    ]
}

GET /v1/data/noise

Same parameter structure as pressure. Returns noise level data in dB SPL.

GET /v1/data/light

Same parameter structure as pressure. Returns ambient light data in lux.

GET /v1/data/coverage

Get data coverage map (no L402 — this is a free public endpoint to attract buyers).

Parameters:

  • h3_resolution (optional): 4–9, default 7
  • sensor_type (optional): Filter by sensor type

Response (200):

{
    "h3_resolution": 7,
    "cells": [
        {
            "h3_cell": "871f1d489ffffff",
            "active_contributors": 23,
            "sensors_available": ["PRESSURE", "LIGHT", "NOISE"],
            "data_quality_avg": 0.85,
            "last_reading": "2026-04-11T14:32:00Z"
        }
    ]
}

Database Schema

PostgreSQL + PostGIS + TimescaleDB

-- V001__create_contributors.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "postgis";

CREATE TABLE contributors (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    device_id VARCHAR(255) UNIQUE NOT NULL,
    device_token VARCHAR(512) UNIQUE NOT NULL,
    device_model VARCHAR(255) NOT NULL,
    android_version INTEGER NOT NULL,
    sensors_available TEXT[] NOT NULL,  -- e.g., {'PRESSURE', 'LIGHT', 'NOISE'}
    lightning_address VARCHAR(255),
    app_version VARCHAR(50) NOT NULL,
    pol_score DOUBLE PRECISION DEFAULT 0.5,
    registered_at TIMESTAMPTZ DEFAULT NOW(),
    last_seen_at TIMESTAMPTZ DEFAULT NOW(),
    is_active BOOLEAN DEFAULT TRUE
);

CREATE INDEX idx_contributors_device_id ON contributors(device_id);
CREATE INDEX idx_contributors_device_token ON contributors(device_token);

-- V002__create_readings.sql
CREATE TABLE sensor_readings (
    id UUID NOT NULL,
    contributor_id UUID NOT NULL REFERENCES contributors(id),
    sensor_type VARCHAR(20) NOT NULL,  -- 'PRESSURE', 'LIGHT', 'NOISE'
    value DOUBLE PRECISION NOT NULL,
    unit VARCHAR(20) NOT NULL,
    location GEOGRAPHY(POINT, 4326) NOT NULL,
    location_accuracy REAL NOT NULL,
    h3_cell_r9 VARCHAR(20) NOT NULL,   -- H3 index at Resolution 9
    h3_cell_r7 VARCHAR(20) NOT NULL,   -- H3 index at Resolution 7 (for coarser aggregation)
    quality_score DOUBLE PRECISION NOT NULL,
    quality_tier VARCHAR(10) NOT NULL,  -- 'HIGH', 'MEDIUM', 'LOW'
    device_model VARCHAR(255) NOT NULL,
    calibration_offset DOUBLE PRECISION DEFAULT 0.0,
    timestamp TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- V004__create_hypertable.sql
SELECT create_hypertable('sensor_readings', 'timestamp',
    chunk_time_interval => INTERVAL '1 day'
);

CREATE INDEX idx_readings_h3_r9_time ON sensor_readings (h3_cell_r9, timestamp DESC);
CREATE INDEX idx_readings_h3_r7_time ON sensor_readings (h3_cell_r7, timestamp DESC);
CREATE INDEX idx_readings_contributor_time ON sensor_readings (contributor_id, timestamp DESC);
CREATE INDEX idx_readings_sensor_type ON sensor_readings (sensor_type);
CREATE INDEX idx_readings_location ON sensor_readings USING GIST (location);

-- V003__create_rewards.sql
CREATE TABLE reward_epochs (
    id SERIAL PRIMARY KEY,
    epoch_number INTEGER UNIQUE NOT NULL,
    start_time TIMESTAMPTZ NOT NULL,
    end_time TIMESTAMPTZ NOT NULL,
    total_readings_validated INTEGER DEFAULT 0,
    total_rewards_sats BIGINT DEFAULT 0,
    processed_at TIMESTAMPTZ
);

CREATE TABLE contributor_rewards (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    contributor_id UUID NOT NULL REFERENCES contributors(id),
    epoch_id INTEGER NOT NULL REFERENCES reward_epochs(id),
    readings_validated INTEGER NOT NULL,
    avg_qod_score DOUBLE PRECISION NOT NULL,
    scarcity_multiplier DOUBLE PRECISION DEFAULT 1.0,
    reward_sats BIGINT NOT NULL,
    claimed BOOLEAN DEFAULT FALSE,
    claimed_at TIMESTAMPTZ,
    payment_hash VARCHAR(64),
    UNIQUE(contributor_id, epoch_id)
);

CREATE INDEX idx_rewards_contributor ON contributor_rewards(contributor_id);
CREATE INDEX idx_rewards_unclaimed ON contributor_rewards(contributor_id) WHERE claimed = FALSE;

-- Continuous aggregate for hourly averages (TimescaleDB)
CREATE MATERIALIZED VIEW hourly_sensor_avg
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 hour', timestamp) AS bucket,
    h3_cell_r9,
    sensor_type,
    AVG(value) AS avg_value,
    STDDEV(value) AS std_dev,
    COUNT(*) AS num_readings,
    COUNT(DISTINCT contributor_id) AS num_contributors,
    AVG(quality_score) AS avg_quality
FROM sensor_readings
WHERE quality_tier IN ('HIGH', 'MEDIUM')
GROUP BY bucket, h3_cell_r9, sensor_type
WITH NO DATA;

-- Refresh policy: refresh hourly, covering the last 3 hours
SELECT add_continuous_aggregate_policy('hourly_sensor_avg',
    start_offset => INTERVAL '3 hours',
    end_offset => INTERVAL '1 hour',
    schedule_interval => INTERVAL '1 hour'
);

Security and Anti-Gaming

Sybil Resistance (Fake Devices)

  • Device Fingerprinting: Hash of (Android ID + device model + Build.FINGERPRINT). Changing any hardware attribute invalidates the device identity.
  • Rate Limiting: Max 1 reading per sensor per 30 seconds per device. More frequent submissions are silently dropped.
  • Geographic Density Limits: Max 50 active contributors per H3 Resolution 9 cell (~0.1 km²). New devices in saturated cells are queued (earn reduced rewards until a slot opens).
  • Progressive Trust: New devices start with a 0.5 PoL score and 0.5× reward multiplier. Full rewards require 7 days of consistent, validated contributions.
  • Cross-Reference Consensus: Readings that deviate significantly from the cell median (>3σ) receive low QoD scores, making sustained spoofing unprofitable.
  • Anomaly Detection: If a device's readings are consistently the outlier in its cell (bottom 5% QoD over 48 hours), it is flagged for review and rewards are paused.

Data Privacy

  • No Audio Recording: The app computes noise level (dB SPL) on-device from raw PCM samples and immediately discards the audio buffer. No audio data ever leaves the phone.
  • Location Coarsening for Marketplace: Data sold to buyers is aggregated at H3 Resolution 9 (~0.1 km²) minimum. Individual device locations are never exposed to data buyers.
  • Contributor Anonymity: Data buyers see aggregated cell data, never individual device IDs or contributor identities.
  • Device Token Auth: Device tokens are cryptographically generated, rotated every 30 days, and never contain PII.

Network Security

  • TLS Everywhere: All API traffic over HTTPS (TLS 1.3)
  • Certificate Pinning: Android app pins the PulseMesh backend TLS certificate
  • Rate Limiting: Redis-backed rate limiting on all API endpoints
  • Input Validation: All request bodies validated against JSON Schema before processing

Project Structure

pulsemesh/
├── README.md                    # This file
├── TASKS.md                     # Ordered build tasks with tests
├── CLAUDE.md                    # Generated by AI agent on first run
├── android/                     # Android app (Kotlin + Jetpack Compose)
│   ├── app/
│   │   ├── build.gradle.kts
│   │   └── src/
│   ├── build.gradle.kts
│   ├── settings.gradle.kts
│   └── gradle.properties
├── backend/                     # Ktor backend
│   ├── src/
│   ├── build.gradle.kts
│   ├── Dockerfile
│   └── docker-compose.yml       # Backend + PostgreSQL + Redis + LND + Aperture
├── aperture/
│   └── aperture.yaml            # Aperture L402 proxy config
└── docs/
    └── architecture.md          # Additional architecture notes

Development Setup

Prerequisites

  • Android Studio Ladybug (2024.2.1) or newer
  • JDK 17+
  • Docker Desktop (for backend services)
  • A Breez SDK API key (free, from https://breez.technology)

Android App

cd android
# Set Breez API key in local.properties
echo "BREEZ_API_KEY=your_key_here" >> local.properties
# Open in Android Studio and sync Gradle
# OR build from command line:
./gradlew assembleDebug
./gradlew testDebugUnitTest

Backend

cd backend
# Start all services (PostgreSQL, Redis, LND, Aperture, Ktor app)
docker compose up -d
# Run migrations
./gradlew flywayMigrate
# Run backend
./gradlew run
# Run tests
./gradlew test

Docker Compose Services

# backend/docker-compose.yml
services:
  postgres:
    image: timescale/timescaledb-ha:pg16
    ports: ["5432:5432"]
    environment:
      POSTGRES_DB: pulsemesh
      POSTGRES_USER: pulsemesh
      POSTGRES_PASSWORD: pulsemesh_dev
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  lnd:
    image: lightninglabs/lnd:v0.18.0-beta
    ports: ["10009:10009", "9735:9735"]
    volumes:
      - lnddata:/root/.lnd
    command: >
      lnd
      --bitcoin.active
      --bitcoin.regtest
      --bitcoin.node=neutrino
      --noseedbackup
      --restlisten=0.0.0.0:8080
      --rpclisten=0.0.0.0:10009

  aperture:
    image: lightninglabs/aperture:v0.3.5-beta
    ports: ["8082:8082"]
    volumes:
      - ../aperture/aperture.yaml:/config/aperture.yaml
      - lnddata:/lnd:ro
    command: ["aperture", "--configfile=/config/aperture.yaml"]
    depends_on: [lnd]

  backend:
    build: .
    ports: ["8080:8080"]
    environment:
      DATABASE_URL: jdbc:postgresql://postgres:5432/pulsemesh
      DATABASE_USER: pulsemesh
      DATABASE_PASSWORD: pulsemesh_dev
      REDIS_URL: redis://redis:6379
      LND_HOST: lnd
    depends_on: [postgres, redis, lnd]

volumes:
  pgdata:
  lnddata:

Testing Strategy

Android

  • Unit Tests: JUnit 5 + Mockk for all ViewModels, UseCases, Repository implementations, SplCalculator, H3Utils, L402 token parsing
  • Integration Tests: Room DB tests with in-memory database, Retrofit tests with MockWebServer
  • UI Tests: Espresso for critical flows (onboarding, service toggle, earnings claim)
  • Sensor Tests: Mock SensorManager for PressureCollector, LightCollector; mock AudioRecord for NoiseCollector

Backend

  • Unit Tests: JUnit 5 for ValidationService, RewardService, all validation pipeline stages
  • Integration Tests: Testcontainers (PostgreSQL + TimescaleDB, Redis) for repository tests and route tests
  • API Tests: Ktor TestApplication for all route handlers
  • Load Tests: k6 scripts for data ingestion and marketplace query performance

Deployment

Backend Production

  • Cloud: Any VPS or cloud provider (Hetzner, DigitalOcean, AWS)
  • Minimum specs: 2 vCPU, 4GB RAM, 100GB SSD
  • Domain: api.pulsemesh.app (backend), data.pulsemesh.app (Aperture/marketplace)
  • TLS: Let's Encrypt via Certbot + Nginx
  • LND: Bitcoin mainnet, connected to the Lightning Network with sufficient inbound/outbound liquidity for contributor payouts and data buyer invoice generation

Android App Distribution

  • Google Play Store: Standard release track
  • APK direct download: For users who prefer sideloading (important for crypto apps; Play Store may restrict Lightning wallet functionality)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors