Skip to content

Instantly share code, notes, and snippets.

@ibam
Last active March 15, 2026 16:30
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

  1. A rider opens the app, sets a destination, and the app calculates a fare client-side using Haversine distance.
  2. The rider posts a ride request on-chain containing a blinded zone ID (ZK proof of zone membership), the offered fare, and escrowed SOL.
  3. Nearby drivers discover the request via libp2p GossipSub and on-chain queries.
  4. Drivers submit offers on-chain — they can accept the rider's fare or counter-offer (capped at 2x the rider's price).
  5. Each driver's offer includes a ZK proof that they hold a valid, non-expired credential from a registered verifier, without revealing which verifier or any personal data.
  6. The rider selects one driver.
  7. A single on-chain transaction atomically locks the match and rejects all others.
  8. The selected driver and rider establish a direct encrypted P2P channel via libp2p.
  9. All real-time communication (exact pickup, GPS, chat, ETA) flows over this channel.
  10. On completion, the escrow splits: 95% to the driver, 4% to the driver's verifier (split between verifier profit and an insurance pool based on risk model), and 1% to the protocol treasury.

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 and credential 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
  • Fare split: 95% driver, 4% verifier+insurance, 1% protocol treasury
  • Driver vetting: decentralized verifier network with staked accountability

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 with split payout) costs about $0.004 total.

Architecture Design Reviews

ADR-1: ZK proofs are generated only when broadcasting a request or responding to one. Never for ongoing comms. Proof generation takes 2-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.

ADR-8: Drivers must hold a valid credential from a registered verifier to participate. Verifiers are independent entities (driving schools, identity services, transport authorities, cooperatives) that stake SOL and earn ongoing revenue from their drivers' rides. A driver can only have one active verifier at a time — the old credential must be revoked before a new one can activate, with a mandatory 48-hour gap between revocation and new activation. This ensures clean attribution when incidents occur.

ADR-9: Verifier stake scales with the number of active drivers they have credentialed using the formula base_stake + (per_driver_stake * driver_count^1.1). The base_stake is 5 SOL and per_driver_stake is 0.5 SOL. This creates superlinear financial exposure at scale, preventing verifiers from carelessly rubber-stamping large numbers of drivers.

ADR-10: The 4% verifier allocation from each ride is split between the verifier's wallet and an insurance pool. The split is determined by a dynamic insurance rate that starts at 50% for new verifiers and decreases by 5 percentage points for every 6 months of clean operation (incident rate below 1% of drivers), down to a floor of 15%. Each upheld dispute increases the rate by 10 percentage points, capped at 75%. Each verifier has their own insurance sub-pool. 5% of each verifier's insurance contributions flow to a global shared insurance pool as a last-resort safety net.

ADR-11: Rider ratings flow uphill to verifiers. Each verifier has a derived rating computed as the weighted average of all their active drivers' ratings. This verifier rating is displayed to riders alongside driver offers. If a driver's rolling average drops below 4.0 the driver gets a warning. Below 3.5 the verifier is notified on-chain. Below 3.0 the credential is auto-suspended pending re-verification within 7 days or auto-revocation. Repeated incidents from the same verifier's drivers compound slashing penalties.


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 7 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), verifier (Pubkey, the driver's current verifier for display purposes), verifier_rating (u16, verifier's current derived rating scaled by 100), verifier_trust_tier (u8, 0=Bronze 1=Silver 2=Gold 3=Platinum), 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 of zone membership, 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's combined ZK proof (zone membership AND valid credential via merkle proof), look up driver's active verifier and verifier rating from on-chain accounts, populate verifier fields in the DriverOffer, append 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. Splits the agreed_fare from escrow atomically in a single transaction: 95% to the driver's wallet, 4% to the verifier allocation (sent to a CPI call into the insurance program which splits it between verifier profit and insurance sub-pool based on the verifier's current insurance rate), 1% to the protocol treasury PDA. Set status=Completed. Close RideRequest 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), rating_rolling_avg (u16, scaled by 100, recomputed on each new rating from the last 50 rides), rides_completed (u32), rides_as_rater (u32, how many rides this wallet has completed as a rider — used to weight their rating influence), stake_lamports (u64, drivers must stake), is_active (bool), current_zone (Option String, geohash-6), active_verifier (Option Pubkey, the driver's single current verifier), credential_status (enum: None/Active/Suspended/PendingSwitch), credential_suspended_at (Option i64), 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 which also updates the target driver's rolling average and triggers the rating escalation ladder (warning at sub-4.0, verifier notification at sub-3.5, auto-suspend at sub-3.0) and updates the verifier's derived rating.

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_split(ride_request, driver_amount, verifier_allocation, treasury_amount) called within complete_ride to atomically distribute to all three destinations, 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.

Program 5: verifier_registry

Verifier account (PDA from authority wallet) fields: authority (Pubkey), stake_lamports (u64), required_stake (u64, computed from formula), active_driver_count (u32), total_credentials_issued (u32), total_credentials_revoked (u32), slash_count (u32), verification_types (Vec of VerificationType enum: IdentityCheck/DrivingLicense/BackgroundCheck/SafetyTraining/VehicleInspection), derived_rating (u16, weighted average of all active drivers' ratings scaled by 100), insurance_rate_bps (u16, current insurance rate as basis points 0-7500 representing 0%-75%), clean_months (u16, consecutive months with incident rate below 1%), registered_at (i64), is_active (bool), metadata_uri (String, off-chain info like name region process), bump (u8).

Trust tier derivation (computed client-side from insurance_rate_bps): Platinum when 1500-2500 (15-25%), Gold when 2500-4000, Silver when 4000-5500, Bronze when 5500-7500.

Instructions:

  1. register_verifier(verification_types, metadata_uri) — caller stakes at least base_stake (5 SOL), creates Verifier account, sets insurance_rate_bps to 5000 (50%), clean_months to 0.

  2. add_stake(amount) — verifier adds more stake.

  3. withdraw_stake(amount) — cannot go below required_stake for current driver count.

  4. update_insurance_rate() — callable by anyone, recalculates insurance_rate_bps: base 5000 minus (clean_months / 6 * 500) plus (slash_count * 1000), clamped to [1500, 7500].

  5. update_derived_rating(verifier) — callable by anyone after a driver rating changes, recomputes derived_rating as weighted average of all active drivers' rolling averages weighted by rides_completed.

  6. deactivate_verifier() — self-deactivate. All drivers enter PendingSwitch with 30-day grace period. Stake locked until all drivers switched or credentials expired.

Required stake formula enforced on-chain: required = 5_000_000_000 + (500_000_000 * (active_driver_count as f64).powf(1.1) as u64). All values in lamports.

Program 6: credential_manager

DriverCredential account (PDA from driver wallet) fields: driver (Pubkey), verifier (Pubkey), credential_type_flags (u8, bitmask of VerificationTypes), issued_at (i64), expires_at (i64), credential_hash ([u8; 32], hash of off-chain credential details), is_revoked (bool), revoked_at (Option i64), bump (u8).

CredentialMerkleTree account: stores the merkle tree of all active credential leaf hashes. Root is what ZK circuit verifies against. Maximum depth 20 (supports over 1 million credentials).

Instructions:

  1. issue_credential(driver, credential_type_flags, expires_at, credential_hash) — only callable by registered active verifier. Checks driver has no existing active credential (single verifier rule). If driver has credential_status=PendingSwitch, checks 48 hours have passed since revocation. Creates DriverCredential. Adds leaf to merkle tree. Increments verifier active_driver_count. Checks verifier stake >= required_stake. Sets driver's active_verifier and credential_status=Active via CPI.

  2. revoke_credential(driver) — callable by issuing verifier, governance, or auto-triggered by rating system. Marks is_revoked=true. Removes leaf from merkle tree. Decrements active_driver_count. Sets credential_status=PendingSwitch and credential_suspended_at=now via CPI.

  3. renew_credential(driver, new_expires_at, new_credential_hash) — callable by issuing verifier. Updates expiry and hash. Updates leaf in merkle tree.

Program 7: credential_policy

CredentialPolicy account (singleton PDA) fields: required_type_flags (u8), min_verifier_stake (u64), max_credential_age_seconds (i64), min_verifier_rating (u16, scaled by 100), authority (Pubkey, DAO multisig), bump (u8).

InsuranceConfig account (singleton PDA) fields: base_insurance_rate_bps (u16, default 5000), clean_period_discount_bps (u16, default 500), incident_penalty_bps (u16, default 1000), min_insurance_rate_bps (u16, default 1500), max_insurance_rate_bps (u16, default 7500), clean_period_months (u8, default 6), global_pool_share_bps (u16, default 500 meaning 5% of insurance goes to global pool), authority (Pubkey), bump (u8).

VerifierInsurancePool account (PDA from verifier pubkey) fields: verifier (Pubkey), balance (u64), total_deposited (u64), total_paid_out (u64), bump (u8).

GlobalInsurancePool account (singleton PDA) fields: balance (u64), total_deposited (u64), total_paid_out (u64), bump (u8).

ProtocolTreasury account (singleton PDA) fields: balance (u64), authority (Pubkey, DAO multisig), bump (u8).

Instructions:

  1. update_policy(required_type_flags, min_verifier_stake, max_credential_age_seconds, min_verifier_rating) — only callable by DAO authority.

  2. update_insurance_config(...) — only callable by DAO authority.

  3. deposit_insurance(verifier, amount, global_share) — called via CPI from complete_ride. Deposits insurance portion into VerifierInsurancePool. Forwards global_share to GlobalInsurancePool.

  4. payout_insurance(verifier, recipient, amount, reason) — callable by dispute resolution authority. Pays from VerifierInsurancePool first, then verifier stake, then GlobalInsurancePool.

  5. submit_dispute(verifier, driver, evidence_uri, bond) — any user posts bond. Creates Dispute account.

  6. vote_dispute(dispute, vote: Uphold/Reject) — callable by DAO members or arbitration multisig.

  7. resolve_dispute(dispute) — if upheld: slash verifier stake (compounding: Nth incident slashes N * base_slash), payout insurance to rider, refund disputer bond, increment slash_count, trigger insurance rate recalculation. If rejected: forfeit disputer bond to verifier insurance pool.

  8. deposit_treasury(amount) — called via CPI from complete_ride for 1% protocol fee.


ZK Privacy Layer

Circuit

The driver's circuit proves two things simultaneously: the driver is in zone X, and the driver holds a valid non-expired credential that exists in the on-chain credential merkle tree. Neither the exact location nor the credential identity is revealed.

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

template MerkleTreeVerifier(depth) {
    signal input leaf;
    signal input root;
    signal input pathElements[depth];
    signal input pathIndices[depth];
    signal output isValid;

    signal hashes[depth + 1];
    hashes[0] <== leaf;

    component hashers[depth];
    component muxLeft[depth];
    component muxRight[depth];

    for (var i = 0; i < depth; i++) {
        muxLeft[i] = Mux1();
        muxLeft[i].c[0] <== hashes[i];
        muxLeft[i].c[1] <== pathElements[i];
        muxLeft[i].s <== pathIndices[i];

        muxRight[i] = Mux1();
        muxRight[i].c[0] <== pathElements[i];
        muxRight[i].c[1] <== hashes[i];
        muxRight[i].s <== pathIndices[i];

        hashers[i] = Poseidon(2);
        hashers[i].inputs[0] <== muxLeft[i].out;
        hashers[i].inputs[1] <== muxRight[i].out;
        hashes[i + 1] <== hashers[i].out;
    }

    component rootCheck = IsEqual();
    rootCheck.in[0] <== hashes[depth];
    rootCheck.in[1] <== root;
    isValid <== rootCheck.out;
}

template VettedDriverProof() {
    signal input lat;
    signal input lng;
    signal input minLat;
    signal input maxLat;
    signal input minLng;
    signal input maxLng;
    signal input credentialHash;
    signal input verifierPubkey;
    signal input credentialTypeFlags;
    signal input issuedAt;
    signal input expiresAt;
    signal input merkleProofPath[20];
    signal input merkleProofIndices[20];
    signal input currentTimestamp;
    signal input credentialMerkleRoot;
    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;
    signal zoneValid <== ge1.out * le1.out * ge2.out * le2.out;

    component notExpired = LessEqThan(64);
    notExpired.in[0] <== currentTimestamp;
    notExpired.in[1] <== expiresAt;

    component leafHash = Poseidon(5);
    leafHash.inputs[0] <== credentialHash;
    leafHash.inputs[1] <== verifierPubkey;
    leafHash.inputs[2] <== credentialTypeFlags;
    leafHash.inputs[3] <== issuedAt;
    leafHash.inputs[4] <== expiresAt;

    component merkleVerify = MerkleTreeVerifier(20);
    merkleVerify.leaf <== leafHash.out;
    merkleVerify.root <== credentialMerkleRoot;
    for (var i = 0; i < 20; i++) {
        merkleVerify.pathElements[i] <== merkleProofPath[i];
        merkleVerify.pathIndices[i] <== merkleProofIndices[i];
    }

    valid <== zoneValid * notExpired.out * merkleVerify.isValid;
    valid === 1;
}

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

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 };
}

export async function generateVettedDriverProof(
  lat: number, lng: number,
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
  credential: { credentialHash: string; verifierPubkey: string; credentialTypeFlags: number; issuedAt: number; expiresAt: number },
  merkleProof: { pathElements: string[]; pathIndices: number[]; root: string },
) {
  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),
    credentialHash: credential.credentialHash, verifierPubkey: credential.verifierPubkey,
    credentialTypeFlags: credential.credentialTypeFlags,
    issuedAt: credential.issuedAt, expiresAt: credential.expiresAt,
    merkleProofPath: merkleProof.pathElements, merkleProofIndices: merkleProof.pathIndices,
    currentTimestamp: Math.floor(Date.now() / 1000), credentialMerkleRoot: merkleProof.root,
  };
  const { proof, publicSignals } = await snarkjs.groth16.fullProve(
    input, 'vetted_driver.wasm', 'vetted_driver_final.zkey'
  );
  return { proof, publicSignals };
}

Riders use the simpler zone-only proof for create_request. Drivers use the combined zone+credential proof for submit_offer. Both proofs are used only at match time.


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> {
    this.stream = await this.node.dialProtocol(multiaddr(peerMultiaddr), 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;
    this.stream.sink(async function* () { yield encode.single(fromString(JSON.stringify({ type, data, ts: Date.now() }))); }());
  }
  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

// 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. Once connected, an encrypted Noise protocol stream opens. No server is involved.

On-Chain Zone Queries

// 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 and connection.onProgramAccountChange.


Rider-Side Fare Calculation

// src/fare/calculator.ts
const BASE_FARE_LAMPORTS = 5_000_000;
const PER_KM_LAMPORTS = 2_000_000;
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 accepts the rider's price or counter-offers (max 2x, enforced on-chain). Each driver's offer includes their verifier name, trust tier (Platinum/Gold/Silver/Bronze), verifier rating, and driver rating. The rider reviews all offers with this info. On selection, select_driver sets agreed_fare and adjusts escrow if needed.


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 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/verifier)
│   ├── rider/
│   │   ├── request.tsx               — set destination, see fare, confirm
│   │   ├── matching.tsx              — driver offers with verifier badges, compare, select
│   │   ├── ride.tsx                  — active ride with map, P2P, chat
│   │   └── complete.tsx              — rating, receipt, verifier feedback
│   ├── driver/
│   │   ├── credential.tsx            — get verified: browse verifiers, initiate vetting
│   │   ├── online.tsx                — go online (blocked if no active credential), wait
│   │   ├── offer.tsx                 — view request, accept/counter fare, submit with ZK proof
│   │   ├── ride.tsx                  — active ride with navigation and P2P
│   │   └── complete.tsx              — earnings breakdown (95% share shown)
│   ├── verifier/
│   │   ├── register.tsx              — register as verifier, stake SOL
│   │   ├── dashboard.tsx             — drivers, ratings, insurance rate, earnings, stake
│   │   ├── issue.tsx                 — issue credential to a driver
│   │   └── disputes.tsx              — view and respond to disputes
│   └── profile/
│       ├── wallet.tsx                — wallet management and balance
│       └── settings.tsx
├── src/
│   ├── blockchain/
│   │   ├── connection.ts
│   │   ├── wallet.ts
│   │   ├── programs/
│   │   │   ├── rideRequest.ts
│   │   │   ├── userRegistry.ts
│   │   │   ├── escrow.ts
│   │   │   ├── bootstrapRegistry.ts
│   │   │   ├── verifierRegistry.ts
│   │   │   ├── credentialManager.ts
│   │   │   └── credentialPolicy.ts
│   │   ├── queries.ts
│   │   └── subscriptions.ts
│   ├── zk/
│   │   ├── prover.ts                 — both zone-only and vetted-driver proofs
│   │   ├── merkle.ts                 — merkle proof retrieval and caching
│   │   └── circuits/
│   ├── p2p/
│   │   ├── libp2p-node.ts
│   │   ├── channel.ts
│   │   ├── zone-pubsub.ts
│   │   └── messages.ts
│   ├── fare/
│   │   └── calculator.ts
│   ├── zones/
│   │   ├── geohash.ts
│   │   ├── corner.ts
│   │   └── expansion.ts
│   ├── verifier/
│   │   ├── insurance.ts              — insurance rate calculation (mirrors on-chain)
│   │   ├── trustTier.ts              — Platinum/Gold/Silver/Bronze from insurance rate
│   │   └── ratingAggregator.ts       — verifier derived rating computation
│   ├── stores/
│   │   ├── rideStore.ts
│   │   ├── locationStore.ts
│   │   ├── walletStore.ts
│   │   ├── p2pStore.ts
│   │   ├── credentialStore.ts        — driver credential status, merkle proof cache
│   │   └── verifierStore.ts          — verifier dashboard state
│   ├── hooks/
│   │   ├── useLocation.ts
│   │   ├── useRideRequest.ts
│   │   ├── useDriverMatching.ts
│   │   ├── useP2PChannel.ts
│   │   ├── useZoneExpansion.ts
│   │   ├── useFareCalculator.ts
│   │   ├── useCredential.ts
│   │   └── useVerifierDashboard.ts
│   ├── components/
│   │   ├── MapView.tsx
│   │   ├── DriverOfferCard.tsx       — ETA, driver rating, fare, VerifierBadge
│   │   ├── VerifierBadge.tsx         — tappable: verifier name, tier, rating, driver count
│   │   ├── FareBreakdown.tsx
│   │   ├── EarningsBreakdown.tsx     — driver post-ride: 95% share, verifier cut, protocol fee
│   │   ├── RideStatusBar.tsx
│   │   ├── ChatOverlay.tsx
│   │   └── ZoneDebugOverlay.tsx
│   └── utils/
│       ├── constants.ts
│       └── format.ts
├── circuits/
│   ├── zone_membership.circom
│   └── vetted_driver.circom
└── programs/
    ├── ride_request/
    ├── user_registry/
    ├── escrow/
    ├── bootstrap_registry/
    ├── verifier_registry/
    ├── credential_manager/
    └── credential_policy/

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. Install all dependencies. Create full directory structure. Initialize Anchor workspace. Install Circom. Configure Jest with Istanbul coverage. Verify compilation.

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

Phase 1: Zone System

Implement src/zones/geohash.ts, corner.ts, expansion.ts. Build debug screen. Write unit tests with ngeohash mocked, covering all corners, boundary conditions, cascade with fake timers, Jakarta coordinates. Mock zone query function for expansion tests. Reach 80% coverage.

After tests pass, spawn both subagents for review.

Phase 2: Fare Calculator

Implement src/fare/calculator.ts, FareBreakdown.tsx, useFareCalculator.ts. Write unit tests (pure math, no mocks needed for calculator). Test known distances, all multipliers, edge cases. Reach 80% coverage.

After tests pass, spawn both subagents for review.

Phase 3: ZK Proof System

Write both circuits: zone_membership.circom and vetted_driver.circom (with MerkleTreeVerifier and Poseidon). Compile both, run trusted setups. Implement src/zk/prover.ts with generateZoneProof and generateVettedDriverProof. Implement src/zk/merkle.ts. Write unit tests with snarkjs mocked. Test vetted driver proof passes correct inputs including merkle path. Reach 80% coverage.

After tests pass, spawn Jon Skeet subagent (no UI, skip Jony Ive).

Phase 4: Solana Programs — Core

Implement ride_request (with 95/4/1 split in complete_ride), user_registry (with active_verifier, credential_status, rating escalation), escrow (with release_split), bootstrap_registry. Write BanksClient tests. Test split payout math exactness: for 1_000_000_000 lamport fare verify 950_000_000 to driver, 40_000_000 to verifier allocation, 10_000_000 to treasury, sum equals original. Test rounding: remainder goes to driver. Test rating escalation thresholds. Test counter-fare and anti-gouge. Reach 80% coverage.

After tests pass, spawn Jon Skeet subagent (no UI, skip Jony Ive).

Phase 5: Solana Programs — Verifier and Credential

Implement verifier_registry, credential_manager, credential_policy. Write BanksClient tests. Test: verifier registration with base stake. Required_stake increases superlinearly with drivers. Withdraw below required fails. Single-verifier rule: issue to driver with active credential fails. 48-hour gap enforcement with clock warp. Insurance rate calculation across scenarios. Insurance deposit split between sub-pool and global. Payout waterfall: sub-pool then stake then global. Full dispute flow. Compounding slash. Credential expiry with clock warp. Reach 80% coverage.

After tests pass, spawn Jon Skeet subagent (no UI, skip Jony Ive).

Phase 6: P2P Communication

Implement libp2p-node.ts, channel.ts, zone-pubsub.ts, messages.ts, queries.ts, subscriptions.ts. Write unit tests with libp2p and Connection fully mocked. No real nodes or connections. Reach 80% coverage.

After tests pass, spawn Jon Skeet subagent (minimal UI, skip Jony Ive).

Phase 7: Mobile App Integration

Implement all stores including credentialStore and verifierStore. Implement all hooks including useCredential and useVerifierDashboard. Implement src/verifier/insurance.ts, trustTier.ts, ratingAggregator.ts. Build all screens:

Rider flow: request.tsx with fare, matching.tsx with DriverOfferCards showing VerifierBadge (trust tier, verifier rating), ride.tsx with P2P, complete.tsx with rating.

Driver flow: credential.tsx listing available verifiers sorted by rating with trust tiers, online.tsx disabled without active credential, offer.tsx submitting with combined ZK proof, ride.tsx, complete.tsx with EarningsBreakdown showing 95/4/1 split.

Verifier flow: register.tsx for staking, dashboard.tsx showing drivers and ratings and insurance rate and earnings and stake requirements, issue.tsx, disputes.tsx.

Write unit tests for all stores, hooks, components. Mock all externals. Test DriverOfferCard renders verifier badge. Test EarningsBreakdown computes correct split display. Test credentialStore handles all states. Reach 80% coverage.

After tests pass, spawn both subagents. Heaviest Jony Ive review: all screens, verifier badge UX, credential onboarding, earnings transparency, dispute flow.

Phase 8: Polish and Edge Cases

Error handling for all failure modes including credential expiry, verifier deactivation grace period. UX polish. Security hardening including merkle root freshness checks. Offline resilience. Accessibility. Rating anti-manipulation: weight by fare amount, discount new wallets under 5 rides, time-weighted moving average over last 50 rides. Write tests for error paths. Reach 80% coverage.

After tests pass, 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 followed by the files to review and the relevant spec section.

Subagent 1: "Jon Skeet" (Code Quality)

Spawn 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. Review the code with ruthless technical precision. Evaluate CORRECTNESS, SECURITY (including insurance pool drainage, verifier collusion, stale merkle roots), PERFORMANCE, ARCHITECTURE, ROBUSTNESS, CODE_QUALITY. Rate each 1-10. Overall = minimum. If below 10, list specific issues with file paths and fixes. Format: each criterion with score, OVERALL score, ISSUES list, APPROVED YES/NO.

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

Spawn with these instructions: You are Jony Ive, legendary designer. The user should never feel blockchain, ZK, or verifier economics complexity. Evaluate SIMPLICITY, CLARITY (verifier trust tier instantly communicates safety), DELIGHT, TRUST (verifier badge creates confidence, earnings breakdown builds driver trust, insurance communicated without jargon), ACCESSIBILITY, INFORMATION_HIERARCHY. Rate each 1-10. Overall = minimum. If below 10, list specific issues with screen names and fixes. Format: each criterion with score, OVERALL score, ISSUES list, APPROVED YES/NO.

Review Loop Procedure

Step 1: Run tests at 80%+ coverage. Step 2: Spawn both subagents with phase files and spec. Step 3: Read scores and issues. Step 4: If both 10/10, proceed. Step 5: If below, fix all issues, re-run tests. Step 6: Re-spawn subagents. Step 7: Repeat up to 5 iterations. Safety valve: log remaining issues in TECH_DEBT.md and proceed.

Which Subagents Review Which Phases

Phase 0: Jon Skeet only. Phase 1: Both. Phase 2: Both. Phase 3: Jon Skeet only. Phase 4: Jon Skeet only. Phase 5: Jon Skeet only. Phase 6: Jon Skeet only. Phase 7: Both (heaviest Jony Ive review). Phase 8: Both.


Testing Strategy

Unit Test Requirements

Minimum 80% coverage (Istanbul/nyc for TypeScript, cargo-tarpaulin for Rust). Zero external dependency calls — all mocked. No databases. Use Jest with jest.mock() for TypeScript. Use BanksClient/bankrun for Rust. Colocated test files.

What to Test Per Module

src/zones/*: Mock ngeohash. Test encoding, decoding, neighbors, corners, expansion cascade with fake timers.

src/fare/calculator.ts: Pure math, no mocks. Test distances, multipliers, edge cases.

src/zk/prover.ts: Mock snarkjs. Test both proof functions pass correct inputs.

src/zk/merkle.ts: Mock Solana Connection. Test proof retrieval, cache hit, cache invalidation on root change.

src/p2p/*: Mock libp2p node and services. Test dial, send, receive, pubsub topic, malformed messages.

src/blockchain/*: Mock Connection. Test filters, parallel queries, subscription routing.

src/verifier/insurance.ts: Pure math. Test rate at various clean_months and slash_counts, clamping.

src/verifier/trustTier.ts: Pure math. Test tier boundaries.

src/verifier/ratingAggregator.ts: Pure math. Test weighted average, empty input.

src/stores/*: Mock blockchain calls. Test all state transitions. Test credentialStore Active/Suspended/PendingSwitch. Test verifierStore dashboard loading.

src/hooks/*: Mock stores. Test reactive state, action dispatch.

Rust programs ride_request + escrow: Test complete_ride split payout exactness including rounding (remainder to driver). Test rating escalation.

Rust programs verifier_registry + credential_manager + credential_policy: Test staking formula, single-verifier rule, 48-hour gap with clock warp, insurance rate math, deposit splitting, payout waterfall, dispute flow, compounding slash, credential expiry.

Integration Tests

Full lifecycle using BanksClient: register user, register verifier, issue credential, create request, submit offer with credential proof, select, start, complete with 95/4/1 split. Counter-fare with topup. Insurance deposit and payout. Dispute resolution. Verifier deactivation with grace period. Every lamport accounted for.

End-to-End Test

Two devices. Driver gets credential first. Rider requests, driver offers, rider sees verifier badge, selects. P2P channel. Ride completes. Escrow splits 95/4/1. Earnings breakdown shown. Ratings update driver and verifier. Insurance pool receives deposit. Treasury receives deposit.


Deployment

Zero servers. Solana programs deploy via anchor deploy. Mobile app via Expo EAS Build. Every device runs a libp2p node. RPC 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...
VERIFIER_REGISTRY_PROGRAM_ID=VER...
CREDENTIAL_MANAGER_PROGRAM_ID=CRED...
CREDENTIAL_POLICY_PROGRAM_ID=POL...
BOOTSTRAP_PEERS=/ip4/x.x.x.x/tcp/9000/p2p/12D3KooW...,/ip4/y.y.y.y/tcp/9000/p2p/12D3KooW...
@romizone
Copy link

How you handle complain and missmatch about the quality of the driver are the main issues

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment