@moon-x/react-native-sdk
v0.3.0
Published
MoonX React Native SDK — WebView-backed transport, react-native-passkeys, AsyncStorage. Mobile parity with @moon-x/react-sdk + @moon-x/core-sdk.
Readme
@moon-x/react-native-sdk
React Native SDK for MoonX — feature parity with
@moon-x/react-sdk
on mobile. WebView-backed transport, native passkeys, AsyncStorage.
Install
# bare RN
pnpm add @moon-x/react-native-sdk \
react-native-webview react-native-passkeys \
@react-native-async-storage/async-storage \
react-native-get-random-values react-native-url-polyfill \
buffer \
viem bs58
# Expo
pnpm add @moon-x/react-native-sdk \
react-native-webview react-native-passkeys \
@react-native-async-storage/async-storage \
react-native-get-random-values react-native-url-polyfill \
buffer \
expo-web-browser expo-linking \
viem bs58The SDK declares everything except @moon-x/core as peer
dependencies. Privy follows the same pattern; their docs are the
canonical install reference and apply 1:1 here:
- https://docs.privy.io/basics/react-native/installation
- https://docs.privy.io/basics/react-native/advanced/setup-passkeys
Polyfills (order matters)
The SDK touches several globals that RN doesn't ship in every config. Import the polyfills at the top of your app entry, before any other code, so they're set up before anything else loads:
// index.js / index.ts — must run first.
// crypto.getRandomValues — needed by passkey challenge generation,
// MPC keygen, and viem.
import "react-native-get-random-values";
// URL / URLSearchParams — needed by viem and the OAuth deep-link
// helpers.
import "react-native-url-polyfill/auto";
// Buffer — needed by bs58, ethers, viem's RLP path, and the SDK's
// Solana broadcast helper. RN does not provide a Buffer global; on
// Expo it's polyfilled implicitly but you should set it explicitly so
// bare-RN consumers and any later eject-from-Expo users don't break.
// Same pattern Privy documents.
import { Buffer } from "buffer";
(global as any).Buffer = (global as any).Buffer || Buffer;
import { registerRootComponent } from "expo";
import App from "./App";
registerRootComponent(App);atob / btoa and TextEncoder are built into Hermes (RN's default
JS engine since 0.71). The peerDep react-native: ">=0.78.0" covers
this — but if you're targeting an older runtime or have explicitly
opted into JSC, add text-encoding and base-64 polyfills here too.
Provider
import { MoonKeyProvider } from "@moon-x/react-native-sdk";
export default function Root() {
return (
<MoonKeyProvider
publishableKey={process.env.EXPO_PUBLIC_MOONKEY_PUBLISHABLE_KEY!}
config={{
iframeUrl: "https://iframe.moonx-dev.com",
oauthRedirectUri: "myapp://oauth-callback",
}}
>
<App />
</MoonKeyProvider>
);
}config.iframeUrl is required and explicit — the RN SDK never
reads env vars to discover it. Use the same URL the web SDK points at.
The iframe-app detects WebView mode at runtime via
window.ReactNativeWebView and routes replies through the bridge
automatically; no iframe-app changes needed per integration.
Security
Every sensitive op (sign / send / create / import / export / addPasskey / removePasskey) drives the same server-verified presence ceremony per call:
- Server-issued WebAuthn challenge.
- Fresh biometric assertion (stripping
response.userHandlebefore anything crosses the bridge to MoonX — DEK hygiene). - Server mints short-lived (30s) single-use JWTs scoped to the specific endpoint set this op needs.
- Each scoped JWT travels as
X-MoonX-Presenceon its matching gated endpoint and is burned (platform.app_presence_jti_used) on first use.
What this closes: captured userHandle + session JWT alone no longer
unlocks DEK material — every gated endpoint also needs a fresh
WebAuthn signature MoonX verifies against the credential's stored
public key. See apps/platform/docs/notes/passkeys/presence-tokens.md
in the backend repo for the full threat model.
The previously-configurable config.security.assertionCacheTtlMs
and per-call requireFreshAssertion flag were removed entirely when
presence-token gating shipped — they are no longer on the public
TypeScript surface. Every op is always-fresh by construction.
Note on RN-side migration status: as of this writing the RN add-passkey (
sdk-passkey-methods.ts) and export-key (use-export-key.ts) flows still use the older parent-only assertion path. The web SDK is fully migrated to the presence-token orchestrator; RN parity is on the to-do list.
Hooks
Authentication & user state
| Hook | What it does |
|---|---|
| useMoonKey() | The big one. { ready, isAuthenticated, user, start, logout, setAppearance, getSessionTokens, refreshUser, ... } + every SDK method on the same instance. |
| useUser() | Just { user, refreshUser }. Re-subscribes to user changes. |
| useLoginWithEmail({ onComplete?, onError? }) | Headless email-OTP. Returns { state, sendCode, loginWithCode, reset }. state is a discriminated union: idle / sending / awaiting-code / verifying / complete / error. |
| useLoginWithOAuth() | Google + Apple flows via expo-web-browser. Returns { state, loginWithOAuth, reset }. |
| useLogout() | { logout } — also clears AsyncStorage + WebView session. |
| useAttachOAuth() / useDetachOAuth() | Link / unlink an OAuth provider on an existing user. |
Passkeys
| Hook | What it does |
|---|---|
| usePasskeyStatus() | { status, refresh }. status.passkeys lists the user's enrolled passkeys with provider labels. |
| useRegisterPasskey() | First-time passkey enrollment via the native ceremony (iOS ASAuthorization, Android Credential Manager). |
| useAddPasskey() | Add an additional passkey to an authenticated user. |
| useRemovePasskey() | Remove a passkey by its credential ID. |
Wallets
| Hook | What it does |
|---|---|
| useWallets() | { wallets, loading } — both Ethereum and Solana, fetched once on mount. |
| useCreateWallet() | Mint a new MPC wallet. Pass { walletType: "ethereum" \| "solana" }. |
| useImportKey() | Bring-your-own-key flow. |
| useExportKey() | Show the user their private key via the visible-WebView modal pattern (see below). Plaintext never leaves the WebView's DOM. |
Per-chain signing — /ethereum and /solana subpaths
Chain-aware hooks live under subpaths so they don't pull in the other chain's adapters if you only use one — same pattern as @moon-x/react-sdk:
import { useSignMessage, useSignTransaction, useSendTransaction } from "@moon-x/react-native-sdk/ethereum";
import { useSignMessage as useSignSolanaMessage } from "@moon-x/react-native-sdk/solana";Ethereum hooks:
useSignMessage— EIP-191personal_sign. Returns{ signature: "0x..." }.useSignTransaction— Signs an EIP-1559 tx. Returns{ signature, serializedSigned, hash }.useSignTypedData— EIP-712. Returns{ signature: "0x..." }.useSignHash— Raw ECDSA digest sign (Privy parity —secp256k1_sign).useSign7702Authorization— EIP-7702 delegation auth.useSendTransaction— Sign + broadcast via the configured RPC.useGetBalance— Native token balance.
Solana hooks:
useSignMessage— Ed25519 signature.useSignTransaction— Signs a serialized base58 tx. Returns{ signedTransaction: Uint8Array }.useSendTransaction— Sign + broadcast.useGetBalance— Lamport balance.
Same return shapes as the web SDK — the wire contract is identical.
Mobile-specific notes
- No
useConnectWallet. External-wallet connection (MetaMask, Phantom, WalletConnect) is web-only — MoonX wallets are the only thing you can sign with from the RN SDK. useExportKeyis a top-level hook on RN. On web the same operation hangs offuseMoonKey().exportKey()inside a parent-side modal; on RN it toggles the always-mounted WebView into a full-screen visible state so the iframe-app renders the key UI itself.
Visible-WebView pattern (sensitive flows)
useExportKey (and future useImportKey, useAddPasskey) toggle the
WebView from off-screen to a full-screen modal so the iframe-app can
render the secret-sequestration UI. The plaintext key never leaves the
WebView's DOM — RN-side JS only sees the rendered pixels.
The hook surface is still headless from the consumer's perspective:
const { exportKey } = useExportKey();
await exportKey(wallet); // shows modal, resolves on user-dismissNative config
The SDK ships an Expo config plugin (app.plugin.js) that writes
the iOS and Android edits described below at expo prebuild time. The
plugin is Expo-only — bare React Native projects (react-native
init, no expo dependency) ignore it entirely and must apply the
same edits by hand. Pick the path that matches your project.
Path A: Expo (recommended)
Add the plugin to app.json:
{
"expo": {
"scheme": "myapp",
"plugins": [
["@moon-x/react-native-sdk", {
"passkeyDomains": ["myapp.com"],
"oauthRedirectScheme": "myapp"
}]
]
}
}Then run pnpm prebuild (or pnpm prebuild --clean to regenerate
native code from scratch). The plugin produces:
- iOS —
webcredentials:<domain>Associated Domains entitlement for eachpasskeyDomainsentry. - iOS —
ITSAppUsesNonExemptEncryption = false(App Store export compliance). - Android —
<meta-data android:name="asset_statements">inside<application>inAndroidManifest.xml, plus a matching<string name="asset_statements">resource inres/values/strings.xmlpointing athttps://<passkeyDomain>/.well-known/assetlinks.jsonfor every entry inpasskeyDomains. Required by Credential Manager — without it,[50152] RP ID cannot be validated. - Android —
<intent-filter>onMainActivityfor theoauthRedirectSchemedeep link.
Re-run pnpm prebuild whenever you change the plugin options.
Path B: Bare React Native (no Expo prebuild)
The plugin does nothing here — app.json and app.plugin.js aren't
consulted. Apply each edit manually:
ios/<App>/<App>.entitlements — add:
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:myapp.com</string>
</array>Also ensure https://myapp.com/.well-known/apple-app-site-association
declares webcredentials for your bundle ID.
ios/<App>/Info.plist — add:
<key>ITSAppUsesNonExemptEncryption</key>
<false/>android/app/src/main/AndroidManifest.xml — inside the existing
<application> element add:
<meta-data
android:name="asset_statements"
android:resource="@string/asset_statements" />Inside <activity android:name=".MainActivity"> add an additional
intent filter for your OAuth deep link (alongside the default LAUNCHER
filter, do not replace it):
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>android/app/src/main/res/values/strings.xml — add:
<string name="asset_statements" translatable="false">
[{ \"include\": \"https://myapp.com/.well-known/assetlinks.json\" }]
</string>For multiple passkeyDomains, comma-separate the include objects
inside the same JSON array (escape every " as \"):
<string name="asset_statements" translatable="false">
[{ \"include\": \"https://myapp.com/.well-known/assetlinks.json\" }, { \"include\": \"https://staging.myapp.com/.well-known/assetlinks.json\" }]
</string>Android passkeys: Digital Asset Links
Android Credential Manager has no developer-mode bypass like
iOS Associated Domains does — every WebAuthn ceremony is validated
against https://<rpId>/.well-known/assetlinks.json, which must list
your app's package name + signing-cert SHA-256 fingerprint.
1. Publish assetlinks.json at the rpId apex
If passkeyDomains is myapp.com, host at
https://myapp.com/.well-known/assetlinks.json. Both relation strings
below are recommended per the
official Android docs:
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": [
"<debug-keystore-sha-256>",
"<upload-keystore-sha-256>",
"<play-app-signing-sha-256>"
]
}
}
]The file must be served as Content-Type: application/json over HTTPS
with a valid certificate.
2. Get every signing fingerprint you need
cd android && ./gradlew signingReportCopy the SHA-256 line from each Variant: block. Three flavors to
include:
- Debug: every developer's local builds. If you don't commit a
shared
debug.keystore, every developer's machine generates a different one — fix is to commit a single keystore to the repo (debug keystores are public-secret per the spec; password isandroid). Seeapps/rn-demo/.gitignorefor the negation pattern that letsandroid/app/debug.keystoresurviveexpo prebuild. React Native's template ships a community-defaultdebug.keystorewith SHA-256FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C— if you copy that file in (it's inreact-native-passkey,react-native-fast-image, etc.), every dev's debug build matches a single SHA. - Release / upload key: builds you upload to Play Console. Local release builds are signed with this until Google takes over.
- Play App Signing key: once your first release uploads, Google strips your upload key and re-signs with their managed key. The resulting fingerprint is the one users actually run. Find it in Play Console → Setup → App integrity → App signing key certificate. Add this SHA-256 the moment your first internal-track upload completes — without it, GPM rejects validation on installs shipped through Play.
3. Verify before debugging
Before reinstalling and re-attempting the ceremony, sanity-check the file with Google's official Digital Asset Links validator:
curl 'https://digitalassetlinks.googleapis.com/v1/assetlinks:check?source.web.site=https://myapp.com&relation=delegate_permission/common.get_login_creds&target.android_app.package_name=com.example.myapp&target.android_app.certificate.sha256_fingerprint=<SHA-WITH-COLONS>'Expected: { "linked": true, ... }. If this returns linked: false,
the on-device validator will also fail — fix the file first.
4. Register the Android fingerprint with the moon-x backend
The backend needs to know which Android signing certs are allowed to
act as the relying party for your app — Credential Manager substitutes
android:apk-key-hash:<base64url(sha256_of_signing_cert)> for the
WebAuthn clientDataJSON.origin instead of the HTTPS origin a browser
sends, and the platform does an exact-match check.
Set the webauthn.android_apk_key_hashes app setting to a JSON array
of every Android signing cert SHA-256 (hex, with or without colons —
both forms accepted; same values you put in assetlinks.json):
["FA:C6:17:45:DC:09:...", "AA:BB:CC:..."]Symptom when missing: passkey ceremony succeeds at the OS level (you
see and pass the biometric prompt), then the backend's verify step
rejects with error validating origin.
5. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Native dialog opens, user taps, response never returns; logcat shows [50152] RP ID cannot be validated. | assetlinks.json not reachable, wrong package_name, wrong SHA, OR a stale negative DAL cache from an earlier failure | Verify file with Google's validator (step 3); uninstall and reinstall the app — clearing com.google.android.gms data does NOT evict the per-package DAL cache, only a reinstall does. |
| error validating origin from the backend after the OS ceremony succeeds | Android signing fingerprint not registered with the backend | Add the SHA to webauthn.android_apk_key_hashes (step 4). |
| Spec-correct setup that worked in dev fails for users installing from Play | Play App Signing key SHA missing | Add the Play App Signing fingerprint from Play Console to assetlinks.json AND webauthn.android_apk_key_hashes. |
| androidx.credentials.exceptions.domerrors.DataError@<hash> with no detail | Older react-native-passkeys doesn't surface the underlying DOM error | Watch logcat directly: adb logcat \| grep -E "Auth.Api.Credentials\|Fido" — the real error code (e.g. [50152]) is visible there. |
| Persistent failure even though everything looks correct | DAL cache from a prior bad attempt | adb uninstall <package> then reinstall. Do not rely on adb shell pm clear com.google.android.gms — it logs the user out of Google but leaves the per-package DAL cache. |
6. Min versions / known issues
- Google Password Manager validates more strictly than the WebAuthn
spec requires. Some debug-keystore + assetlinks setups that pass
Google's centralized DAL validator still fail GPM's on-device check
on first install. Reinstall after publishing
assetlinks.jsonis the most reliable workaround. - Real device testing is recommended: emulator + GMS sometimes exhibits subtly different validation behaviour.
- Android 13+ is what we test against. The Credential Manager backport on 9-12 works for most flows but isn't continuously exercised here.
Min OS versions
- iOS 16+ (passkeys via ASAuthorization)
- Android 9+ (passkeys via Credential Manager)
- React Native 0.78+ (React 19 support)
- Hermes is the supported JS engine. JSC will work for non-passkey flows but isn't tested.
What's deferred from v1
- WalletConnect / external-wallet connectors
- "WithUI" SDK method variants (web-only — RN is headless)
- Iframe-app emitting
EXPORT_COMPLETEone-way postMessage on user-dismiss (currently the modal's close button resolves the promise; the post-back contract is a planned follow-up) - RN-mode bundle-ID origin whitelist on the iframe-app
(
window.ReactNativeWebView-mode currently bypasses origin whitelisting; publishableKey + session JWT are the gates in v1)
Reference
- Web parity:
@moon-x/react-sdk - Wire protocol:
packages/core/src/utils/post-message.ts - Architecture:
packages/react-native-sdk/AGENTS.md
