Skip to content

Instantly share code, notes, and snippets.

@ibam
Last active March 14, 2026 12:32
Show Gist options
  • Select an option

  • Save ibam/07817119db75528a0212c4cddec65d4a to your computer and use it in GitHub Desktop.

Select an option

Save ibam/07817119db75528a0212c4cddec65d4a to your computer and use it in GitHub Desktop.

P2P Ridehailing App — Claude Code Instructions

You are building a fully decentralized peer-to-peer ridehailing mobile app. Zero centralized servers. The entire system runs on user devices and the Solana blockchain.

Core Flow

A rider opens the app, sets a destination, and the app calculates a fare client-side using Haversine distance. The rider posts a ride request on-chain containing a blinded zone ID (ZK proof of zone membership), the offered fare, and escrowed SOL. Nearby drivers discover the request via libp2p GossipSub and on-chain queries. Drivers submit offers on-chain — they can accept the rider's fare or counter-offer (capped at 2x the rider's price). The rider selects one driver. A single on-chain transaction atomically locks the match and rejects all others. The selected driver and rider establish a direct encrypted P2P channel via libp2p. All real-time communication (exact pickup, GPS, chat, ETA) flows over this channel. On completion, escrow releases to the driver.

Hard Requirements

  • Match time from broadcast to driver accepted: 15 seconds or less
  • Total on-chain cost per ride: under $0.01 USD
  • Mobile framework: Expo (React Native)
  • Blockchain: Solana
  • Centralized servers: zero
  • Privacy: ZK zone proofs at match time only, never for ongoing comms
  • Realtime comms: direct encrypted P2P via libp2p with Noise protocol
  • NAT traversal: libp2p DCUtR hole punching plus Circuit Relay v2 (peer-relayed, no TURN server)
  • Fare model: rider-side calculation posted as on-chain offer

Why Solana

Solana gives sub-cent fees (~$0.0005 per tx), 400ms slot times, mature React Native SDK (@solana/web3.js), and expressive smart contracts via Anchor/Rust. A full ride lifecycle (create + offer + select + complete) costs about $0.003 total.

Architecture Decisions

ADR-1: ZK proofs are generated only when broadcasting a request or responding to one. Never for ongoing comms. Proof generation takes 1-3 seconds on mobile so doing it per GPS update would be unusable.

ADR-2: Use libp2p instead of WebRTC. WebRTC requires signaling servers and TURN servers by design — you cannot remove them. libp2p provides direct dialing via multiaddr, Noise encryption, Kademlia DHT for peer lookup, DCUtR for NAT hole punching, Circuit Relay v2 for fallback relayed by other peers (not our servers), and GossipSub for pubsub.

ADR-3: When rider selects a driver, one transaction sets selected_driver and status=Matched. All other drivers subscribe to this account. When they see Matched and the pubkey is not theirs, they move on. No individual rejection messages needed.

ADR-4: Zones use geohash precision 6 (~1 km² cells). If no drivers found, expand to 3 corner-adjacent neighbors, then to parent zone (geohash-5), then to parent's 3 corner neighbors. Worst case ~16 seconds across all levels.

ADR-5: A Solana account stores up to 50 long-lived peer multiaddrs for bootstrapping. Any peer online 1+ hour with 1+ completed ride can register. Stale entries prunable by anyone.

ADR-6: Ride requests are discoverable via on-chain getProgramAccounts queries (authoritative, ~300ms) and libp2p GossipSub zone topic announcements (fast, sub-second). Both run simultaneously.

ADR-7: The rider's device calculates fare using Haversine distance times road multiplier times per-km rate. This fare is posted on-chain as a take-it-or-counter-it offer. Drivers can accept or propose a different amount capped at 2x the rider's price. Rider has final selection authority.


Zone System

Use geohash encoding. Primary matching at geohash precision 6 (approximately 1.2 km by 0.6 km cells). Parent zones at precision 5 (approximately 4.9 km by 4.9 km). Micro-zones at precision 7 used only for corner detection.

Zone Expansion Algorithm

Step 1: Search rider's Level 1 zone (geohash-6). Timeout 5 seconds. If drivers found, show offers and stop.

Step 2: Determine rider's nearest corner within their Level 1 zone using actual coordinates client-side. The nearest corner is computed by checking if the rider is above or below the cell's latitude midpoint and left or right of the longitude midpoint. Expand to the 3 Level 1 neighbor zones sharing that corner: for NE corner expand to north, east, and northeast neighbors; for NW expand to north, west, northwest; for SE expand to south, east, southeast; for SW expand to south, west, southwest. Timeout 4 seconds. If drivers found, show offers and stop.

Step 3: Expand to the full Level 2 parent zone (geohash-5 covering all 32 children). Timeout 4 seconds. If drivers found, show offers and stop.

Step 4: Determine nearest corner of the Level 2 parent zone. Expand to 3 Level 2 neighbor zones sharing that corner. Timeout 3 seconds. If drivers found, show offers and stop.

Step 5: No drivers found. Inform rider. Allow retry or queue.

Corner Detection

function getNearestCorner(lat: number, lng: number, cellBounds: ZoneBounds): Corner {
  const midLat = (cellBounds.minLat + cellBounds.maxLat) / 2;
  const midLng = (cellBounds.minLng + cellBounds.maxLng) / 2;
  const ns = lat >= midLat ? 'N' : 'S';
  const ew = lng >= midLng ? 'E' : 'W';
  return `${ns}${ew}` as Corner;
}

Files to Create

  • src/zones/geohash.ts — getGeohash(), getZoneBounds(), getNeighbors() using ngeohash
  • src/zones/corner.ts — getNearestCorner(), getCornerNeighbors()
  • src/zones/expansion.ts — ZoneExpander class with cascading search and timeouts

Blockchain Layer (Solana)

Build 4 Anchor programs.

Program 1: ride_request

RideRequest account fields: rider (Pubkey), zone_geohash (String, 6 chars), rider_offered_fare (u64, lamports set by rider), status (enum: Open/Matched/InProgress/Completed/Cancelled/Disputed), selected_driver (Option Pubkey), driver_offers (Vec of DriverOffer, max 10), pickup_geohash (Option String, zone-level only not exact coords), dropoff_geohash (Option String), distance_estimate_meters (u32), created_at (i64), matched_at (Option i64), completed_at (Option i64), bump (u8).

DriverOffer struct fields: driver (Pubkey), accepted_fare (bool, true means accepts rider's price), counter_fare (Option u64, alternative price if not accepting), eta_seconds (u16), timestamp (i64).

Events: NewRideRequestEvent with ride_request pubkey, zone_geohash, rider_offered_fare, distance_estimate_meters. DriverSelectedEvent with ride_request pubkey, selected_driver, agreed_fare.

Instructions:

  1. create_request(zone_geohash, rider_offered_fare, distance_estimate_meters, pickup_geohash, dropoff_geohash, zk_proof_data) — verify ZK proof, transfer rider_offered_fare to escrow PDA, create RideRequest with status=Open, emit NewRideRequestEvent.

  2. submit_offer(ride_request, accepted_fare, counter_fare, eta_seconds, zk_proof_data) — verify driver ZK zone proof, append DriverOffer to driver_offers (max 10), enforce counter_fare <= rider_offered_fare * 2 if provided.

  3. select_driver(ride_request, selected_driver_pubkey, agreed_fare) — only callable by rider, agreed_fare must equal rider_offered_fare or the selected driver's counter_fare, if agreed_fare > escrowed amount then rider tops up escrow, if agreed_fare < escrowed then difference refunded, set selected_driver and status=Matched, emit DriverSelectedEvent.

  4. start_ride(ride_request) — callable by selected_driver, set status=InProgress.

  5. complete_ride(ride_request) — both parties confirm or timeout auto-completes after 5 min inactivity, release escrow to driver, set status=Completed, close account to reclaim rent.

  6. cancel_request(ride_request) — only by rider while status=Open, full escrow refund, set status=Cancelled.

  7. dispute_ride(ride_request, reason) — either party while status=InProgress, lock funds, set status=Disputed.

Program 2: user_registry

UserProfile account fields: wallet (Pubkey), role (enum: Rider/Driver/Both), rating_sum (u64), rating_count (u32), rides_completed (u32), stake_lamports (u64, drivers must stake), is_active (bool), current_zone (Option String, geohash-6), libp2p_peer_id (String), libp2p_multiaddrs (Vec String), multiaddr_updated_at (i64), created_at (i64), bump (u8).

Instructions: register_user(role), update_zone(new_geohash), update_multiaddr(peer_id, multiaddrs), stake(amount), unstake(amount) only if not in active ride, rate_user(target, rating 1-5) post-ride.

Program 3: escrow

EscrowVault account fields: ride_request (Pubkey), rider (Pubkey), driver (Option Pubkey), amount (u64), status (enum: Funded/Released/Refunded/Locked), bump (u8).

Instructions: fund_escrow(ride_request, amount) called within create_request CPI, topup_escrow(ride_request, additional_amount) if rider accepts counter-fare, partial_refund(ride_request, refund_amount) if agreed fare < escrowed, release_to_driver(ride_request) called within complete_ride, refund_to_rider(ride_request) called within cancel_request, lock_for_dispute(ride_request) called within dispute_ride.

Program 4: bootstrap_registry

BootstrapRegistry account with peers (Vec of BootstrapPeer, max 50). BootstrapPeer struct: peer_id (String), multiaddrs (Vec String), wallet (Pubkey), uptime_start (i64), last_refreshed (i64).

Instructions: register_bootstrap(peer_id, multiaddrs) requires 1+ completed ride, refresh_bootstrap() updates last_refreshed, prune_stale() removes entries not refreshed in 1 hour and is callable by anyone.


ZK Privacy Layer

Circuit

pragma circom 2.0.0;
include "circomlib/comparators.circom";

template ZoneMembership() {
    signal input lat;
    signal input lng;
    signal input minLat;
    signal input maxLat;
    signal input minLng;
    signal input maxLng;
    signal output valid;

    component ge1 = GreaterEqThan(32);
    ge1.in[0] <== lat;
    ge1.in[1] <== minLat;

    component le1 = LessEqThan(32);
    le1.in[0] <== lat;
    le1.in[1] <== maxLat;

    component ge2 = GreaterEqThan(32);
    ge2.in[0] <== lng;
    ge2.in[1] <== minLng;

    component le2 = LessEqThan(32);
    le2.in[0] <== lng;
    le2.in[1] <== maxLng;

    valid <== ge1.out * le1.out * ge2.out * le2.out;
    valid === 1;
}

component main {public [minLat, maxLat, minLng, maxLng]} = ZoneMembership();

Proof Generation (on-device)

// src/zk/prover.ts
import * as snarkjs from 'snarkjs';

export async function generateZoneProof(
  lat: number, lng: number,
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }
) {
  const input = {
    lat: Math.round(lat * 1e7),
    lng: Math.round(lng * 1e7),
    minLat: Math.round(bounds.minLat * 1e7),
    maxLat: Math.round(bounds.maxLat * 1e7),
    minLng: Math.round(bounds.minLng * 1e7),
    maxLng: Math.round(bounds.maxLng * 1e7),
  };
  const { proof, publicSignals } = await snarkjs.groth16.fullProve(
    input, 'zone_membership.wasm', 'zone_membership_final.zkey'
  );
  return { proof, publicSignals };
}

ZK proofs are used ONLY in create_request and submit_offer. After matching, all communication is over the encrypted P2P channel with no proofs.


P2P Communication (libp2p)

Node Setup

// src/p2p/libp2p-node.ts
import { createLibp2p } from 'libp2p';
import { tcp } from '@libp2p/tcp';
import { noise } from '@chainsafe/libp2p-noise';
import { yamux } from '@chainsafe/libp2p-yamux';
import { kadDHT } from '@libp2p/kad-dht';
import { circuitRelayTransport, circuitRelayServer } from '@libp2p/circuit-relay-v2';
import { dcutr } from '@libp2p/dcutr';
import { autoNAT } from '@libp2p/autonat';
import { identify } from '@libp2p/identify';
import { bootstrap } from '@libp2p/bootstrap';
import { pubsubPeerDiscovery } from '@libp2p/pubsub-peer-discovery';
import { gossipsub } from '@chainsafe/libp2p-gossipsub';
import { generateKeyPairFromSeed } from '@libp2p/crypto/keys';

async function deriveLibp2pKey(solanaSecretKey: Uint8Array) {
  const seed = solanaSecretKey.slice(0, 32);
  return await generateKeyPairFromSeed('Ed25519', seed);
}

export async function createP2PNode(solanaSecretKey: Uint8Array, bootstrapPeers: string[]) {
  const privateKey = await deriveLibp2pKey(solanaSecretKey);
  const node = await createLibp2p({
    privateKey,
    addresses: { listen: ['/ip4/0.0.0.0/tcp/0', '/p2p-circuit'] },
    transports: [tcp(), circuitRelayTransport({ discoverRelays: 1 })],
    connectionEncrypters: [noise()],
    streamMuxers: [yamux()],
    peerDiscovery: [
      bootstrap({ list: bootstrapPeers }),
      pubsubPeerDiscovery({ interval: 10000, topics: ['p2p-ride._peer-discovery._p2p._pubsub'] }),
    ],
    services: {
      identify: identify(),
      dht: kadDHT({ clientMode: false }),
      dcutr: dcutr(),
      autonat: autoNAT(),
      relay: circuitRelayServer({
        reservations: { maxReservations: 10, defaultDurationLimit: 120000, defaultDataLimit: BigInt(1 << 20) },
      }),
      pubsub: gossipsub({ allowPublishToZeroTopicPeers: true, emitSelf: false }),
    },
  });
  await node.start();
  return node;
}

P2P Channel

// src/p2p/channel.ts
import type { Libp2p } from 'libp2p';
import type { Stream } from '@libp2p/interface';
import { multiaddr } from '@multiformats/multiaddr';
import { encode, decode } from 'it-length-prefixed';
import { fromString, toString } from 'uint8arrays';

const RIDE_PROTOCOL = '/p2p-ride/1.0.0';

export class P2PChannel {
  private node: Libp2p;
  private stream: Stream | null = null;
  private onMessageCallback: ((msg: any) => void) | null = null;

  constructor(node: Libp2p) { this.node = node; }

  listenForConnection(callback: (msg: any) => void): void {
    this.onMessageCallback = callback;
    this.node.handle(RIDE_PROTOCOL, async ({ stream }) => {
      this.stream = stream;
      this.startReading();
    });
  }

  async connectToPeer(peerMultiaddr: string): Promise<void> {
    const ma = multiaddr(peerMultiaddr);
    this.stream = await this.node.dialProtocol(ma, RIDE_PROTOCOL);
    this.startReading();
  }

  async connectByPeerId(peerIdStr: string): Promise<void> {
    const peerId = peerIdFromString(peerIdStr);
    await this.node.peerRouting.findPeer(peerId);
    this.stream = await this.node.dialProtocol(peerId, RIDE_PROTOCOL);
    this.startReading();
  }

  send(type: string, data: any): void {
    if (!this.stream) return;
    const msg = JSON.stringify({ type, data, ts: Date.now() });
    this.stream.sink(async function* () { yield encode.single(fromString(msg)); }());
  }

  onMessage(callback: (msg: any) => void): void { this.onMessageCallback = callback; }
  close(): void { this.stream?.close(); this.stream = null; }

  private async startReading(): Promise<void> {
    if (!this.stream) return;
    try {
      for await (const chunk of decode(this.stream.source)) {
        this.onMessageCallback?.(JSON.parse(toString(chunk.subarray())));
      }
    } catch (err) { console.error('P2P read error:', err); }
  }
}

Zone GossipSub

// src/p2p/zone-pubsub.ts
function zoneTopic(geohash: string): string { return `/p2p-ride/zone/${geohash}`; }

async function subscribeToZone(node: Libp2p, geohash: string, onRequest: (msg: any) => void) {
  const topic = zoneTopic(geohash);
  node.services.pubsub.subscribe(topic);
  node.services.pubsub.addEventListener('message', (event) => {
    if (event.detail.topic === topic) {
      onRequest(JSON.parse(new TextDecoder().decode(event.detail.data)));
    }
  });
}

async function announceRequest(node: Libp2p, geohash: string, rideRequestPubkey: string, fare: number) {
  await node.services.pubsub.publish(
    zoneTopic(geohash),
    new TextEncoder().encode(JSON.stringify({ type: 'new_request', rideRequestPubkey, fare, timestamp: Date.now() }))
  );
}

P2P Message Types

These flow over the encrypted channel after matching. No ZK proofs, no blockchain.

// src/p2p/messages.ts
type P2PMessage =
  | { type: 'pickup_location'; data: { lat: number; lng: number; address: string; notes?: string } }
  | { type: 'driver_location'; data: { lat: number; lng: number; heading: number; speed: number } }
  | { type: 'rider_location'; data: { lat: number; lng: number } }
  | { type: 'eta_update'; data: { eta_seconds: number } }
  | { type: 'driver_arrived'; data: {} }
  | { type: 'chat_message'; data: { text: string; sender: 'rider' | 'driver' } }
  | { type: 'ride_ended'; data: { final_fare_lamports: number } };

Driver sends driver_location every 2 seconds. Rider sends rider_location every 5 seconds.

Connection Flow

After select_driver tx confirms, the rider reads the driver's UserProfile from Solana to get their multiaddrs and peer_id. The rider calls libp2p.dial(driverMultiaddr) directly. If NAT blocks the connection, libp2p automatically tries AutoNAT, then DCUtR hole punching, then Circuit Relay v2 via another peer in the network. Once connected, an encrypted Noise protocol stream opens. The rider sends pickup_location over this stream. No server is involved at any point.

On-Chain Zone Queries (replacing centralized indexer)

// src/blockchain/queries.ts
export async function queryRideRequestsInZone(connection: Connection, geohash: string): Promise<RideRequestData[]> {
  const filters = [
    { memcmp: { offset: ZONE_GEOHASH_OFFSET, bytes: Buffer.from(geohash).toString('base64') } },
    { memcmp: { offset: STATUS_OFFSET, bytes: Buffer.from([0]).toString('base64') } },
  ];
  const accounts = await connection.getProgramAccounts(RIDE_REQUEST_PROGRAM_ID, { filters });
  return accounts.map(({ pubkey, account }) => ({ pubkey, ...decodeRideRequest(account.data) }));
}

When expanding to multiple zones, fire all queries in parallel with Promise.all. For real-time updates, use connection.onAccountChange to subscribe to specific ride request accounts and connection.onProgramAccountChange for zone-wide activity.


Rider-Side Fare Calculation

// src/fare/calculator.ts
const BASE_FARE_LAMPORTS = 5_000_000;        // 0.005 SOL
const PER_KM_LAMPORTS = 2_000_000;           // 0.002 SOL per km
const ROAD_DISTANCE_MULTIPLIER = 1.4;
const PEAK_HOURS = [7, 8, 9, 17, 18, 19];
const PEAK_MULTIPLIER = 1.2;
const NIGHT_HOURS = [22, 23, 0, 1, 2, 3, 4];
const NIGHT_MULTIPLIER = 1.3;

export interface FareEstimate {
  totalLamports: number;
  totalSOL: number;
  distanceKm: number;
  baseFare: number;
  distanceFare: number;
  timeMultiplier: number;
  breakdown: string;
}

export function calculateFare(
  pickupLat: number, pickupLng: number,
  dropoffLat: number, dropoffLng: number,
  currentHour?: number,
): FareEstimate {
  const straightLineKm = haversineDistanceKm(pickupLat, pickupLng, dropoffLat, dropoffLng);
  const estimatedRoadKm = straightLineKm * ROAD_DISTANCE_MULTIPLIER;
  let timeMultiplier = 1.0;
  const hour = currentHour ?? new Date().getHours();
  if (PEAK_HOURS.includes(hour)) timeMultiplier = PEAK_MULTIPLIER;
  if (NIGHT_HOURS.includes(hour)) timeMultiplier = NIGHT_MULTIPLIER;
  const baseFare = BASE_FARE_LAMPORTS;
  const distanceFare = Math.round(PER_KM_LAMPORTS * estimatedRoadKm);
  const totalLamports = Math.round((baseFare + distanceFare) * timeMultiplier);
  return {
    totalLamports, totalSOL: totalLamports / 1_000_000_000,
    distanceKm: Math.round(estimatedRoadKm * 10) / 10,
    baseFare, distanceFare, timeMultiplier,
    breakdown: `Base: ${baseFare} + Distance (${estimatedRoadKm.toFixed(1)} km): ${distanceFare} x ${timeMultiplier}x = ${totalLamports} lamports`,
  };
}

function haversineDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
  const R = 6371;
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

function toRad(deg: number): number { return (deg * Math.PI) / 180; }

Offer Flow

The rider sets a destination, the app calculates fare, the rider confirms, and create_request escrows rider_offered_fare. A driver sees the request and either sets accepted_fare=true to accept the rider's price, or sets accepted_fare=false with a counter_fare (max 2x rider price, enforced on-chain). The rider reviews all offers showing ETA, rating, and whether the driver accepted or countered. On selection, select_driver sets agreed_fare. If agreed_fare exceeds escrowed amount, the rider tops up. If agreed_fare is less, the difference is refunded. Every fare is visible on-chain for auditability.


Expo Mobile App

Project Setup

npx create-expo-app p2p-ride --template expo-template-blank-typescript
cd p2p-ride

npx expo install expo-location expo-crypto expo-secure-store expo-haptics
npx expo install @react-navigation/native @react-navigation/native-stack
npx expo install react-native-screens react-native-safe-area-context
npx expo install react-native-maps

npm install @solana/web3.js @coral-xyz/anchor buffer react-native-get-random-values

npm install libp2p @libp2p/tcp @chainsafe/libp2p-noise @chainsafe/libp2p-yamux
npm install @libp2p/kad-dht @libp2p/circuit-relay-v2 @libp2p/dcutr @libp2p/autonat
npm install @libp2p/identify @libp2p/bootstrap @libp2p/pubsub-peer-discovery
npm install @chainsafe/libp2p-gossipsub @libp2p/crypto
npm install @multiformats/multiaddr it-pipe it-length-prefixed uint8arrays

npm install snarkjs ngeohash zustand react-native-elements

Directory Structure

p2p-ride/
├── app/
│   ├── _layout.tsx
│   ├── index.tsx                     — role selection (rider/driver)
│   ├── rider/
│   │   ├── _layout.tsx
│   │   ├── request.tsx               — set destination, see fare, confirm
│   │   ├── matching.tsx              — see driver offers, compare fares, select
│   │   ├── ride.tsx                  — active ride with map, P2P, chat
│   │   └── complete.tsx              — rating and receipt
│   ├── driver/
│   │   ├── _layout.tsx
│   │   ├── online.tsx                — go online, wait for requests
│   │   ├── offer.tsx                 — view request, accept/counter fare, submit
│   │   ├── ride.tsx                  — active ride with navigation and P2P
│   │   └── complete.tsx              — rating and earnings
│   └── profile/
│       ├── wallet.tsx                — wallet management and balance
│       └── settings.tsx
├── src/
│   ├── blockchain/
│   │   ├── connection.ts             — Solana connection (configurable RPC)
│   │   ├── wallet.ts                 — keypair generation and SecureStore
│   │   ├── programs/
│   │   │   ├── rideRequest.ts
│   │   │   ├── userRegistry.ts
│   │   │   ├── escrow.ts
│   │   │   └── bootstrapRegistry.ts
│   │   ├── queries.ts                — getProgramAccounts zone queries
│   │   └── subscriptions.ts          — onAccountChange and onProgramAccountChange
│   ├── zk/
│   │   ├── prover.ts
│   │   └── circuits/                 — compiled WASM and zkey bundled with app
│   ├── p2p/
│   │   ├── libp2p-node.ts
│   │   ├── channel.ts
│   │   ├── zone-pubsub.ts
│   │   └── messages.ts
│   ├── fare/
│   │   └── calculator.ts
│   ├── zones/
│   │   ├── geohash.ts
│   │   ├── corner.ts
│   │   └── expansion.ts
│   ├── stores/
│   │   ├── rideStore.ts              — ride lifecycle state machine
│   │   ├── locationStore.ts          — GPS and geohash
│   │   ├── walletStore.ts            — balance and keypair
│   │   └── p2pStore.ts               — channel status and messages
│   ├── hooks/
│   │   ├── useLocation.ts
│   │   ├── useRideRequest.ts
│   │   ├── useDriverMatching.ts
│   │   ├── useP2PChannel.ts
│   │   ├── useZoneExpansion.ts
│   │   └── useFareCalculator.ts
│   ├── components/
│   │   ├── MapView.tsx
│   │   ├── DriverOfferCard.tsx       — shows ETA, rating, fare status (accepted or counter), select button
│   │   ├── FareBreakdown.tsx         — shows distance, base, distance fare, multiplier, total
│   │   ├── RideStatusBar.tsx
│   │   ├── ChatOverlay.tsx
│   │   └── ZoneDebugOverlay.tsx
│   └── utils/
│       ├── constants.ts
│       └── format.ts
├── circuits/
│   └── zone_membership.circom
└── programs/
    ├── ride_request/
    ├── user_registry/
    ├── escrow/
    └── bootstrap_registry/

Wallet Management

// src/blockchain/wallet.ts
import * as SecureStore from 'expo-secure-store';
import { Keypair } from '@solana/web3.js';
import 'react-native-get-random-values';
import { Buffer } from 'buffer';

const WALLET_KEY = 'solana_keypair';

export async function getOrCreateWallet(): Promise<Keypair> {
  const stored = await SecureStore.getItemAsync(WALLET_KEY);
  if (stored) return Keypair.fromSecretKey(Buffer.from(stored, 'base64'));
  const keypair = Keypair.generate();
  await SecureStore.setItemAsync(WALLET_KEY, Buffer.from(keypair.secretKey).toString('base64'));
  return keypair;
}

Build Phases

Execute in order. Each phase must compile, pass tests at 80% coverage with all external calls mocked, and pass the subagent review loop before proceeding.

Phase 0: Environment Setup

Create Expo project with npx create-expo-app p2p-ride --template expo-template-blank-typescript. Install all dependencies listed above. Create the full directory structure. Initialize Anchor workspace in programs/ with anchor init p2p-ride-programs --no-git. Install Circom from circom.io. Configure Jest with Istanbul coverage reporting. Verify everything compiles: npx expo start and anchor build.

After completing, spawn the Jon Skeet subagent to review project structure and config.

Phase 1: Zone System

Implement src/zones/geohash.ts with getGeohash, getZoneBounds, getNeighbors using ngeohash. Implement src/zones/corner.ts with getNearestCorner and getCornerNeighbors. Implement src/zones/expansion.ts with ZoneExpander class supporting getCurrentSearchZones, expand, and getLevel with the 5-step cascade and configurable timeouts. Build a debug screen at app/debug/zones.tsx showing geohash cells as map polygons with corner detection visualization. Write unit tests with ngeohash mocked via jest.mock, covering all 4 corners, boundary conditions, full cascade with fake timers, and Jakarta coordinates (lat=-6.2088, lng=106.8456). For expansion.ts tests, mock the zone query function that would call Solana. Reach 80% coverage.

After tests pass at 80% coverage, spawn both subagents for review.

Phase 2: Fare Calculator

Implement src/fare/calculator.ts with the full calculateFare function including Haversine, road multiplier, base fare, per-km rate, and time-of-day multipliers. Implement src/components/FareBreakdown.tsx showing the calculation visually. Implement src/hooks/useFareCalculator.ts as a reactive hook. Write unit tests with no external deps needed for calculator.ts (pure math). For the hook test, mock the underlying store. Test known distances, all multipliers, and edge cases (very short trips under 500m, very long trips over 50km). Reach 80% coverage.

After tests pass at 80% coverage, spawn both subagents for review.

Phase 3: ZK Proof System

Write circuits/zone_membership.circom as specified above. Compile with circom, run trusted setup with snarkjs, export verification key. Copy compiled artifacts to src/zk/circuits/. Implement src/zk/prover.ts. Write unit tests with snarkjs.groth16.fullProve and snarkjs.groth16.verify mocked via jest.mock. Test that generateZoneProof passes correctly scaled integer inputs to fullProve. Test that a mock proof for valid coordinates returns successfully. Test that the function handles snarkjs errors gracefully. Do not call the real snarkjs library in unit tests. Reach 80% coverage.

After tests pass at 80% coverage, spawn the Jon Skeet subagent for review (no UI to review so skip Jony Ive).

Phase 4: Solana Programs

Implement all 4 Anchor programs: ride_request with rider_offered_fare and counter_fare logic, user_registry with libp2p_peer_id and libp2p_multiaddrs, escrow with topup and partial refund, bootstrap_registry. Write Rust tests using Anchor's BanksClient/bankrun framework which simulates the Solana runtime in-process without requiring a running validator or external database. Test: full lifecycle with accepted fare, counter-fare flow with escrow topup, anti-gouge rejection when counter exceeds 2x, driver rejection via state change detection, cancel and refund, multiple simultaneous offers, and exact escrow math verification (every lamport accounted for). Reach 80% coverage via cargo-tarpaulin. Generate TypeScript IDL client from anchor build output.

After tests pass at 80% coverage, spawn the Jon Skeet subagent for review (no UI to review so skip Jony Ive).

Phase 5: P2P Communication

Implement src/p2p/libp2p-node.ts, src/p2p/channel.ts, src/p2p/zone-pubsub.ts, src/p2p/messages.ts. Implement src/blockchain/queries.ts with getProgramAccounts zone queries and parallel multi-zone search. Implement src/blockchain/subscriptions.ts with onAccountChange and onProgramAccountChange. Write unit tests with the entire libp2p module mocked (mock the node object, node.dialProtocol, node.handle, node.services.pubsub, node.peerRouting). Mock Connection for all Solana query and subscription tests. Test that channel.connectToPeer dials the correct multiaddr. Test that send serializes correctly. Test that incoming messages dispatch to callbacks. Test that zone-pubsub subscribes to correct topic strings and publishes correct payloads. Test that queries.ts constructs correct memcmp filters. Test that subscriptions.ts routes Matched status to the correct handler. No real libp2p nodes or Solana connections in unit tests. Reach 80% coverage.

After tests pass at 80% coverage, spawn the Jon Skeet subagent for review (minimal UI so skip Jony Ive).

Phase 6: Mobile App Integration

Implement all Zustand stores: rideStore with state machine (idle, requesting, matching, matched, riding, completed), locationStore, walletStore, p2pStore. Implement all hooks. Build all screens:

Rider flow: request.tsx shows map with pickup pin, destination input, FareBreakdown component, and Request Ride button that triggers ZK proof then create_request tx. matching.tsx shows list of DriverOfferCards each displaying ETA, rating, accepted/counter fare badge, with zone expansion indicator and select button. ride.tsx shows map with live driver GPS via P2P, ETA, status bar, ChatOverlay. complete.tsx shows receipt with agreed fare and tx link, rating stars.

Driver flow: online.tsx has Go Online toggle that updates zone on-chain and starts libp2p node, shows incoming requests. offer.tsx shows request details with Accept Fare button and Counter Offer input. ride.tsx shows navigation to pickup using P2P pickup_location, GPS streaming, Arrived/Start/End buttons. complete.tsx shows earnings and rating.

Wallet screen shows public key, balance, fund address, transaction history.

Write unit tests for all stores and hooks. Mock all blockchain calls, libp2p operations, expo-location, and expo-secure-store. For store tests, verify every state transition and reject invalid transitions. For hook tests, mock the underlying stores and verify reactive behavior. For component tests, use React Native Testing Library with all external modules mocked. Reach 80% coverage across all new files.

After tests pass at 80% coverage, spawn both subagents for review. This is the heaviest review phase — Jony Ive reviews all screens and the full user flow.

Phase 7: Polish and Edge Cases

Error handling: network drop auto-reconnects libp2p with status indicator, tx failure retries with backoff, ZK proof failure shows error with retry, GPS unavailable prompts to enable, peer unreachable tries DHT then circuit relay. UX polish: loading spinners, haptic feedback, animated driver marker with smooth interpolation, ETA countdown. Security: all txs signed, P2P messages validated, keypair never leaves SecureStore, Noise encryption verified. Offline: queue txs on temporary network drop, resume ride state on app restart, show offline indicator. Accessibility: all elements labeled for screen readers, sufficient contrast, tested with VoiceOver and TalkBack. Write unit tests for all error handling paths and retry logic with external dependencies mocked. Reach 80% coverage on new and modified files.

After tests pass at 80% coverage, spawn both subagents for final review.


Multi-Agent Review Loop

After completing each build phase, you (the main Claude Code agent) must spawn two reviewing subagents using Claude Code's built-in subagent spawning. Do not proceed to the next phase until both subagents score 10/10.

How to Spawn Subagents

Use Claude Code's native subagent capability. For each review cycle, spawn two subagents in parallel. Give each subagent the persona prompt below as the start of its instructions, followed by the files to review and the relevant spec section from this document.

Subagent 1: "Jon Skeet" (Code Quality)

Spawn a subagent with these instructions:

You are Jon Skeet, widely regarded as one of the greatest programmers alive. You have mass expertise in systems design, cryptographic protocols, blockchain development, TypeScript, Rust, and React Native. Your job is to review the code for this build phase with ruthless technical precision. You evaluate on six criteria: CORRECTNESS (does the code do what the spec says, logic bugs, edge cases, race conditions), SECURITY (vulnerabilities, smart contract exploits, ZK circuit soundness, P2P MitM risks), PERFORMANCE (will it meet 15 sec match and 3 sec ZK proof timing, unnecessary allocations, O(n^2) loops), ARCHITECTURE (follows spec, clean separation of concerns, no hidden coupling), ROBUSTNESS (behavior on network failures, malicious peers, concurrent access), CODE_QUALITY (readable, well-typed, documented where non-obvious, idiomatic). Rate each criterion 1-10. Your overall score is the MINIMUM of all criteria — a single 7 anywhere means overall 7. If below 10, provide specific actionable feedback with file names, line numbers, and exact changes needed. Respond in this exact format: CORRECTNESS X/10, SECURITY X/10, PERFORMANCE X/10, ARCHITECTURE X/10, ROBUSTNESS X/10, CODE_QUALITY X/10, OVERALL X/10, then ISSUES listing each issue with file path and fix, then APPROVED YES or NO.

After the persona instructions, include: "Here is the spec for this phase:" followed by the relevant section of this document, then "Here are the files to review:" followed by the contents of all files created or modified in this phase. The subagent should read the files from disk using its own tools.

Subagent 2: "Jony Ive" (Design and UX)

Spawn a subagent with these instructions:

You are Jony Ive, the legendary designer behind Apple's most iconic products. You believe design is not just how it looks but how it works. You have deep expertise in mobile UX, interaction design, and making complex technology feel effortless. Your job is to review the user experience of this build phase. The user should never feel the blockchain or P2P complexity — it should feel as simple as opening a door. You evaluate on six criteria: SIMPLICITY (minimal flow, no unnecessary steps or decisions, can anything be removed), CLARITY (user always knows what is happening, immediate feedback, meaningful loading states, fare understandable at a glance), DELIGHT (feels good to use, smooth transitions, right haptic feedback, fluid map interaction), TRUST (inspires confidence, user can verify fare is fair, P2P feels safe, wallet management approachable), ACCESSIBILITY (screen reader support, color contrast, touch targets, one-hand usability), INFORMATION_HIERARCHY (most important info like fare ETA rating is most prominent, secondary info like zone level and tx signatures is backgrounded). Rate each criterion 1-10. Your overall score is the MINIMUM of all criteria. If below 10, provide specific actionable feedback with screen name, component, what user sees vs should see, and exact changes. Respond in this exact format: SIMPLICITY X/10, CLARITY X/10, DELIGHT X/10, TRUST X/10, ACCESSIBILITY X/10, INFORMATION_HIERARCHY X/10, OVERALL X/10, then ISSUES listing each issue with screen and fix, then APPROVED YES or NO.

After the persona instructions, include the user flow description for the phase and the paths to all UI component files. The subagent should read and review them using its own tools.

Review Loop Procedure

After completing each phase, execute this loop:

Step 1: Run all unit tests for the phase. They must pass with 80% or higher coverage before review begins.

Step 2: Spawn both subagents in parallel. Give each the files from this phase and the relevant spec section.

Step 3: Read both subagents' responses. Extract their OVERALL scores and ISSUES lists.

Step 4: If both scores are 10/10 and both say APPROVED YES, proceed to the next phase.

Step 5: If either score is below 10, fix every issue listed by that subagent. Do not skip or defer issues. After fixing, re-run unit tests to confirm nothing broke.

Step 6: After fixing, spawn both subagents again with the updated files. This is a new review iteration.

Step 7: Repeat until both approve or you have iterated 5 times. If 5 iterations pass without dual 10/10, log all remaining issues as tech debt in a file called TECH_DEBT.md at the project root, noting the phase, issue, and which reviewer flagged it. Then proceed to the next phase.

Which Subagents Review Which Phases

Phase 0 (Setup): Jon Skeet only. No UI to review. Phase 1 (Zones): Both. Jon Skeet reviews zone logic and tests. Jony Ive reviews the debug map screen. Phase 2 (Fare): Both. Jon Skeet reviews calculation accuracy and edge cases. Jony Ive reviews FareBreakdown component. Phase 3 (ZK): Jon Skeet only. No UI to review. Phase 4 (Solana): Jon Skeet only. No UI to review. Phase 5 (P2P): Jon Skeet only. Minimal UI. Phase 6 (App Integration): Both. This is where Jony Ive does the heaviest review across all screens and full user flow. Phase 7 (Polish): Both. Final pass on error handling, accessibility, and UX polish.


Testing Strategy

Unit Test Requirements

All unit tests must meet these constraints:

  • Minimum 80% code coverage measured by Istanbul/nyc for TypeScript and cargo-tarpaulin for Rust. Run coverage after every phase and do not proceed if below 80%.
  • Zero external dependency calls. Every external dependency must be mocked. This includes: all Solana RPC calls (Connection, getProgramAccounts, sendTransaction, onAccountChange), all libp2p operations (dial, publish, subscribe, stream read/write), all snarkjs proof generation and verification, all expo-location GPS reads, all expo-secure-store reads and writes, all ngeohash library calls, and all filesystem or network I/O.
  • No local or in-memory databases. Do not spin up SQLite, LevelDB, Redis, or any other database for tests. All state under test lives in plain objects, Zustand stores initialized with mock data, or Anchor program test accounts created via the Anchor testing framework's built-in BanksClient.
  • Use Jest for TypeScript tests. Use jest.mock() at the module level to replace external modules. For Solana interactions, create mock Connection objects that return predetermined account data. For libp2p, mock the node object and its services (pubsub, dht, relay). For ZK proofs, mock snarkjs.groth16.fullProve to return a fixture proof object and snarkjs.groth16.verify to return true or false as needed.
  • Use Anchor's built-in test framework (BanksClient/bankrun) for Rust program tests. These do not require a running validator or any external process — they simulate the Solana runtime in-process.
  • Each test file must be colocated with the module it tests using the pattern src/module/__tests__/module.test.ts or for Rust, inline #[cfg(test)] modules.

What to Test Per Module

src/zones/geohash.ts: Mock ngeohash. Test encoding returns correct geohash strings for known coordinates. Test decoding returns correct bounding boxes. Test getNeighbors returns 8 correct neighbor hashes. Test Jakarta coordinates (lat=-6.2088, lng=106.8456) specifically.

src/zones/corner.ts: No external deps to mock. Test all 4 corner results (NE, NW, SE, SW) for points in each quadrant of a cell. Test exact midpoint edge cases. Test getCornerNeighbors returns the correct 3 neighbors for each corner.

src/zones/expansion.ts: Mock the zone query function (the one that calls Solana). Test that Step 1 returns only the primary zone. Test that Step 2 expands to exactly 3 corner neighbors. Test that Step 3 returns the parent zone. Test that Step 4 returns 3 parent-level neighbors. Test that Step 5 returns empty. Test timeout behavior using Jest fake timers. Test that finding a driver at any step stops expansion.

src/fare/calculator.ts: No external deps to mock. Test a 5 km trip returns expected fare. Test a 0.3 km trip uses base fare as floor. Test a 60 km trip. Test peak hour multiplier applies during hours 7-9 and 17-19. Test night multiplier applies during hours 22-4. Test default multiplier outside those windows. Test that totalLamports is always a positive integer.

src/zk/prover.ts: Mock snarkjs.groth16.fullProve and snarkjs.groth16.verify. Test that generateZoneProof passes correct integer-scaled inputs to fullProve. Test that a mock proof for coordinates inside the zone returns valid. Test that a mock proof for coordinates outside the zone returns invalid. Test that the function handles snarkjs errors gracefully.

src/p2p/channel.ts: Mock the libp2p node object entirely. Mock node.dialProtocol to return a mock stream. Mock node.handle to capture the protocol handler. Test that connectToPeer calls dialProtocol with the correct multiaddr and protocol string. Test that send serializes messages correctly and writes to the stream sink. Test that incoming messages are deserialized and dispatched to the callback. Test close tears down the stream.

src/p2p/zone-pubsub.ts: Mock node.services.pubsub (subscribe, publish, addEventListener). Test subscribeToZone subscribes to the correct topic string. Test announceRequest publishes the correct JSON payload. Test that incoming pubsub messages are parsed and forwarded to the callback. Test malformed messages do not crash the handler.

src/p2p/messages.ts: No external deps. Test serialization round-trip for every P2P message type (pickup_location, driver_location, rider_location, eta_update, driver_arrived, chat_message, ride_ended). Test that malformed JSON throws or returns an error rather than silently passing.

src/blockchain/wallet.ts: Mock expo-secure-store (getItemAsync, setItemAsync). Test that first call generates a new keypair and stores it. Test that subsequent calls return the same keypair from storage. Test that stored data round-trips correctly through base64 encoding.

src/blockchain/queries.ts: Mock Connection.getProgramAccounts to return predetermined account arrays. Test that memcmp filters are constructed with correct offsets and bytes. Test that parallel zone queries fire all promises simultaneously. Test empty results. Test that results are correctly decoded.

src/blockchain/subscriptions.ts: Mock Connection.onAccountChange and Connection.onProgramAccountChange. Test that subscription callbacks fire with decoded data. Test that status=Matched triggers the correct handler path for selected vs non-selected drivers.

src/stores/rideStore.ts: No external deps to mock (pure state logic). Test every valid state transition: idle to requesting, requesting to matching, matching to matched, matched to riding, riding to completed. Test that invalid transitions are rejected (e.g., idle to riding directly). Test cancel from requesting and matching states. Test dispute from riding state.

src/hooks/ files: Mock the underlying stores and blockchain calls. Test that hooks return correct reactive state. Test that action functions dispatch to the right store methods.

Rust program tests (programs/): Use Anchor's BanksClient test framework. Test create_request with valid ZK proof stores correct data. Test submit_offer with accepted_fare=true. Test submit_offer with counter_fare at exactly 2x (should succeed) and at 2x+1 (should fail). Test select_driver updates status, selected_driver, and adjusts escrow. Test complete_ride releases exact escrow amount to driver. Test cancel_request refunds full escrow. Test dispute_ride locks funds. Test that only the rider can call select_driver and cancel_request. Test that driver_offers caps at 10.

Integration Tests

These are separate from unit tests and are allowed to make real calls between modules, but still do not use external services or databases.

Full Solana lifecycle using BanksClient: register, request with fare, offer accept, select, start, complete. Counter-fare flow: request, counter offer, select with topup, complete. Anti-gouge: counter exceeding 2x rider fare rejected. Zone expansion with mocked Solana responses simulating sparse zones. Escrow math: verify every lamport is accounted for across all flows with no rounding loss.

End-to-End Test

Two devices or simulators. Rider opens app, sets destination, sees fare, confirms. Driver goes online, sees request, accepts fare, submits offer. Rider sees offer, selects driver. P2P channel opens. Rider sends pickup location. Driver navigates, streams GPS. Driver arrives. Ride starts on-chain. Ride completes on-chain. Escrow releases. Both rate each other. Verify on-chain: escrow released, accounts closed, ratings updated.


Deployment

All components are either on-device or on-chain. We operate zero servers.

Solana programs deploy from Devnet to Mainnet-Beta with anchor deploy. The mobile app builds via Expo EAS Build to TestFlight and Play Store. Every user's device runs a libp2p node that participates as a peer, relay, and DHT node. RPC access goes through any Solana node and is user-configurable.

Environment Variables

SOLANA_RPC_URL=https://api.devnet.solana.com
RIDE_REQUEST_PROGRAM_ID=RiDE...
USER_REGISTRY_PROGRAM_ID=USR...
ESCROW_PROGRAM_ID=ESC...
BOOTSTRAP_REGISTRY_PROGRAM_ID=BOOT...
BOOTSTRAP_PEERS=/ip4/x.x.x.x/tcp/9000/p2p/12D3KooW...,/ip4/y.y.y.y/tcp/9000/p2p/12D3KooW...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment