@funkit/connect-rn
v3.8.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.
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. - 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 bare-RN 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.
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.
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 — e.g. Inter-Regular and
Inter-Medium. 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 } 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' },
},
}
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'
})
if (!fontsLoaded) return null // or a splash screen
return <FunkitProvider themes={themes} {...}>{/* … */}</FunkitProvider>
}