@funkit/connect-rn
v9.2.0
Published
React Native bindings for @funkit/connect. Re-exports the headless @funkit/connect-core logic; the RN platform halves (Restyle theme, WebView Swapped transport, Transfer/Swapped screens, logging) land in later releases.
Readme
@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 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 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() FunLoggerwired to a React Native log transportWebViewSwappedTransport+ 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.
Withdrawal display currency
Set WithdrawalFlowConfig.displayCurrency to 'EUR' to enter crypto withdrawal
amounts in euros. Users can switch between EUR and source-token units. The
conversion uses the token's USD price and the shared USD-to-EUR rate, without
assuming that EURC trades at exactly one euro. For example, at $2 per token and
0.8 EUR per USD, entering €8 stages 5 tokens for withdrawal.
Token amounts round down to the token's precision; the typed fiat value is preserved through denomination switches and navigation. Use Max still stages the exact allowed token amount. Rate refreshes do not rewrite an existing entry. Until usable rates are available, entry stays in clearly labelled token units; a rate arriving later does not reinterpret an amount already typed. Cached rates remain usable if a background refresh fails.
Omitting the option, or setting 'USD', preserves existing USD entry behavior.
Limits, quotes, confirmation screens, and settlement retain their existing
denominations. Configure withdrawal ceilings separately using the maximum
callbacks; a EUR ceiling does not implicitly select an entry currency.
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.
When lockAmount is set and this cap is supplied, the transfer screen sizes the
amount to send from a routing quote for the selected source token: the price-feed
estimate is quoted, scaled so the route delivers the cap, rounded up, then grown by
the lockeddepositbufferbps Statsig config (default 30 bps, targetable per
customer via the Statsig user's apiKey). The buffer is skipped when the quoted
route returns the sent amount in the same asset (same token on the same chain, or
the same symbol across chains). If the quote or its route inputs are unavailable,
the screen falls back to the price-feed estimate plus the same buffer and logs a
warning.
Locked deposits without this cap keep the plain price-feed estimate.
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.
Expo
Install the version compatible with your Expo SDK:
pnpm exec expo install expo-cryptoCreate polyfills.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:
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:
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.
Identity verification permissions (iOS)
Sumsub's document capture photographs an ID, takes a selfie and uploads documents such as proof of address, so iOS hosts must declare both usage strings:
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "Used to photograph your ID and take a selfie when identity verification is required for a deposit.",
"NSPhotoLibraryUsageDescription": "Used to upload a photo of your ID or a document such as proof of address when identity verification is required for a deposit."
}
}
}
}In bare React Native, add the same two keys to ios/<App>/Info.plist.
Sumsub's native SDK (optional)
Sumsub's identity capture runs in a WebView by default. Its WebSDK takes no colors, fonts or radii from code, so it never matches your theme. Install Sumsub's mobile SDK and the same step opens Sumsub's native screens, themed from your connect-rn theme (light/dark colors, brand overrides, Inter, button and card radii).
@sumsub/react-native-mobilesdk-module is an optional peer. Without it, or
in a binary that doesn't link it (Expo Go included), Sumsub stays in the
WebView, unchanged. Sumsub's screens present modally over your app, so our back
button and step dots don't show on them; closing Sumsub before submitting
counts as a cancel.
Expo
npx expo install @sumsub/react-native-mobilesdk-moduleAdd connect-rn's config plugin, and embed the Inter cuts natively with
expo-font — Sumsub's native screens can't see a font loaded at runtime:
{
"expo": {
"plugins": [
[
"expo-font",
{
"fonts": [
"node_modules/@expo-google-fonts/inter/400Regular/Inter_400Regular.ttf",
"node_modules/@expo-google-fonts/inter/500Medium/Inter_500Medium.ttf",
"node_modules/@expo-google-fonts/inter/600SemiBold/Inter_600SemiBold.ttf"
]
}
],
[
"@funkit/connect-rn",
{
"sumsub": {
"cameraPermission": "Used to photograph your ID and take a selfie.",
"microphonePermission": "Used to record a short video when asked.",
"photoLibraryPermission": "Used to upload a photo of your ID or a document.",
"locationPermission": "Used to confirm your country when asked."
}
}
]
]
}
}The plugin:
- adds Sumsub's CocoaPods spec repo (
https://github.com/sumsub/Specs.git) and the CocoaPods CDN to the Podfile; - adds Sumsub's Maven repository (
https://maven.sumsub.com/repository/maven-public/) to the Android project; - sets
NSCameraUsageDescription,NSMicrophoneUsageDescription,NSPhotoLibraryUsageDescriptionandNSLocationWhenInUseUsageDescription. Eachsumsuboption overrides its key; without one, a reason your app already declares is kept, and a default is written otherwise.
Every option is optional, so "@funkit/connect-rn" alone works. Pass
{ "sumsub": false } to skip the native setup. Then rebuild the native app
(npx expo prebuild / expo run:ios).
Bare React Native
Follow Sumsub's React Native setup:
the two source lines at the top of the Podfile, the Maven repository, and the
four Info.plist keys above. Bundle the Inter .ttf files natively (UIAppFonts
on iOS, android/app/src/main/assets/fonts on Android), named
Inter-Regular, Inter-Medium and Inter-SemiBold as the theme uses them.
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) 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
expopackage) installed to work, so it runs either way. Anexpo-*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-sheetis 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 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/clipboardis a bare-RN native module not in Expo Go.src/transfer/clipboard.tsonlyimport()s it when a copy actually happens (never at startup) inside a try/catch — so in Expo Go, wheregetEnforcing('RNCClipboard')would throw, a copy degrades to a no-op + warning rather than crashing. A host injects its own writer viaFunkitProvider'scopyToClipboard(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-nativeis 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 guardedimport()insrc/fiat/providers/stripe/stripeSdk.ts, whichStripeOnrampBridgeruns only after a host passedstripeOnramptoFunkitProvider. 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 withincludeOnramp: true; bare RN:stripe-react-native/Onrampin the Podfile andStripeSdk_includeOnramp=trueingradle.properties), and rebuilds; that rail cannot run in Expo Go. - Sumsub's native SDK is an optional peer, loaded on demand. The capture
step checks for its native module first and
import()s@sumsub/react-native-mobilesdk-moduleonly when it is linked (src/fiat/providers/sumsub/sumsubSdk.ts); otherwise Sumsub runs in the WebView. - The glass module is optional at runtime.
FunkitGlassEffect(this package'sios/) is not in Expo Go.src/native/glassEffect.tslooks it up withrequireOptionalNativeModule, so an unlinked binary renders theexpo-blurfallback (which Expo Go bundles) instead of throwing. - No
react-native-device-info.src/statsig/statsigMetadata.tsvendors the Statsig metadata adapter with thereact-native-device-infodependency removed (it's a native module that needs a dev build). The app-version / device-model fields are leftundefined;systemNameandlocaleare read from RN's built-inPlatform/NativeModules(no extra native dep). Geo/IP resolution uses plainfetch.
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): a blur of the host app with Liquid Glass
refracting through it, drawn by one native view, FrostedGlassView, so the
blur and the glass share a single corner. The tint is the material's — every
surface renders it as is, and none passes one in. The deposit-received toast
draws its card on the same view.
The view lives in a native module this package carries under ios/
(FunkitGlassEffect). It also exports GlassView, forked from
expo-glass-effect with iOS 26's container-concentric corners added, which the
SDK uses alone for controls floating over content. Expo autolinking picks the
module up from expo-module.config.json; a host rebuilds its binary once after
upgrading. Below iOS 26 FrostedGlassView draws the blur without glass. Where
the pod isn't linked — Android, Expo Go, a binary built before the upgrade —
src/native/glassEffect.ts reads the module as absent and the surfaces draw
an expo-blur blur instead. Wherever there is no glass, each surface paints
its fill over the blur.
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 |
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.
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. 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 --filterform (orcd packages/connect-rnand drop the filter).
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:7007Typical 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.
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 globalfetch, enablesIS_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-baseedge — never mock internal hooks (useTransferInit, etc.). Snapshot tests may mock internal hooks to freeze inputs. See the repo 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. 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):
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>
}