Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions examples/react-native-webview-widget-page/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>LI.FI Widget - React Native WebView host page</title>
<style>
html,
body,
#root {
margin: 0;
padding: 0;
height: 100%;
overscroll-behavior: none;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
26 changes: 26 additions & 0 deletions examples/react-native-webview-widget-page/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "react-native-webview-widget-page",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview --host",
"check:types": "tsc --noEmit"
},
"dependencies": {
"@lifi/widget": "workspace:*",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"@lifi/widget-provider-ethereum": "workspace:*"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^6.0.3",
"vite": "^8.1.4",
"@types/node": "^26.1.1"
}
}
75 changes: 75 additions & 0 deletions examples/react-native-webview-widget-page/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { LiFiWidget, type WidgetConfig } from '@lifi/widget'
import { EthereumProvider } from '@lifi/widget-provider-ethereum'
import { useEffect, useMemo, useState } from 'react'

/**
* This page is meant to be rendered inside a react-native-webview.
*
* The React Native host injects an EIP-1193 provider (announced via EIP-6963)
* before this page loads - see ../react-native-webview/src/bridge/injectedProvider.ts.
* From the widget's point of view the host app is just another injected
* wallet, so no widget internals are involved at all.
*
* The host can optionally pass widget config overrides:
* - via the `config` query param (URI-encoded JSON), read once at load
* - via postMessage({ type: 'widget:config', config }) at runtime
*/

const readConfigFromQuery = (): Partial<WidgetConfig> => {
try {
const raw = new URLSearchParams(window.location.search).get('config')
return raw ? JSON.parse(decodeURIComponent(raw)) : {}
} catch {
return {}
}
}

export const App = () => {
const [configOverrides, setConfigOverrides] =
useState<Partial<WidgetConfig>>(readConfigFromQuery)

useEffect(() => {
// RN -> page messages arrive on `document` on Android and `window` on iOS.
const onMessage = (event: Event) => {
const data = (event as MessageEvent).data
if (typeof data !== 'string') {
return
}
try {
const message = JSON.parse(data)
if (message?.type === 'widget:config' && message.config) {
setConfigOverrides(message.config)
}
} catch {
// Not ours - the wallet bridge uses its own message envelope.
}
}
window.addEventListener('message', onMessage)
document.addEventListener('message', onMessage)
return () => {
window.removeEventListener('message', onMessage)
document.removeEventListener('message', onMessage)
}
}, [])

const config: WidgetConfig = useMemo(
() => ({
integrator: 'lifi-rn-webview-example',
// The widget takes wallet providers explicitly since v4. EVM only here:
// the RN host's injected EIP-6963 provider is discovered by wagmi's
// multi-injected-provider discovery inside EthereumProvider.
providers: [EthereumProvider()],
appearance: 'light',
theme: {
container: {
height: '100%',
border: 'none',
},
},
...configOverrides,
}),
[configOverrides]
)

return <LiFiWidget integrator={config.integrator} config={config} />
}
4 changes: 4 additions & 0 deletions examples/react-native-webview-widget-page/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createRoot } from 'react-dom/client'
import { App } from './App'

createRoot(document.getElementById('root')!).render(<App />)
11 changes: 11 additions & 0 deletions examples/react-native-webview-widget-page/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"incremental": false,
"isolatedDeclarations": false,
"types": ["node", "vite/client"]
},
"include": ["src"]
}
11 changes: 11 additions & 0 deletions examples/react-native-webview-widget-page/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default defineConfig({
plugins: [react()],
server: {
// The RN app loads this page over the LAN, so bind beyond localhost.
host: true,
port: 5174,
},
})
5 changes: 5 additions & 0 deletions examples/react-native-webview/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Expo prebuild output - regenerate with `npx expo prebuild` / `expo run:ios`
/ios
/android
node_modules
.expo
124 changes: 124 additions & 0 deletions examples/react-native-webview/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Example host app: the LI.FI widget running inside a WebView, with this
* app's wallet serving the widget via the EIP-1193/6963 postMessage bridge.
*
* Run the widget page first (see ../widget-page), then point WIDGET_PAGE_URL
* at it. Load the page from a real origin (LAN IP in dev) - a file:// bundle
* gets an opaque origin, which breaks localStorage on Android and origin
* checks on iOS.
*/
import { type ComponentRef, useMemo, useRef } from 'react'
import {
Alert,
Platform,
SafeAreaView,
StatusBar,
StyleSheet,
} from 'react-native'
import { WebView, type WebViewMessageEvent } from 'react-native-webview'
import { bytesToHex } from 'viem'
import { generatePrivateKey, mnemonicToAccount } from 'viem/accounts'
import { buildInjectedProvider } from './src/bridge/injectedProvider'
import { isBridgeRequest } from './src/bridge/types'
import { type ApprovalRequest, WalletHost } from './src/bridge/walletHost'

// Dev server of ../widget-page. Use your machine's LAN IP for a device,
// localhost works for the iOS simulator.
// Optional local-swap harness (off by default). Run
// `anvil --fork-url <mainnet rpc> --chain-id 1` on the host and flip this on:
// the wallet becomes anvil's well-known account #0 (10k fake ETH on the fork)
// and all chain-1 traffic - the app's AND the widget's - is pointed at the
// fork, so same-chain swaps execute end-to-end with zero real funds. anvil's
// account #0 key is public knowledge; it holds nothing outside forks.
const HARNESS = false
const HARNESS_RPC = 'http://localhost:8545'
// anvil / hardhat well-known dev mnemonic. Account #0's key is derived from it
// at runtime rather than pasted in, so no real-looking private key literal
// lives in the example. It's a public test account that only ever holds fork
// funds - never put a real key here.
const ANVIL_MNEMONIC =
'test test test test test test test test test test test junk'

// The widget page's dev server. Use your machine's LAN IP on a real device;
// localhost works for the iOS simulator.
const WIDGET_PAGE_BASE = Platform.select({
ios: 'http://localhost:5174',
default: 'http://192.168.1.67:5174',
})!
// Point the widget's own SDK reads at the fork too, or execution status
// would be checked against real mainnet and never see the forked txs.
const WIDGET_PAGE_URL = HARNESS
? `${WIDGET_PAGE_BASE}/?config=${encodeURIComponent(
JSON.stringify({ sdkConfig: { rpcUrls: { 1: [HARNESS_RPC] } } })
)}`
: WIDGET_PAGE_BASE

// Ephemeral dev account, new on every app launch (or anvil's account #0 under
// the harness). Never hardcode a real key anywhere near production code.
const DEV_PRIVATE_KEY = HARNESS
? bytesToHex(mnemonicToAccount(ANVIL_MNEMONIC).getHdKey().privateKey!)
: generatePrivateKey()

const requestApproval = (request: ApprovalRequest): Promise<boolean> =>
new Promise((resolve) => {
Alert.alert('LI.FI widget', request.summary, [
{ text: 'Reject', style: 'cancel', onPress: () => resolve(false) },
{ text: 'Approve', onPress: () => resolve(true) },
])
})

export default function App() {
const webViewRef = useRef<ComponentRef<typeof WebView>>(null)

const walletHost = useMemo(
() =>
new WalletHost({
privateKey: DEV_PRIVATE_KEY,
requestApproval,
postToPage: (json) => webViewRef.current?.postMessage(json),
rpcOverrides: HARNESS ? { 1: HARNESS_RPC } : undefined,
}),
[]
)

const injectedJavaScriptBeforeContentLoaded = useMemo(
() =>
buildInjectedProvider({
name: 'LI.FI RN Example',
rdns: 'fi.li.example.rn',
// 1x1 green px placeholder; EIP-6963 requires a data:/https: URI.
icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
initialChainIdHex: walletHost.chainIdHex,
}),
[walletHost]
)

return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" />
<WebView
ref={webViewRef}
source={{ uri: WIDGET_PAGE_URL }}
injectedJavaScriptBeforeContentLoaded={
injectedJavaScriptBeforeContentLoaded
}
onMessage={(event: WebViewMessageEvent) => {
try {
const message = JSON.parse(event.nativeEvent.data)
if (isBridgeRequest(message)) {
walletHost.handleRequest(message)
}
} catch {
// Non-bridge traffic (or malformed) - not ours.
}
}}
// The widget is an SPA; avoid iOS rubber-banding inside it.
bounces={false}
/>
</SafeAreaView>
)
}

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#ffffff' },
})
93 changes: 93 additions & 0 deletions examples/react-native-webview/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# LI.FI Widget + React Native (WebView) Example

This project shows how to run the LI.FI Widget inside a React Native app. The
widget renders in a [`react-native-webview`](https://github.com/react-native-webview/react-native-webview),
and the React Native host serves it a wallet over an EIP-1193 provider that is
injected before the page loads and announced via EIP-6963. From the widget's
point of view the host app is just another injected wallet, so no widget
internals are touched.

## How it works

- The host injects a small EIP-1193 provider into the WebView with
`injectedJavaScriptBeforeContentLoaded` and announces it via EIP-6963, so
the widget's wallet menu lists the host app like any browser wallet
(`src/bridge/injectedProvider.ts`).
- Every `request({ method, params })` from the page is forwarded to React
Native over `postMessage` with an id-correlated response. The host answers
reads and signs transactions with [viem](https://viem.sh) in-process; keys
never enter the WebView (`src/bridge/walletHost.ts`).
- `chainChanged` / `accountsChanged` events and the EIP-1193 `4001` rejection
code are forwarded back into the page, so the widget's route execution stays
in sync and recovers cleanly when the user rejects.

## Project layout

Two packages, run together:

| Package | Role |
| --- | --- |
| `../react-native-webview-widget-page` | A small Vite page that renders `<LiFiWidget>`. Served from a real origin (never `file://`, whose opaque origin breaks `localStorage` on Android). |
| `react-native-webview` (this one) | The Expo host app: the WebView, the injected provider, and the viem-backed wallet host. |

## Requirements

- [Node](https://nodejs.org) + [pnpm](https://pnpm.io)
- Xcode (iOS) and/or Android Studio for a native build. Expo's New
Architecture is used, so this needs a dev build, not Expo Go.

## Installation

From the repo root:

```bash
pnpm install
```

## Run

1. Start the widget page (defaults to `http://localhost:5174`):

```bash
pnpm --filter react-native-webview-widget-page dev
```

2. Point `WIDGET_PAGE_BASE` in `App.tsx` at that server. `localhost` works for
the iOS simulator; use your machine's LAN IP for a physical device.

3. Build and launch the host app:

```bash
pnpm --filter react-native-webview-example-app ios
# or: pnpm --filter react-native-webview-example-app android
```

Open the widget's wallet menu and you will see the host app listed. Connect,
and requests round-trip through a native approval prompt.

## Wallets and chains

EVM only. The widget takes wallet providers explicitly since v4, so the page
passes `providers: [EthereumProvider()]`; the host's injected provider is
picked up by wagmi's multi-injected-provider discovery inside it.
Solana / Sui / Tron are separate provider families and are not covered here.

The example signs in-process with a viem local account. An external
WalletConnect wallet works the same way (it is just another EIP-1193 source on
the host), but app-switching suspends WKWebView JS timers on iOS, which can
freeze the widget mid-route, so in-process signing keeps the example simple.

## Optional: local swap harness

`App.tsx` has a `HARNESS` flag (off by default) for exercising a full swap
against a local [anvil](https://book.getfoundry.sh/anvil/) mainnet fork with no
real funds:

```bash
anvil --fork-url <mainnet-rpc> --chain-id 1
```

With `HARNESS = true`, the wallet becomes anvil's well-known account #0 and
both the app's and the widget's chain-1 reads are pointed at the fork, so a
same-chain swap executes end-to-end. anvil's account #0 key is public and holds
nothing outside a fork.
Loading