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.
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).
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.
Over the course of the hackathon, I built the Android client, the initial Ktor backend, and a complete Website UI dashboard from scratch:
- 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.
- 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!
- Ktor API Backend: Designed with PostgreSQL, TimescaleDB, and PostGIS for heavy geospatial time-series data aggregation via Uber H3 grid cells.
- Sensor Validation Pipeline: A robust 4-stage pipeline (Bounds Check -> Cross-Reference -> Proof of Location -> QoD Scorer) that assigns trust scores to node inputs.
- L402 Gateway: Configured and deployed Aperture tied to an LND node to monetize the REST API directly over the Lightning Network.
See the full Development Setup section below.
- Open the
/androiddirectory in Android Studio Ladybug to compile the app and test the integrated Breez SDK lightning wallet on testnet. - Open the
/backenddirectory and deploy the backend environment utilizingdocker compose up -dto spool PostgreSQL, TimescaleDB, Redis, LND, and Aperture.
- Product Overview
- Target Users
- Tech Stack
- System Architecture
- Android App Architecture
- Backend Architecture
- L402 Payment Integration
- Sensor Data Specification
- Data Validation Pipeline
- API Contracts
- Database Schema
- Security and Anti-Gaming
- Project Structure
- Development Setup
- Testing Strategy
- Deployment
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.
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.
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. RequiresRECORD_AUDIOruntime 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.
- 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
- 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)
- 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)
- 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
- 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)
┌─────────────────────────────────────────────────────────────────────────┐
│ 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 │ │
│ └─────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
- 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).
- Local Storage: Each reading is stored in Room DB with timestamp, sensor type, value, lat/lng, accuracy, and device model.
- 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).
- Upload: Batch is POSTed to
POST /v1/readings/batchwith the contributor's device ID and authentication token. - Validation: Backend runs the validation pipeline (bounds check → cross-reference → PoL scoring → QoD scoring). Invalid readings are rejected; valid readings are stored in TimescaleDB.
- 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).
- 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.
- 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.
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
- 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
AudioRecordat 44100Hz, MONO, PCM_16BIT, records 1 second of audio, computes RMS → dB SPL, releases AudioRecord - Writes each reading to Room DB via
SensorReadingDao
- WorkManager
PeriodicWorkRequestwith 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 = truein Room - On failure: WorkManager's built-in exponential backoff retries automatically
- Constraints:
NetworkType.CONNECTED(only runs when network available)
- OkHttp
Interceptorimplementation (~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
- 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
<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" />- App opens → Welcome screen explaining what PulseMesh does
- Screen 2: "I need access to your microphone to measure noise levels. I NEVER record conversations — only noise intensity (decibels)."
→ Request
RECORD_AUDIO - Screen 3: "I need your location to tag sensor data geographically."
→ Request
ACCESS_FINE_LOCATION, thenACCESS_BACKGROUND_LOCATION - Screen 4: "Allow notifications so I can show you the data collection status."
→ Request
POST_NOTIFICATIONS - Screen 5: Lightning wallet setup (Breez SDK initialization, optional: import existing wallet via mnemonic)
- → Dashboard screen, service starts automatically
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
# 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 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).
- Data buyer sends:
GET /v1/data/pressure?h3_cell=891f1d48a3bffff&from=2026-04-01&to=2026-04-10 - Request hits Nginx → routed to Aperture (listening on port 8082)
- Aperture checks for
Authorization: L402 ...header. Not present → Aperture asks LND to create a BOLT11 invoice for the configured price (e.g., 50 sats) - Aperture responds:
HTTP 402 Payment Requiredwith header:WWW-Authenticate: L402 macaroon="<base64>", invoice="lnbc500n1p..." - Data buyer's client pays the Lightning invoice (50 sats), receives the 32-byte preimage
- Data buyer retries with:
Authorization: L402 <base64(macaroon)>:<hex(preimage)> - Aperture verifies:
sha256(preimage) == payment_hashembedded in macaroon → valid - Aperture proxies request to Ktor backend on port 8080
- Ktor backend returns the sensor data JSON
- Aperture forwards response to data buyer
# 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"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.
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)
}
}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)
)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 |
// 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
}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.
- Reject any reading outside the physical bounds table above
- Reject readings with
locationAccuracy > 100.0meters (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)
- 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)
- 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
- 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.
Authentication: Device token in Authorization: Bearer <device_token> header. Device token is issued on first registration.
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
}
}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 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
}Claim pending rewards via Lightning payment.
Request:
{
"bolt11_invoice": "lnbc12450n1p..."
}Response (200):
{
"payment_hash": "abc123...",
"amount_sats": 350,
"status": "COMPLETED"
}These endpoints sit behind Aperture. First request returns 402; after paying, the L402 credential grants access.
Query barometric pressure readings.
Parameters:
h3_cell(required): H3 cell index at Resolution 9. Useh3_cells(comma-separated) for multiple cells.from(required): ISO 8601 start timestampto(required): ISO 8601 end timestampquality_min(optional): Minimum QoD score, default 0.5aggregation(optional):raw(default),hourly_avg,daily_avglimit(optional): Max results, default 1000, max 10000offset(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
}
]
}Same parameter structure as pressure. Returns noise level data in dB SPL.
Same parameter structure as pressure. Returns ambient light data in lux.
Get data coverage map (no L402 — this is a free public endpoint to attract buyers).
Parameters:
h3_resolution(optional): 4–9, default 7sensor_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"
}
]
}-- 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'
);- 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.
- 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.
- 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
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
- 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)
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 testDebugUnitTestcd 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# 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:- 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
- 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
- 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
- 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)