# @funkit/connect-rn

React Native bindings for Funkit Connect. **This README is for working _on_ the
package.** For integrating the SDK into an app (install, peer dependencies,
`FunkitProvider` API, `DepositFlowConfig`), see the client integration guide —
maintained in Notion. To run the SDK on a simulator/device, use the
[`with-expo`](../../apps/with-expo) playground.

## Architecture

`@funkit/connect-rn` is the React Native half of a three-package split. The
headless business logic lives once in
[`@funkit/connect-core`](../connect-core) and is shared by web and RN, so the two
platforms can't drift:

| Package | Role |
| --- | --- |
| `@funkit/connect-core` | Headless, platform-agnostic — domains, utils, query/Statsig hooks, theme factories, `FunLogger`. **No DOM, vanilla-extract, or wagmi** under `src/**`. |
| `@funkit/connect` | Web presentation halves (`.tsx`, vanilla-extract, Dialog/portals, iframe transport, wagmi identity). |
| `@funkit/connect-rn` | **This package** — the RN presentation halves over core. |

The platform halves this package supplies (mirroring connect's web halves):

- a local Shopify Restyle theme + `useFunkitTheme()` / `useColorScheme()`
- `FunLogger` wired to a React Native log transport
- `WebViewSwappedTransport` + the Swapped screen
- the managed Add money → Transfer / UDA → QR flow

Core stays platform-agnostic by depending on **contracts** that each platform
implements: `IdentityProvider` (web wraps wagmi; RN takes host props),
`SwappedTransport` (web `<iframe>`; RN `WebView`), and `LogTransport` (web
Datadog browser-logs; RN console). The customer `theme` is a plain `ThemeVars`
object from core that RN maps to a Restyle theme via `toRestyleTheme(tokens)`.

**Public surface.** `index.ts` re-exports core plus an **explicit, named** set of
RN symbols (no barrels): the provider, the `useFunkitCheckout` entry hook, theme
tokens/adapter/primitives, and config types. The screens, Statsig RN bindings,
log transports, identity context, and transfer hooks are **internal** (mirroring
`@funkit/connect`, which exports none of them). When adding a public export, wire
it explicitly in `index.ts`.

## Payment deposit metadata

`clientMetadata.authorizedAmountBaseUnit` is an explicit maximum gross deposit
in destination-token base units, forwarded unchanged to `POST /eoa`. For Base
EURC, `'20000000'` caps the gross deposit at 20 EURC before fees. It is separate
from `initialAmount` (fiat input), `lockAmount` (UI editing), and actual net credit;
no cap is inferred. Supply a positive integer string of 1–30 digits without leading
zeros, or `beginDeposit` throws `FunkitMisconfiguredError` before opening.
The field is optional in the SDK; Betty's backend requires it with the payment
IDs and rejects a different cap or destination when reusing the same payment ID.

## MoonPay crypto setup

MoonPay requires a secure `globalThis.crypto.getRandomValues` implementation on
Hermes. Install it in your app entry **before importing `@funkit/connect-rn` or
loading your app**. The SDK supports either setup below; it does not install a
native crypto module for you. See the [MoonPay SDK README](https://www.npmjs.com/package/@moonpay/platform-sdk-react-native).

### Expo

Install the version compatible with your Expo SDK:

```sh
pnpm exec expo install expo-crypto
```

Create `polyfills.ts`:

```ts
import { getRandomValues } from 'expo-crypto'

if (typeof globalThis.crypto?.getRandomValues !== 'function') {
  const crypto = globalThis.crypto ?? ({} as Crypto)
  Object.defineProperty(crypto, 'getRandomValues', {
    value: getRandomValues,
    configurable: true,
    writable: true,
  })
  if (globalThis.crypto !== crypto) {
    Object.defineProperty(globalThis, 'crypto', {
      value: crypto,
      configurable: true,
      writable: true,
    })
  }
}
```

Load it first in your entry. For Expo Router, use `import './polyfills'` followed
by `import 'expo-router/entry'`. For a custom Expo entry:

```ts
import './polyfills'
import { registerRootComponent } from 'expo'
import App from './App'

registerRootComponent(App)
```

### Bare React Native

Install `react-native-get-random-values` with `pnpm add react-native-get-random-values`,
install iOS pods, and rebuild the native app. Make this the first import in your
entry, before importing your app:

```ts
import 'react-native-get-random-values'
```

MoonPay 1.17.0 includes the `about:srcdoc` WebView origin allowance. Consumers no
longer need a dependency patch for MoonPay's authorization frame.

## Peer dependencies

These are declared as **peer dependencies** (not bundled), so the host app links a
single copy and controls the native-module versions. Editing `peerDependencies`
in `package.json` is a public-API change — bump it deliberately and add a
changeset (a *peer* bump is a **major** change for consumers).

The authoritative list lives in `package.json` (`peerDependencies` /
`peerDependenciesMeta`) — it's intentionally **not** duplicated here so it can't
drift. Adding a peer dependency is a consumer-facing decision: every integrator
must install it. Before adding one:

- **Pick it carefully.** Prefer a widely-used, well-maintained library — ideally
  one (nearly) every customer already depends on — over a niche package that adds
  install burden for everyone. A peer dep is a tax on all consumers.
- **Update the client docs.** A new peer dep changes the install/setup steps
  integrators follow, so the client integration guide (Notion) must be updated in
  the same change.
- **Native modules need extra care.** A native peer (iOS/Android native code, vs
  JS-only) can't be linked by the host without a rebuild, carries version
  compatibility constraints against the RN core, and may be absent in some
  runtimes. Treat it as a heavier commitment than a JS-only dep.
- **Prefer Expo-Go-bundled native modules.** When a native peer is needed, prefer
  one already bundled in Expo Go (see [Expo Go compatibility](#expo-go-compatibility))
  so the SDK keeps working in the playground without a custom dev build. This is a
  preference, not a hard requirement — if a non-bundled native module is genuinely
  needed, make it optional + lazy + guarded with a host override (the way
  clipboard is).
- **Don't depend on the Expo SDK.** "Bundled in Expo Go" is a convenience for our
  playground, not an assumption about consumers — many integrate via **bare React
  Native**, not Expo. Prefer a framework-agnostic RN library over one that needs the
  Expo SDK (the `expo` package) installed to work, so it runs either way. An
  `expo-*` name isn't itself a red flag — those modules are *published by* Expo but
  run fine in bare RN; the concern is a library that genuinely requires the Expo
  runtime, like `@expo/ui`. For example, `@gorhom/bottom-sheet` is preferred over
  `@expo/ui`'s bottom sheet.

## Expo Go compatibility

connect-rn is **designed to run in Expo Go** (no custom dev build needed) — this
is what lets the [`with-expo`](../../apps/with-expo) playground preview the flow
in Expo Go. **Keep new code Expo-Go-safe.**

Every native peer the SDK relies on is either bundled in Expo Go for the target
SDK (`react-native-reanimated`, `react-native-gesture-handler`,
`react-native-webview`, `react-native-svg`, `react-native-safe-area-context`,
AsyncStorage) or JS-only (`@gorhom/bottom-sheet`, `react-native-qrcode-svg`).
Two deliberate choices keep it that way:

- **Clipboard is optional, lazy, and guarded.** `@react-native-clipboard/clipboard`
  is a bare-RN native module *not* in Expo Go. `src/transfer/clipboard.ts` only
  `import()`s it **when a copy actually happens** (never at startup) inside a
  try/catch — so in Expo Go, where `getEnforcing('RNCClipboard')` would throw, a
  copy degrades to a no-op + warning rather than crashing. A host injects its own
  writer via `FunkitProvider`'s `copyToClipboard` (e.g. `expo-clipboard`), and
  that override runs instead, so the throwing path never executes.
- **Stripe is an optional peer, loaded on demand.** `@stripe/stripe-react-native`
  is a native module *not* in Expo Go, and it throws at import time on a binary
  without its pod. Nothing names the package outside the guarded `import()` in
  `src/fiat/providers/stripeSdk.ts`, which `StripeOnrampBridge` runs only after a
  host passed `stripeOnramp` to `FunkitProvider`. A host that never configures
  Stripe never installs it and never links it. A host that wants Stripe installs
  it, enables its Onramp module (Expo: the config plugin with
  `includeOnramp: true`; bare RN: `stripe-react-native/Onramp` in the Podfile and
  `StripeSdk_includeOnramp=true` in `gradle.properties`), and rebuilds; that
  rail cannot run in Expo Go.
- **The glass module is optional at runtime.** `FunkitGlassEffect` (this
  package's `ios/`) is not in Expo Go. `src/native/glassEffect.ts` looks it up
  with `requireOptionalNativeModule`, so an unlinked binary renders the
  `expo-blur` fallback (which Expo Go bundles) instead of throwing.
- **No `react-native-device-info`.** `src/statsig/statsigMetadata.ts` vendors the
  Statsig metadata adapter with the `react-native-device-info` dependency removed
  (it's a native module that needs a dev build). The app-version / device-model
  fields are left `undefined`; `systemName` and `locale` are read from RN's
  built-in `Platform` / `NativeModules` (no extra native dep). Geo/IP resolution
  uses plain `fetch`.

**Contributor rule:** don't add a hard top-level import of a native module that
Expo Go doesn't bundle. If you genuinely need one, make it optional + lazily
imported + guarded with a host-injectable override, the way clipboard is — or,
for a native view of our own, look it up as an optional module the way the glass
view is.

## Sheet material (blur / Liquid Glass)

The V2 deposit sheet and the drawers over it (fee details, country list, KYC
choosers) are drawn on glass: the design's Figma layer is a dark fill with a
**Glass** effect, which is iOS 26's Liquid Glass. The SDK ships that material
itself (`src/flow/SheetMaterial.tsx`): an `expo-blur` blur of the host app with
the SDK's own Liquid Glass view refracting through it. The tint is the
material's — every surface renders it as is, and none passes one in.

The glass view is a native module this package carries under `ios/`
(`FunkitGlassEffect`, forked from `expo-glass-effect` with iOS 26's
container-concentric corners added, so the slab's corners match the device's).
Expo autolinking picks it up from `expo-module.config.json`; a host rebuilds
its binary once after upgrading. Where the pod isn't linked — Android, Expo Go,
a binary built before the upgrade — `src/native/glassEffect.ts` reads the
module as absent, the blur stands alone, and each surface paints its fill over
it, as the blur path always has.

`expo` and `expo-blur` are peer dependencies for this. A host that wants a
different native material still has `FunkitProvider`'s `renderBackdropBlur`,
paired with `liquidGlass` when that view is Liquid Glass; the SDK then paints
no fill over it.

## Lighter withdrawals

Lighter lives on its own entry, `@funkit/connect-rn/clients/lighter`, so a host
that never configures it carries none of it. **You** call the hook and pass the
methods it returns to `beginWithdrawal` — the SDK's withdrawal config names no
venue. The methods replace the generic crypto row on the method screen:

| | Fast |
| --- | --- |
| Source | USDC Perps |
| Destination | any supported chain/token |
| Path | Fun + Relay |
| Speed | seconds |
| Limits | $4 min, $3M max |

```tsx
import { useFunkitCheckout } from '@funkit/connect-rn'
import {
  type LighterWithdrawalFlowConfig,
  useLighterWithdrawal,
} from '@funkit/connect-rn/clients/lighter'

const lighter: LighterWithdrawalFlowConfig = {
  // The end user's L1 address. The SDK resolves their L2 main-account index
  // from it — Lighter addresses accounts by index, not by address.
  l1Address,
  // Your Lighter L2 signer. The SDK never holds L2 key material.
  signerClient,
  // Reserved for the Native Bridge rail, which has not shipped — see below.
  sendLighterSecureWithdrawal: ({ amountTokenUnits, assetIndex, routeType }) =>
    client.withdraw({ amount: amountTokenUnits, assetIndex, routeType }),
  // Your own accounting: margin requirements and locked collateral are not
  // visible to the SDK.
  withdrawalSourceTokenBalance: ({ sourceToken }) =>
    balances[sourceToken.symbol] ?? 0,
  // Awaited before the rail signs. Lighter strategies and withdrawals share
  // one L2 signing account, so cancel in-flight strategies here or the
  // withdrawal signs against a nonce that has already moved. Throwing aborts
  // the withdrawal and consumes no nonce.
  onBeforeSign: () => cancelOpenStrategies(),
}

// Empty until the L2 account resolves — both rails address it by index, so the
// flow falls back to its generic crypto row until then.
const methods = useLighterWithdrawal({
  lighter,
  config: { ...withdrawalConfig, withdrawalWallet },
})

const { beginWithdrawal } = useFunkitCheckout()
beginWithdrawal({ ...withdrawalConfig, withdrawalWallet, methods })
```

### The unstaked fee

Lighter charges a flat $3 on Fast withdrawals for users not sufficiently staked
in its LIT pool. The SDK reads the stake off the account and takes the fee off
the amount **before** quoting — so the quote prices what Lighter will actually
move, and the user is not promised ~3 tokens the transfer never delivers.

That timing is the whole contract: `quoteInputFeeTokens` is deducted pre-quote,
and a fee taken from a quote's *result* is a different number at a different
time. If a venue ever charges that way it needs its own field rather than
reusing this one.

It gets no row in the fee summary. The quote already prices the smaller amount,
so its own cost rows carry what the withdrawal costs; a venue that bills its fee
as source-chain gas instead sets `extraSourceGasUsd`, which lands in the network
cost row.

While the account is still loading the fee holds at 0 — "no shares" and "not a
staker" are indistinguishable then, and charging would bill an exempt user. A
*failed* lookup fails closed and charges, because Lighter takes the fee whether
or not the SDK could see the stake.

**The Native Bridge rail is not available yet.** Only Fast is reachable — its
row is the sole Lighter method on the screen. `sendLighterSecureWithdrawal` is
required by the config type and reserved for that rail, so it must be supplied
even though nothing calls it today; a stub is fine. Same for the optional
`getSecureMinWithdrawalAmount`.

## Solana withdrawals

Solana lives on its own entry, `@funkit/connect-rn/clients/solana`, so a host
that never configures it carries none of it. The host owns the Solana wallet
connection; the SDK takes the connected wallet's signer and builds a withdrawal
config over it. USDC on Solana is the only source today, and both the crypto
and the cash (Swapped) methods work from it.

```tsx
import { useFunkitCheckout } from '@funkit/connect-rn'
import { useSolanaWithdrawal } from '@funkit/connect-rn/clients/solana'
import { useMemo } from 'react'

// Both inputs are compared by identity, so keep them stable across renders.
const solana = useMemo(() => ({ signer: solanaWallet }), [solanaWallet])
const config = useMemo(
  () => ({
    defaultReceiveToken: 'USDC',
    // Your own accounting for the spendable balance, if you have it.
    withdrawalSourceTokenBalance: () => usdcBalance,
  }),
  [usdcBalance],
)
// `solanaWallet`: a base58 address, the RPC connection funkit submits through,
// and one of `signTransaction` / `signAndSendTransaction`.
const withdrawal = useSolanaWithdrawal({ solana, config })

const { beginWithdrawal } = useFunkitCheckout()
// `undefined` while the signer is absent or its address is not base58.
if (withdrawal) beginWithdrawal(withdrawal)
```

Install `@solana/web3.js` (an optional peer) and give Hermes what it lacks
before importing the SDK: `Buffer` (the `buffer` package), a full `URL`
(`react-native-url-polyfill/auto`), and `crypto.getRandomValues` as described
under [MoonPay crypto setup](#moonpay-crypto-setup). Point the connection at an
RPC you control; Solana's public endpoints rate-limit.

## Local development

> No root shortcut exists for this package — use the `pnpm --filter` form (or
> `cd packages/connect-rn` and drop the filter).

```bash
pnpm --filter @funkit/connect-rn build         # esbuild bundle + typecheck
pnpm --filter @funkit/connect-rn build:watch   # rebuild on change
pnpm --filter @funkit/connect-rn dev           # build:watch + type-gen watch in parallel
pnpm --filter @funkit/connect-rn typecheck     # tsc --noEmit
pnpm --filter @funkit/connect-rn lint          # oxlint (lint:fix to auto-fix)
pnpm --filter @funkit/connect-rn storybook     # web Storybook on http://localhost:7007
```

Typical inner loop when changing the SDK and watching it in the app: run
`pnpm --filter @funkit/connect-rn dev` in one terminal and
`pnpm --filter with-expo start` in another. Metro watches the whole monorepo, so
SDK edits hot-reload in the playground.

## Testing

Tests run with **Vitest** in the **`node`** environment (no DOM — native modules
are stubbed). Components mount through `test/testRenderer.ts` (a DOM-free
`test-renderer` adapter) + `act()`, not React Testing Library. It tracks host
nodes only, so query by tag string (`'view'`, `'text'`, `'pressable'`) rather
than by component reference.

```bash
pnpm --filter @funkit/connect-rn test                # run once
pnpm --filter @funkit/connect-rn test -- --watch     # watch mode
pnpm --filter @funkit/connect-rn test -- test/providers/FunkitProvider.test.tsx  # one file
```

- Config: `vitest.config.ts`. Setup: `test/setup.ts` (stubs global `fetch`,
  enables `IS_REACT_ACT_ENVIRONMENT`).
- Native-module stubs live in `test/*.stub.ts(x)` (`react-native`,
  `react-native-svg`, `react-native-reanimated`, `@gorhom/bottom-sheet`,
  clipboard, webview, qrcode). **Pulling in a new native module? Add a stub** or
  the import fails under node.
- **Mock at the `@funkit/api-base` edge** — never mock internal hooks
  (`useTransferInit`, etc.). Snapshot tests may mock internal hooks to freeze
  inputs. See the repo [TESTING.md](../../TESTING.md).

## Fonts

connect-rn's theme renders text in the **system UI font** by default (SF on iOS,
Roboto on Android) at two weights: `400` (regular) and `500` (medium). There is
no semibold/bold in the token set. The default needs **no font assets** — it
works out of the box.

connect-rn is **font-agnostic**: it ships no typeface and no font preset. To
render in a custom font (the Fun Mobile design uses **Inter**), supply your own
`fonts` tokens and register the matching font files (see "Rendering in a custom
font" below).

**connect-rn does not — and cannot — load fonts itself.** A JS-only React
Native library has no way to register a font with the native font manager: fonts
are either bundled and linked natively (iOS `UIAppFonts` / Android `assets/fonts`)
or loaded at runtime through a native module such as
[`expo-font`](https://docs.expo.dev/versions/latest/sdk/font/). Both live in the
**host app**, not in this package.

### Rendering in a custom font

To render in a custom font instead of the system default, set `fonts` on your
tokens and register the matching font files.

You can't select a custom font's weight via `fontWeight` in React Native/Expo:
Android matches custom fonts by exact family-name string, and a font loaded at
runtime registers one native typeface per name — so `fontWeight: '500'` on a
custom `Inter` family is silently ignored. connect-rn therefore expresses weight
through the **family name** (set `family` + a per-weight `familySuffix`), and the
host registers **each cut under its own name** — `Inter-Regular`, `Inter-Medium`
and `Inter-SemiBold`. Use `fontFamilyForWeight(...)` to resolve those names so you
don't hardcode them.

Define the font tokens in your host app, load the files **before rendering
`<FunkitProvider>`** (if a name is missing, that text falls back to the system
font), and build your theme with them. Example with Expo (`expo-font` +
`@expo-google-fonts/inter`):

```tsx
import {
  useFonts,
  Inter_400Regular,
  Inter_500Medium,
  Inter_600SemiBold,
} from '@expo-google-fonts/inter'
import {
  type FunkitFontTokens,
  fontFamilyForWeight,
  lightTokens,
  toRestyleTheme,
} from '@funkit/connect-rn'

// Host-owned: weight lives in the family name (RN/Expo can't pick a cut via
// fontWeight). cssWeight is kept for the Swapped WebView (CSS) theme.
const interFonts: FunkitFontTokens = {
  family: 'Inter',
  weights: {
    base: { familySuffix: 'Regular', cssWeight: '400' },
    medium: { familySuffix: 'Medium', cssWeight: '500' },
    semibold: { familySuffix: 'SemiBold', cssWeight: '600' },
  },
}

const themes = {
  light: toRestyleTheme({ ...lightTokens, fonts: interFonts }),
  // …dark likewise
}

export default function App() {
  const [fontsLoaded] = useFonts({
    [fontFamilyForWeight(interFonts, 'base')]: Inter_400Regular, // 'Inter-Regular'
    [fontFamilyForWeight(interFonts, 'medium')]: Inter_500Medium, // 'Inter-Medium'
    [fontFamilyForWeight(interFonts, 'semibold')]: Inter_600SemiBold, // 'Inter-SemiBold'
  })
  if (!fontsLoaded) return null // or a splash screen
  return <FunkitProvider themes={themes} {...}>{/* … */}</FunkitProvider>
}
```
