Skip to content

nirapod-labs/react-native-dapp-browser

Repository files navigation

react-native-dapp-browser

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.

npm MIT License React Native Nitro Platform


What it does

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.

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                       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          │
└───────────────────────────────────────────────────────────────┘

RPC transport (zero evaluateJavaScript)

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.


Installation

bun add react-native-dapp-browser react-native-nitro-modules react-native-mmkv

iOS — pod install:

cd ios && pod install

Peer dependencies:

{
  "react-native": ">=0.78.0",
  "react-native-nitro-modules": ">=0.35.0",
  "react-native-mmkv": ">=4.3.1"
}

Quick start

Minimal browser

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

Full wallet integration

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.
        }
      }}
    />
  );
}

API Reference

<DappBrowser /> props

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.

DappProviderConfig

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
}

Native methods via hybridRef

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.

Provider events

// 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.

RPCError and error codes

// 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.

Typed RPC method map

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 }

useAntiPhishingEngine()

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   === 190
interface 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
}

Anti-Phishing Engine

Detection layers

# 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).

Why sorted hashes replaced unordered_set<string>

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 ✓

Binary format

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.

Boot sequence

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.

Updating the phishing binary

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 10000

Automatic (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.


Repository structure

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

Contributing

# 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.ts

License

MIT © 2026 Nirapod Contributors

About

Production-grade Dapp browser for Crypto Wallets in React Native

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors