E2EE peer-to-peer stablecoin payments for iOS and Android.
StablePay is a self-custody mobile wallet for sending and receiving stablecoin payments. All private keys are generated and stored on-device, and all communication between users is end-to-end encrypted.
- Self-custody: 12-word recovery phrase, keys never leave device
- E2EE messaging: Payment requests encrypted with X25519 + AES-256-GCM
- Gasless transactions: ERC-4337 account abstraction (users don't need ETH/MATIC)
- Multi-network: Polygon Amoy and Avalanche Fuji testnets
- Simple UX: Pay and receive with @username or 6-digit ID
- Mobile: React Native (bare CLI), TypeScript
- Blockchain: ethers.js, ERC-4337 (Pimlico)
- Crypto: @noble/curves, @noble/ciphers, @scure/bip39
- Storage: react-native-keychain (Secure Enclave), op-sqlite
- Relay: WebSocket server on Koyeb
- Contracts: Solidity 0.8.20, OpenZeppelin
- Node.js 20+
- Xcode 15+ (iOS)
- Android Studio (Android)
- CocoaPods 1.14+
# Clone the repo
git clone <repo-url>
cd StablePay
# Install dependencies
npm install
# Install iOS pods
cd ios && pod install && cd ..# iOS
npx react-native run-ios
# Android
npx react-native run-androidsrc/
├── app/ # Navigation & app entry
│ ├── App.tsx # Main entry, initializes DB & WebSocket
│ ├── Navigation.tsx # React Navigation stack
│ └── theme.ts # Colors, spacing, typography
├── contracts/ # Solidity smart contracts
│ └── DemoStablecoin.sol # ERC-20 faucet token
├── core/
│ ├── crypto/ # Key derivation, E2EE
│ │ ├── keyDerivation.ts # BIP-39/BIP-32, X25519
│ │ └── e2ee.ts # X25519 + AES-256-GCM
│ ├── blockchain/ # ethers.js, ERC-4337
│ │ ├── networks.ts # Chain configs
│ │ ├── provider.ts # RPC providers
│ │ ├── erc4337.ts # Smart accounts, Pimlico
│ │ └── contracts.ts # ABIs
│ ├── storage/ # Keychain, SQLite
│ │ ├── keychain.ts # Secure Enclave wrapper
│ │ └── database.ts # SQLite for tx/requests
│ └── websocket/ # Relay client
│ ├── client.ts # WebSocket with auto-reconnect
│ ├── messageHandler.ts # E2EE decryption, routing
│ ├── userService.ts # Register, lookup
│ └── types.ts # Message protocol
├── features/
│ ├── onboarding/ # Wallet creation, recovery phrase
│ ├── wallet/ # Home screen, balance, tx list
│ ├── payments/ # Send, receive, request details
│ │ └── services/
│ │ └── paymentRequestService.ts # E2EE requests
│ └── profile/ # Settings, network switch, view phrase
├── shared/ # Reusable components, hooks
│ ├── components/ # Button, Logo
│ └── hooks/ # useBiometrics, useWebSocket
├── store/ # Zustand state management
│ └── useAppStore.ts
└── types/
┌──────────────────────────────────────────────────────┐
│ USER DEVICE │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │
│ │ Keychain │ │ SQLite │ │ React Native│ │
│ │ (encrypted) │ │ (local db) │ │ App │ │
│ └─────────────┘ └─────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────┐ ┌──────────────┐
│ WebSocket │ │ EVM │
│ Relay │ │ Blockchain │
│ (Koyeb) │ │ (Polygon/AVAX)│
└─────────────┘ └──────────────┘
Update src/core/blockchain/networks.ts with your deployed contract addresses:
export const NETWORKS = {
'polygon-amoy': {
chainId: 80002,
stablecoinAddress: '0x...', // Your deployed dUSDT
bundlerUrl: 'https://api.pimlico.io/v2/80002/rpc?apikey=YOUR_KEY',
paymasterUrl: 'https://api.pimlico.io/v2/80002/rpc?apikey=YOUR_KEY',
},
'avalanche-fuji': {
chainId: 43113,
stablecoinAddress: '0x...',
bundlerUrl: 'https://api.pimlico.io/v2/43113/rpc?apikey=YOUR_KEY',
paymasterUrl: 'https://api.pimlico.io/v2/43113/rpc?apikey=YOUR_KEY',
},
};Update src/core/websocket/types.ts:
export const RELAY_SERVER_URL = 'wss://your-relay-server.koyeb.app';| Service | Purpose | Get it at |
|---|---|---|
| Pimlico | Bundler + Paymaster (gasless tx) | https://pimlico.io |
The demo stablecoin (src/contracts/DemoStablecoin.sol) is an ERC-20 with:
- Faucet: Anyone can claim 100 dUSDT every 24 hours
- Owner mint: Deployer can mint additional tokens for testing
- 18 decimals: Standard stablecoin precision
OpenZeppelin contracts are included as a dev dependency:
npm install # Already includes @openzeppelin/contractsIf your Solidity IDE shows import errors, create remappings.txt in the project root:
@openzeppelin/=node_modules/@openzeppelin/
Deploy to both testnets using Foundry, Hardhat, or Remix:
// Constructor mints 1M tokens to deployer
constructor() ERC20('Demo USDT', 'dUSDT') Ownable(msg.sender) {
_mint(msg.sender, 1_000_000 * 10 ** 18);
}After deployment, update the stablecoinAddress in networks.ts.
| Function | Description |
|---|---|
faucet() |
Claim 100 dUSDT (24h cooldown) |
timeUntilNextClaim(address) |
Seconds until address can claim again |
mint(address, uint256) |
Owner-only: mint tokens to address |
decimals() |
Returns 18 |
The relay handles user discovery and encrypted message routing. Deploy to Koyeb.
Message Types (Client → Server):
| Type | Purpose |
|---|---|
auth |
Authenticate with signed message |
register |
Register @username + X25519 public key |
lookup |
Find user by @username, 6-digit ID, or address |
payment_request |
Send E2EE payment request |
cancel_request |
Cancel pending request |
ping |
Keep-alive |
Message Types (Server → Client):
| Type | Purpose |
|---|---|
auth_success / auth_error |
Auth result with 6-digit ID |
register_success / register_error |
Registration result |
lookup_result / lookup_error |
User lookup result |
payment_request |
Incoming E2EE request from another user |
request_cancelled / request_paid |
Status updates |
pong |
Keep-alive response |
| Layer | Protection |
|---|---|
| Private keys | Secure Enclave / Android Keystore |
| Onboarding | Stored in Keychain (persists across app reinstall) |
| Transactions | Biometric/PIN required to sign |
| Messages | X25519 + AES-256-GCM encryption |
| Transport | WebSocket over TLS |
| Recovery phrase | Shown once, biometric to view again |
- App generates 12-word recovery phrase
- User must confirm they saved it (no skip option)
- Keys derived and stored in Keychain
- User enters main interface
- Enter @username, 6-digit ID, or 0x address
- Enter amount + optional memo
- Confirm with biometric/PIN
- Transaction submitted via ERC-4337 (gasless)
- Enter who to request from
- Enter amount + note
- E2EE request sent via WebSocket
- Recipient sees popup, can pay or decline
- Requests expire after 1 hour
- Edit @username (synced to relay)
- View/copy 6-digit ID and wallet address
- Switch network (Polygon Amoy ↔ Avalanche Fuji)
- View recovery phrase (requires biometric)
{
"ethers": "^6.x",
"@scure/bip39": "latest",
"@scure/bip32": "latest",
"@noble/curves": "latest",
"@noble/ciphers": "latest",
"@noble/hashes": "latest",
"react-native-keychain": "latest",
"react-native-biometrics": "latest",
"@op-engineering/op-sqlite": "latest",
"@react-navigation/native": "latest",
"@react-navigation/native-stack": "latest",
"zustand": "latest",
"permissionless": "latest",
"viem": "latest"
}Dev Dependencies:
{
"@openzeppelin/contracts": "^5.x"
}Always use the workspace file:
ios/StablePay.xcworkspace ✓
ios/StablePay.xcodeproj ✗
- Cached locally in SQLite (
src/core/storage/database.ts) - Syncs from chain on app open and pull-to-refresh
- Falls back to cache silently if offline
- Stored in Zustand (memory) and optionally SQLite
- Expire after 1 hour (checked every minute)
- Either party can cancel
- Status synced via WebSocket
- Zustand (
src/store/useAppStore.ts): Wallet address, balance, network, WebSocket status, pending requests, transactions - Keychain: Mnemonic, X25519 private key, onboarding state
- SQLite: Transaction cache, sync state
Before releasing:
- Deploy
DemoStablecoin.solto Polygon Amoy - Deploy
DemoStablecoin.solto Avalanche Fuji - Update
stablecoinAddressinnetworks.ts - Get Pimlico API key and update bundler/paymaster URLs
- Deploy WebSocket relay to Koyeb
- Update
RELAY_SERVER_URLintypes.ts - Fund Pimlico paymaster with testnet tokens
Proprietary — Demo purposes only.