A self-custodial Ethereum wallet for iOS and Android that sends transactions over a BLE mesh network, enabling offline-first transfers without internet access.
- What is MeshWallet?
- Architecture Overview
- Transaction Flow
- Tech Stack
- Project Structure
- Quick Start
- Environment Variables
- Why @offline-protocol/mesh-sdk?
- Why @noble/* cryptography?
- Contributing
- License
MeshWallet is a self-custodial Ethereum mobile wallet that routes signed transactions through a BLE (Bluetooth Low Energy) device mesh when no internet is available. Any phone running the app acts as a relay node; when a node gains connectivity it flushes buffered transactions to an Ethereum RPC endpoint (Sepolia testnet) and polls for confirmation.
The wallet generates secp256k1 key pairs natively, stores private keys in the OS secure enclave (via expo-secure-store), and signs transactions locally using @noble/curves. WalletConnect v2 integration allows dApp pairing. The design assumes an adversarial environment: every packet is AES-256-GCM encrypted, carries a TTL, and is deduplicated before relay.
graph TB
subgraph Device["Mobile Device (iOS / Android)"]
UI["Expo Router UI\n(app/ screens)"]
WS["walletStore\nZustand"]
MS["meshStore\nZustand"]
KM["KeyManager\nexpo-secure-store + secp256k1"]
SG["Signer\n@noble/curves"]
EN["Encryptor\nAES-256-GCM"]
DB[(SQLite\nexpo-sqlite)]
GW["GatewaySync\nBackgroundFetch"]
end
subgraph BLE["BLE Mesh Layer"]
BM["BLEManager\n@offline-protocol/mesh-sdk"]
MR["MeshRouter\nTTL Β· dedup Β· relay buffer"]
PS["PairingService\nneighbor discovery"]
PC["PacketCodec\nJSON encode/decode"]
end
subgraph External["External"]
RPC["Ethereum RPC\nSepolia (multi-endpoint)"]
ES["Etherscan API\nSepolia"]
WC["WalletConnect v2\nCloud relay"]
end
UI --> WS
UI --> MS
UI --> KM
UI --> SG
UI --> EN
SG --> DB
GW --> DB
GW --> RPC
GW --> ES
MR --> DB
MR --> BM
BM --> PC
MR --> GW
PS --> BM
UI --> PS
UI --> WC
WS --> UI
MS --> UI
sequenceDiagram
participant User
participant UI as Expo Router UI
participant Signer
participant Encryptor
participant DB as SQLite
participant MeshRouter
participant BLEMesh as BLE Mesh (peers)
participant GatewaySync
participant RPC as Ethereum RPC (Sepolia)
User->>UI: Enter recipient + amount, tap Send
UI->>Signer: signTransaction(unsignedTx)
Signer->>DB: insertTransaction(status=QUEUED_LOCAL)
UI->>Encryptor: encryptForSender(signedTx, privKey)
UI->>MeshRouter: enqueue MeshPacket (TTL=12)
MeshRouter->>DB: insertRelayPacket + statusβIN_MESH
MeshRouter->>BLEMesh: writePacketToDevice (per peer)
BLEMesh-->>MeshRouter: neighbor relays packet onwards
alt Device has internet
MeshRouter->>GatewaySync: flushPendingToGateway()
GatewaySync->>DB: read relay_buffer (forwarded=0)
GatewaySync->>Encryptor: decryptFromSender(packet)
GatewaySync->>RPC: ethers.Wallet.sendTransaction (EIP-1559)
RPC-->>GatewaySync: txHash
GatewaySync->>DB: statusβBROADCAST + tx_hash
loop Poll every 5 s (max 10 min)
GatewaySync->>RPC: getTransactionReceipt(txHash)
RPC-->>GatewaySync: receipt
GatewaySync->>DB: statusβCONFIRMED or FAILED
end
else No internet (pure mesh relay)
BLEMesh-->>BLEMesh: packet hops until a node with internet flushes it
end
| Failure Point | Effect | Recovery |
|---|---|---|
| All RPC endpoints unreachable | broadcastTransactionDirect throws; forwarded stays 0 |
Next flushPendingToGateway call retries automatically |
| Wrong chain ID from RPC | Error thrown, RPC skipped | Falls through to next RPC in fallback list |
| Receipt poll timeout (10 min) | Status stays BROADCAST |
reconcileBroadcastTransactions() can be called manually |
| Decryption failure | Packet marked forwarded=1 and skipped |
No retry; packet is effectively dropped |
Relay buffer full (RELAY_BUFFER_MAX=50) |
Incoming packet dropped | Sender will re-inject after TTL reset |
| TTL reaches 0 | Packet dropped at MeshRouter.handleIncomingPacket |
Sender must rebroadcast |
| Module | Language | Runtime | Framework | Storage | Role |
|---|---|---|---|---|---|
| Mobile App | TypeScript | Hermes (RN 0.81) | Expo 54 / React Native | expo-sqlite (SQLite) | UI, wallet, mesh coordination |
| BLE Layer | TypeScript | Hermes | @offline-protocol/mesh-sdk | in-memory relay buffer | BLE central+peripheral, mesh routing |
| Crypto | TypeScript | Hermes | @noble/curves, @noble/ciphers | expo-secure-store | Key gen, signing, AES-256-GCM |
| Gateway | TypeScript | Hermes | ethers.js v6 | SQLite relay_buffer | RPC broadcast, confirmation polling |
| State | TypeScript | Hermes | Zustand v5 | in-memory | Wallet + mesh reactive state |
| Component | Technology | Purpose |
|---|---|---|
| On-device DB | expo-sqlite (SQLite) | Transactions, relay buffer, ledger |
| Secure key storage | expo-secure-store | Private key persistence in OS keychain |
| BLE transport | @offline-protocol/mesh-sdk | Central/peripheral management, mesh routing |
| Ethereum RPC | Sepolia public endpoints (drpc, publicnode, 1rpc, rpc.sepolia.org) | Transaction broadcast + receipt polling |
| WalletConnect | @walletconnect/core v2 + modal-react-native | dApp pairing via QR / deep link |
| Background sync | expo-background-fetch + expo-task-manager | Gateway flush while app is backgrounded |
| Build / OTA | EAS Build + EAS Submit | Cloud builds for iOS and Android |
MeshWallet/
βββ app/ # Expo Router file-based screens
β βββ (tabs)/ # Bottom-tab group
β β βββ wallet.tsx # Main balance + recent txs
β β βββ history.tsx # Full transaction history
β β βββ network.tsx # BLE mesh peer view + relay events
β β βββ profile.tsx # Address, QR code, settings
β βββ send/ # Multi-step send flow (stack)
β β βββ index.tsx # Amount + recipient entry
β β βββ scan.tsx # QR camera scanner
β β βββ pairing.tsx # BLE peer picker
β β βββ confirm.tsx # Review + sign
β β βββ success.tsx # Broadcast confirmation
β βββ transaction/[id].tsx # Transaction detail
β βββ onboarding.tsx # First-run key generation
β βββ ledger.tsx # On-chain balance reconciliation
β βββ receive.tsx # QR receive address
β βββ _layout.tsx # Root navigator + WalletContext init
βββ src/
β βββ ble/
β β βββ BLEManager.ts # Wraps @offline-protocol/mesh-sdk singleton
β β βββ MeshRouter.ts # TTL, dedup, relay buffer, gateway trigger
β β βββ PacketCodec.ts # MeshPacket type + JSON encode/decode
β β βββ PairingService.ts # Neighbor discovery for send pairing
β βββ crypto/
β β βββ KeyManager.ts # secp256k1 keygen, SecureStore persistence
β β βββ Signer.ts # keccak256 + secp256k1 tx signing/verify
β β βββ Encryptor.ts # AES-256-GCM encrypt/decrypt per packet
β βββ db/
β β βββ schema.ts # SQLite table definitions (transactions, relay_buffer, ledger)
β β βββ TransactionRepo.ts # CRUD for transactions + relay_buffer rows
β β βββ LedgerRepo.ts # Balance + nonce cache
β βββ gateway/
β β βββ GatewaySync.ts # Relay buffer β RPC flush, confirmation poll, reconcile
β βββ store/
β β βββ walletStore.ts # Zustand: address, balance, pendingOutgoing
β β βββ meshStore.ts # Zustand: peers, relay buffer, mesh events
β βββ hooks/
β β βββ useWallet.ts # Wallet init, balance refresh
β β βββ useMesh.ts # Mesh start/stop lifecycle
β β βββ useTransactionQueue.ts # Outbox management
β βββ context/
β β βββ WalletContext.tsx # Root context: key init, background task reg
β βββ components/ # Shared UI primitives
β βββ constants/
β βββ colors.ts # Design tokens
β βββ config.ts # WalletConnect project ID, Sepolia RPC
βββ constants/
β βββ ble.ts # BLE UUIDs, TTL, packet size, relay buffer cap
β βββ chain.ts # Chain ID, RPC endpoints + fallbacks
βββ plugins/
β βββ withBLEAdvertiserCompat.js # Expo config plugin: Android BLE advertiser compat patch
βββ scripts/
β βββ patch-native.js # Post-install native patching
βββ app.json # Expo config: permissions, bundle IDs, plugins
βββ eas.json # EAS Build profiles (development, preview, production)
βββ babel.config.js
βββ metro.config.js
βββ tailwind.config.js
βββ tsconfig.json
βββ vitest.config.ts # Unit test config (crypto + BLE codec tests)
| Tool | Min Version | Install |
|---|---|---|
| Node.js | 20 | https://nodejs.org |
| npm | 10 | Bundled with Node 20 |
| Expo CLI | latest | npm i -g expo-cli |
| EAS CLI | 16.32+ | npm i -g eas-cli |
| Xcode (iOS) | 15 | Mac App Store |
| Android Studio (Android) | Hedgehog | https://developer.android.com/studio |
| CocoaPods (iOS) | 1.14+ | gem install cocoapods |
-
Clone and install dependencies
git clone https://github.com/mkwallet/MeshWallet.git cd MeshWallet npm install -
Set your WalletConnect Project ID
Create a
.envfile in the repo root:echo "EXPO_PUBLIC_WC_PROJECT_ID=your_project_id_here" > .env
Get a free project ID at https://cloud.walletconnect.com.
-
Install iOS native dependencies
npx expo prebuild --platform ios cd ios && pod install && cd ..
-
Run on iOS simulator
npm run ios
-
Run on Android emulator / device
npm run android
-
Run unit tests
npx vitest run
Note: BLE scanning and advertising require a physical device. Simulators will not discover peers.
To run native modules (BLE, SecureStore) that require a custom dev client:
npm run devbuild:android # EAS cloud build β install APK on device| Variable | Description | Default | Required |
|---|---|---|---|
EXPO_PUBLIC_WC_PROJECT_ID |
WalletConnect v2 Cloud project ID | 'YOUR_WALLETCONNECT_PROJECT_ID' |
Yes (for dApp pairing) |
All other configuration is static in source:
| Constant | Location | Value |
|---|---|---|
| Sepolia chain ID | constants/chain.ts |
11155111 |
| Primary RPC URL | constants/chain.ts |
https://ethereum-sepolia-rpc.publicnode.com |
| RPC fallbacks | constants/chain.ts |
drpc, 0xrpc, 1rpc, rpc.sepolia.org |
| Etherscan API | constants/chain.ts |
https://api-sepolia.etherscan.io/api |
| BLE Service UUID | constants/ble.ts |
A1B2C3D4-E5F6-7890-ABCD-EF0123456789 |
| Default TTL | constants/ble.ts |
12 |
| Max packet size | constants/ble.ts |
512 bytes |
| Relay buffer cap | constants/ble.ts |
50 packets |
- Abstracts BLE central + peripheral simultaneously. Managing
ble-plx(central) andreact-native-ble-advertiser(peripheral) as separate libraries requires platform-specific glue code for Android API level splits and MIUI scan-filter bugs. The SDK collapses both roles into a singleOfflineProtocolinstance. - Native mesh routing. The SDK handles multi-hop relay internally;
MeshRouteronly needs to deduplicate and TTL-gate packets at the application layer, not implement flooding logic itself. - Stable public-keyβbased addressing.
neighbor_discoveredevents exposepeer_idwhich maps directly to the wallet's secp256k1 public key hex, enablingcomputeAddress()to derive the Ethereum address of a discovered peer with no extra handshake. - Transport-agnostic. The SDK can fall back to Wi-Fi Direct or other transports without API changes to
BLEManager.ts.
- Audited pure-TypeScript. No native bindings means the same code runs on iOS, Android, and in Vitest unit tests without mocking.
- Explicit primitives.
@noble/curves/secp256k1exposessign,verify, andgetPublicKeydirectly; there is no hidden key-derivation abstraction that could silently weaken security. - Minimal bundle footprint. Tree-shaking eliminates unused cipher suites; only
secp256k1,keccak_256, andaes/gcmare included in the final bundle. @noble/ciphersAES-256-GCM. Authenticated encryption with per-packet 24-byte random nonces prevents replay attacks on the BLE mesh layer.
- Fork the repository and create a feature branch from
main:git checkout -b feat/your-feature
- Make changes. Run lint and tests before committing:
npm run lint npx vitest run
- Commit with a conventional message (
feat:,fix:,chore:, etc.). - Open a pull request against
main. Describe the motivation and include relevant test output.
Code style: TypeScript strict mode, Prettier (config in prettier.config.js), ESLint (eslint.config.js). Run npm run format to auto-fix.
Private β all rights reserved.