Production-grade Web3 browser for React Native. Full EIP-1193 provider injection, five-layer phishing protection, and zero-overhead RPC messaging — powered by Nitro JSI.
react-native-dapp-browser gives any React Native wallet a full Web3 browser in one component. Drop in <DappBrowser />, wire up your signing logic, and DApps on Uniswap, OpenSea, Aave, and everywhere else just work.
- Native WebView — WKWebView on iOS, Chromium-based System WebView on Android.
- EIP-1193 provider injected at document start. Works with MetaMask impersonation.
- EIP-6963 multi-wallet discovery via
DappIdentity(name, rdns, uuid, icon). - EIP-5792 wallet capabilities reported on connect — prevents Uniswap v4 white screens.
- Typed RPC handler map — the compiler enforces required methods and return shapes.
- Five-layer phishing protection via a shared C++20 engine. Blocks 282K+ threats in ~190 ns.
- Zero-eval RPC bridge — DApp promises resolve/reject without a single
evaluateJavaScript. - Scroll-snap callbacks — drive Safari-style auto-hiding toolbars from native scroll physics.
┌─────────────────────────────────────────────────────────────────┐
│ React Native JS │
│ <DappBrowser /> createRPCHandler() useAntiPhishingEngine │
└──────────────────────────────┬──────────────────────────────────┘
│ Nitro JSI (zero bridge overhead)
┌──────────────────────────────▼──────────────────────────────────┐
│ Nitrogen-generated Spec (nitro.json) │
│ HybridDappBrowserSpec HybridAntiPhishingEngineSpec │
└──────────┬───────────────────────────────────┬──────────────────┘
│ │
┌──────────▼──────────────┐ ┌────────────▼────────────────┐
│ HybridDappBrowser │ │ HybridAntiPhishingEngine │
│ Swift (iOS) │ │ Pure C++20 (shared) │
│ Kotlin (Android) │ │ AntiPhishingEngine.h/.cpp │
└──────────┬──────────────┘ └────────────┬────────────────┘
│ │
│ ObjC bridge (pod-safe) │ globalAntiPhishingEngine()
│ AntiPhishingBridge.mm │ AntiPhishingEngineSingleton.h
│ │
┌──────────▼───────────────────────────────────▼────────────────┐
│ AntiPhishingEngine (C++20) │
│ │
│ Layer 1 Whitelist exact match O(1) unordered_set │
│ Layer 2 Hash binary search O(log n) sorted uint32[] │
│ Layer 3 Fuzzy / Levenshtein distance ≤ 2 │
│ Layer 4 Homoglyph / IDN Unicode confusables │
│ Layer 5 Subdomain spoofing eTLD+1 extraction │
└───────────────────────────────────────────────────────────────┘
| Platform | Transport | How it works |
|---|---|---|
| iOS 14+ | WKScriptMessageHandlerWithReply |
DApp calls postMessage() which returns a native Promise. Reply handler stored by request ID, called when resolveRPC / rejectRPC fires. |
| iOS 12–13 | WKURLSchemeHandler (dappbridge://) |
DApp calls fetch("dappbridge://rpc", {body}). URL scheme task held open until RPC resolves. |
| Android | WebMessagePort (bidirectional) |
Channel created on onPageFinished. DApp sends JSON on its port; native port calls postMessage back. Zero eval for round-trip. |
evaluateJavaScript is used only for one-way state push (updateState, emitEvent) — never for RPC responses.
bun add react-native-dapp-browser react-native-nitro-modules react-native-mmkviOS — pod install:
cd ios && pod installPeer dependencies:
{
"react-native": ">=0.78.0",
"react-native-nitro-modules": ">=0.35.0",
"react-native-mmkv": ">=4.3.1"
}import { DappBrowser } from 'react-native-dapp-browser';
import { useRef } from 'react';
export function Browser() {
const browserRef = useRef(null);
return (
<DappBrowser
url="https://app.uniswap.org"
provider={{
identity: {
name: 'MyWallet',
icon: 'data:image/svg+xml;base64,...',
rdns: 'com.mywallet',
uuid: '2d5f6b7a-9e10-4b4d-8c61-6c3b3ff0f2ab',
},
initialState: { chainId: '0x1', accounts: ['0xYourAddress'] },
settings: { impersonateMetaMask: true, injectWeb3: true, debugMode: false },
}}
onRPCRequest={async (method, params, requestId, origin) => {
// Resolve or reject the pending promise in the DApp
browserRef.current?.resolveRPC(requestId, JSON.stringify(['0xYourAddress']));
}}
hybridRef={(ref) => { browserRef.current = ref; }}
/>
);
}Use createRPCHandler + useRPCHandler for type-safe, modular RPC handling.
import {
DappBrowser,
createRPCHandler,
useRPCHandler,
RPCError,
RPCErrorCode,
type RPCHandlerMap,
} from 'react-native-dapp-browser';
import { useMemo } from 'react';
const walletHandlers: RPCHandlerMap = {
eth_requestAccounts: async (_params, { origin }) => {
const approved = await showConnectModal(origin);
if (!approved) throw RPCError.UserRejected();
return ['0xYourAddress'];
},
wallet_requestPermissions: async (_params, { origin }) => {
const approved = await showConnectModal(origin);
if (!approved) throw RPCError.UserRejected();
return [{ parentCapability: 'eth_accounts' }];
},
wallet_getCapabilities: async () => ({
'0x1': { atomicBatch: { supported: false } },
}),
personal_sign: async ([message, _address], { origin }) => {
const approved = await showSignModal(origin, message);
if (!approved) throw new RPCError(RPCErrorCode.UserRejected, 'User rejected');
return await wallet.sign(message as string);
},
eth_sendTransaction: async ([tx], { origin }) => {
const approved = await showTxModal(origin, tx);
if (!approved) throw RPCError.UserRejected();
return await wallet.send(tx);
},
};
export function WalletBrowser() {
const handlers = useMemo(() => createRPCHandler(walletHandlers), []);
const onRPCRequest = useRPCHandler(handlers);
return (
<DappBrowser
url="https://app.uniswap.org"
provider={{
identity: {
name: 'Nirapod',
rdns: 'com.nirapod.wallet',
icon: 'data:image/svg+xml;base64,...',
uuid: '2d5f6b7a-9e10-4b4d-8c61-6c3b3ff0f2ab',
},
initialState: { chainId: '0x1', accounts: [] },
settings: { impersonateMetaMask: true, injectWeb3: true, debugMode: __DEV__ },
}}
onRPCRequest={onRPCRequest}
onAntiPhishingResult={(url, threatLevel, matchType) => {
if (threatLevel === 'blocked') {
// Navigation already cancelled. Show a block screen.
} else {
// 'warning': fuzzy/subdomain match. User can choose to proceed.
// Call browserRef.current?.loadUrl(url) to override.
}
}}
/>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
url |
string |
required | Initial URL to load. |
provider |
DappProviderConfig |
— | EIP-1193 provider config. |
allowsBackForwardGestures |
boolean |
true |
Swipe back/forward on iOS. |
showProgress |
boolean |
true |
Loading progress bar. |
onNavigationChange |
(state: NavigationState) => void |
— | URL, title, loading, canGoBack/Forward changed. |
onProgress |
(progress: number) => void |
— | Load progress 0.0 to 1.0. |
onRPCRequest |
(method, params, requestId, origin, metadata) => Promise<unknown> |
— | DApp sent an EIP-1193 request. Throw RPCError to reject. |
onAntiPhishingResult |
(url, threatLevel, matchType) => void |
— | Navigation already cancelled. Handle "blocked" or "warning". |
onScroll |
(offsetY, velocity, direction) => void |
— | Every native scroll frame. |
onScrollSnap |
(target: "show" | "hide") => void |
— | Fired once when drag ends. Native physics picks the snap target. |
onScrollToTop |
() => void |
— | User tapped status bar or pulled past the top. |
hybridRef |
(ref: DappBrowserMethods) => void |
— | Imperative handle for navigation and RPC resolution. |
interface DappProviderConfig {
identity?: DappIdentity;
initialState?: DappInitialState;
policy?: DappPolicy;
capabilities?: WalletCapabilities; // EIP-5792
settings?: DappSettings;
}
interface DappIdentity {
name: string; // "Nirapod"
icon: string; // data URI or HTTPS URL (prefer SVG)
rdns: string; // "com.nirapod.wallet"
uuid: string; // UUIDv4
}
interface DappInitialState {
chainId: Hex; // e.g. "0x1"
accounts: Address[];
}
interface DappPolicy {
// Auto-reject these methods with the given reason string.
// e.g. { "eth_sign": "Disabled — use eth_signTypedData_v4" }
rejections: Partial<Record<RPCMethodName, string>>;
}
interface DappSettings {
impersonateMetaMask: boolean; // window.ethereum.isMetaMask = true
injectWeb3: boolean; // window.web3 shim
debugMode: boolean; // provider console logging
}| Method | Signature | Description |
|---|---|---|
loadUrl |
(url: string) => void |
Navigate to a URL. |
goBack |
() => void |
Navigate back. |
goForward |
() => void |
Navigate forward. |
reload |
() => void |
Reload the current page. |
resolveRPC |
(requestId: number, resultJson: string) => void |
Resolve a pending DApp promise with a JSON value. |
rejectRPC |
(requestId: number, code: number, message: string) => void |
Reject with an EIP-1193 error. |
emitEvent |
(event: ProviderEventName, data: AnyMap) => void |
Push a provider event to the DApp. |
// Emit after connecting, switching chains, or disconnecting:
browserRef.current?.emitEvent('accountsChanged', { items: ['0xNewAddress'] });
browserRef.current?.emitEvent('chainChanged', { value: '0x89' }); // Polygon
browserRef.current?.emitEvent('connect', { chainId: '0x1' });
browserRef.current?.emitEvent('disconnect', { code: 4900, message: 'Disconnected' });Typed event names from ProviderEventName: accountsChanged | chainChanged | connect | disconnect | message.
// Throw these inside any onRPCRequest handler:
throw RPCError.UserRejected();
throw RPCError.UnsupportedMethod('eth_sign');
throw RPCError.InvalidParams('Missing address');
throw RPCError.InternalError('Keystore unavailable');
// Or with a raw EIP-1193 code:
throw new RPCError(RPCErrorCode.Unauthorized, 'Not connected');| Constant | Code | Meaning |
|---|---|---|
UserRejected |
4001 | User dismissed the request. |
Unauthorized |
4100 | Method not authorized for this account. |
UnsupportedMethod |
4200 | Wallet does not support this method. |
Disconnected |
4900 | Wallet disconnected from all chains. |
ChainDisconnected |
4901 | Connected, but not to the requested chain. |
UnrecognizedChainId |
4902 | Use wallet_addEthereumChain first. |
ParseError |
-32700 | JSON parse failure. |
InvalidRequest |
-32600 | Malformed JSON-RPC request. |
MethodNotFound |
-32601 | Method does not exist. |
InvalidParams |
-32602 | Invalid parameters. |
InternalError |
-32603 | Unexpected internal error. |
createRPCHandler enforces types for every supported method. The compiler requires eth_requestAccounts, wallet_requestPermissions, and wallet_getCapabilities — missing any produces a build error.
// A representative subset of RPCMethodMap:
'eth_requestAccounts': { params: []; result: Address[] }
'personal_sign': { params: [message: Hex, address: Address]; result: Hex }
'eth_signTypedData_v4': { params: [address: Address, typedData: string]; result: Hex }
'eth_sendTransaction': { params: [tx: TransactionRequest]; result: Hex }
'wallet_switchEthereumChain': { params: [{ chainId: Hex }]; result: null }
'wallet_addEthereumChain': { params: [AddEthereumChainParameter]; result: null }
'wallet_getCapabilities': { params: [address?: Address]; result: WalletCapabilities }
'wallet_requestPermissions': { params: [{ eth_accounts: object }]; result: WalletPermission[] }
'wallet_getPermissions': { params: []; result: WalletPermission[] }
'eth_getBalance': { params: [address: Address, block?: string]; result: Hex }
'eth_estimateGas': { params: [call: CallRequest]; result: Hex }
'eth_call': { params: [call: CallRequest, block?: string]; result: Hex }Mounts a JS handle to the native engine singleton and starts the 24-hour CDN update loop using MMKV for ETag persistence.
const engineRef = useAntiPhishingEngine();
const result = engineRef.current?.check('https://unlswap.org');
// result.threatLevel === 'warning'
// result.matchType === 'fuzzy'
// result.suggestedUrl === 'uniswap.org'
// result.latencyUs === 190interface AntiPhishingEngine {
readonly isReady: boolean;
check(url: string): AntiPhishingResult;
loadBinary(buffer: ArrayBuffer): boolean; // ~2 ms for 282K entries
loadConfig(json: string): void; // ~200 ms — fallback only
getStats(): AntiPhishingStats;
}
interface AntiPhishingResult {
threatLevel: 'safe' | 'warning' | 'blocked';
matchType?: 'whitelist' | 'exact_blacklist' | 'fuzzy' | 'homoglyph' | 'subdomain';
confidence: number; // 0.0–1.0
hostname: string;
suggestedUrl?: string;
latencyUs: number; // microseconds
}| # | Layer | Algorithm | Threat |
|---|---|---|---|
| 1 | Whitelist | unordered_set O(1) |
Safe — fast exit |
| 2 | Exact blacklist | binary_search on sorted uint32_t[] FNV-32 hashes |
Blocked |
| 3 | Fuzzy matching | Levenshtein distance ≤ 2 vs fuzzylist | Warning |
| 4 | Homoglyph / IDN | Unicode confusables + RFC 3492 punycode decode | Blocked |
| 5 | Subdomain spoofing | eTLD+1 extraction + known DApp label detection | Warning |
Layer 2 handles 282K+ entries in ~190 ns (19 comparisons worst-case, data fits in L3 cache).
The MetaMask phishing list grew from ~10K (2021) to ~282K entries (2026):
| Structure | 10K | 282K |
|---|---|---|
BloomFilter<1<<17, 7> |
0.08% FP ✓ | 100% FP — saturated ✗ |
unordered_set<string> |
~940 KB | ~25 MB ✗ |
vector<uint32_t> hashes |
40 KB | 1.1 MB ✓ |
| App asset (JSON) | ~400 KB | 7 MB ✗ |
App asset (.nirapod binary) |
40 KB seed | 1.1 MB ✓ |
Offset Bytes Field
------ ----- -----------------------------------------
0 4 magic = "NRPD" (0x4E 0x52 0x50 0x44)
4 4 config version (LE uint32)
8 4 flags (reserved = 0)
12 4 blacklist hash count N (LE uint32)
16 4 whitelist string count W (LE uint32)
20 4 fuzzylist string count F (LE uint32)
24 8 reserved (0x00 × 8)
32 4×N sorted FNV-1a 32-bit hashes (LE uint32[N])
... varies whitelist: uint16_t len + UTF-8 bytes, W times
... varies fuzzylist: uint16_t len + UTF-8 bytes, F times
Produced by packages/binary-compiler. Consumed by AntiPhishingEngine::loadBinary() on both platforms.
App launch (before JS runs)
│
▼
AntiPhishingAutoLoader.boot() [iOS Swift]
AntiPhishingBootLoader.boot() [Android Kotlin]
│
├── Step 1: antiphishing-seed.bin ← bundled asset, ~40 KB, top 10K threats, ~2 ms
│ Engine is ready for navigation after this step.
│
├── Step 2: antiphishing.bin ← disk cache, ~1.1 MB, full 282K entries, ~2 ms
│ Hot-swaps engine if a previous CDN download exists.
│
└── Step 3: CDN update (async background, ETag-gated, daily)
URL: github.com/.../releases/latest/download/antiphishing.bin
Atomic hot-swap. No restart required.
JS bundle loads
│
▼
useAntiPhishingEngine()
└── CDN check via MMKV 24h gate → loadBinary(ArrayBuffer) ~2 ms
Every navigation passes through decidePolicyFor (iOS) / shouldOverrideUrlLoading (Android) where the C++ singleton runs synchronously before the page loads.
Manual:
bun packages/binary-compiler/index.ts \
--input /path/to/eth-phishing-detect/config.json \
--output packages/react-native-dapp-browser/src/assets/antiphishing.bin \
--seed-output packages/react-native-dapp-browser/src/assets/antiphishing-seed.bin \
--seed-count 10000Automatic (CI): .github/workflows/update-phishing-binary.yml runs daily at 03:00 UTC. It downloads the latest MetaMask eth-phishing-detect config (ETag-gated), recompiles both binaries, publishes the full binary to GitHub Releases, and opens a PR when the committed seed file changes.
react-native-dapp-browser/
├── packages/
│ ├── react-native-dapp-browser/
│ │ ├── cpp/
│ │ │ ├── AntiPhishingEngine.h/.cpp C++20 five-layer detection engine
│ │ │ ├── AntiPhishingEngineSingleton.h/.cpp global process singleton
│ │ │ ├── HybridAntiPhishingEngine.h/.cpp Nitro JSI bridge for the engine
│ │ │ └── BloomFilter.h (retained, unused in v2)
│ │ ├── ios/
│ │ │ ├── HybridDappBrowser.swift WKWebView + dual RPC transport
│ │ │ ├── AntiPhishingAutoLoader.swift 3-step boot sequence
│ │ │ ├── AntiPhishingBridge.h/.mm ObjC++ bridge (pod-safe interop)
│ │ │ └── Bridge.h C declarations for non-pod use
│ │ ├── android/src/main/
│ │ │ ├── java/.../HybridDappBrowser.kt Chromium WebView + WebMessagePort
│ │ │ ├── java/.../AntiPhishingBootLoader.kt 3-step boot sequence
│ │ │ ├── java/.../AntiPhishingJNI.kt JNI bridge to C++ singleton
│ │ │ └── cpp/cpp-adapter.cpp JNI entry points
│ │ ├── src/
│ │ │ ├── specs/
│ │ │ │ ├── dapp-browser.nitro.ts Nitrogen WebView spec
│ │ │ │ └── antiphishing-engine.nitro.ts Nitrogen engine spec
│ │ │ ├── views/DappBrowser.tsx React component
│ │ │ ├── hooks/useAntiPhishingEngine.ts CDN update hook
│ │ │ ├── rpc/
│ │ │ │ ├── types.ts RPCMethodMap + all EIP types
│ │ │ │ ├── errors.ts RPCError class
│ │ │ │ ├── createRPCHandler.ts type-safe handler factory
│ │ │ │ └── useRPCHandler.ts onRPCRequest dispatcher hook
│ │ │ └── index.ts public package exports
│ │ ├── provider/
│ │ │ └── ethereum-provider.js EIP-1193 + EIP-6963 injected script
│ │ └── nitro.json Nitrogen autolinking config
│ └── binary-compiler/
│ └── index.ts .nirapod compiler CLI
├── apps/
│ └── example/ Expo Router example app
└── .github/workflows/
└── update-phishing-binary.yml Daily phishing list CI
# Install all workspace dependencies
bun install
# Run example on iOS
cd apps/example && bun run ios
# Run example on Android
cd apps/example && bun run android
# Regenerate Nitrogen glue after editing a .nitro.ts spec file
bun --filter='react-native-dapp-browser' specs
# iOS: pod install is required after any C++ or Swift file change
cd apps/example/ios && pod install
# Recompile phishing binaries from the local submodule
bun packages/binary-compiler/index.tsMIT © 2026 Nirapod Contributors