@moon-x/react-native-sdk
v0.13.1
Published
Headless MoonX React Native SDK with WebView-backed transport, native passkeys, and app-owned UI.
Readme
@moon-x/react-native-sdk
Headless React Native SDK for MoonX. Applications own their authentication, wallet, funding, connection, and signing UI; the SDK provides hooks, native ceremonies, session state, wallet operations, and a WebView-backed trust boundary.
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 { MoonXProvider } from "@moon-x/react-native-sdk";
export default function Root() {
return (
<MoonXProvider
publishableKey={process.env.EXPO_PUBLIC_MOONX_PUBLISHABLE_KEY!}
config={{
iframeUrl: "https://iframe.moonx-dev.com",
oauthRedirectUri: "myapp://oauth-callback",
}}
>
<App />
</MoonXProvider>
);
}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.
Hooks
Authentication & user state
| Hook | What it does |
|---|---|
| useMoonX() | The active headless SDK instance: { ready, isAuthenticated, user, logout, getSessionTokens, refreshUser, ... }. Web-only start, modal, and appearance methods are intentionally absent. |
| 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. |
getSessionTokens() returns { accessToken, identityToken, expiresAt } or
null. Refresh tokens remain inside the WebView and are never exposed to the
host application.
getCurrentSession() returns the current session or null when the WebView
authoritatively reports that no session exists. It rejects on bridge/transport
failures. Callers that poll it directly should catch those failures and retain
their last-known auth state rather than treating an unavailable WebView as a
logout. This distinction was introduced with @moon-x/core 0.15.0.
The provider revalidates its hidden-WebView session on cold start and whenever
the native app returns to the foreground. revalidatedAt advances after each
probe so shared wallet hooks refresh app-owned wallet state.
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(walletType) | Generic chain-aware list hook for Ethereum, Solana, or Tron. Chain subpaths provide pinned useWallets() variants. |
| useCreateWallet() | Generic top-level lifecycle hook; chain subpaths provide pinned variants. |
| 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. |
| useEphemeralSigner() | Manage the user's WaaS ephemeral signers (SES) end-to-end. Returns { signers, isLoading, refresh, provision, revoke, lastProvisioned, error, reset, ... }. Auto-refreshes the list after provision/revoke. |
The main entry and chain subpaths deliberately use different wallet-lifecycle call shapes. The main entry is generic and takes an explicit chain; subpaths pin the chain and match the web SDK facades:
| Import | Call | Result |
|---|---|---|
| Main entry | importKey(walletType, key) | { wallet: PublicWallet } |
| /ethereum, /solana, /tron | importKey(key) | PublicWallet |
| Export | Call |
|---|---|
| Main entry | exportKey(wallet) |
| /ethereum, /solana, /tron | exportKey({ wallet }) |
import {
useExportKey as useGenericExportKey,
useImportKey as useGenericImportKey,
} from "@moon-x/react-native-sdk";
import {
useExportKey as useEthereumExportKey,
useImportKey as useEthereumImportKey,
} from "@moon-x/react-native-sdk/ethereum";
const { importKey: importGeneric } = useGenericImportKey();
const { exportKey: exportGeneric } = useGenericExportKey();
const { importKey: importEthereum } = useEthereumImportKey();
const { exportKey: exportEthereum } = useEthereumExportKey();
const { wallet } = await importGeneric("ethereum", privateKey);
await exportGeneric(wallet);
const ethereumWallet = await importEthereum(privateKey);
await exportEthereum({ wallet: ethereumWallet });Per-chain wallets and signing — /ethereum, /solana, and /tron
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";
import { useGetBalance as useTronBalance } from "@moon-x/react-native-sdk/tron";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.useGetTokenAccounts— SPL token accounts owned by the wallet.
Tron hooks:
useSignMessage— TIP-191 message signing.useSignTransaction— signs a TronWeb transaction JSON object.useSendTransaction— signs and submits through the supplied fullnode URL.useGetBalance— native TRX balance.
All three subpaths also expose chain-pinned useCreateWallet, useWallets,
useImportKey, useExportKey, and useFundWallet hooks.
Sign, send, balance, and token-account results use the same wire contract as the web SDK.
Headless funding
useFundWallet returns the provider session instead of opening UI. Present the
returned URL in your own native or in-app browser:
import * as WebBrowser from "expo-web-browser";
import { useFundWallet } from "@moon-x/react-native-sdk/ethereum";
const { fundWallet } = useFundWallet();
const session = await fundWallet(wallet.public_address, {
chain: { id: 8453 },
amount: "25",
});
await WebBrowser.openBrowserAsync(session.url);External wallets and WalletConnect
useExternalWallets(connectors) orchestrates host-supplied native wallet
connectors without rendering a picker. It exposes discovery, connection,
disconnection, account switching, and the connected wallet's signing/request
methods.
For WalletConnect, initialize a v2 SignClient in the host app and pass it to
connectWalletConnect. The SDK returns a pairing URI and approval promise;
the host owns QR/deep-link presentation:
import { connectWalletConnect } from "@moon-x/react-native-sdk";
const pairing = await connectWalletConnect({
client,
requiredNamespaces: {
eip155: {
chains: ["eip155:8453"],
methods: ["personal_sign", "eth_sendTransaction"],
events: ["accountsChanged", "chainChanged"],
},
},
});
if (pairing.uri) presentPairingUri(pairing.uri);
const wallet = await pairing.approval();
const signature = await wallet.signMessage?.("hello");pairing.uri is optional because WalletConnect may approve from an existing
pairing without returning a new URI.
Use listWalletConnectSessions and restoreWalletConnectSession to discover
and restore approved sessions after a native app restart.
Mobile-specific notes
- No SDK-owned wallet picker. External-wallet and WalletConnect primitives are headless; presentation stays in the host app.
useExportKeyis a top-level hook on RN. On web the same operation hangs offuseMoonX().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 temporarily moves the always-mounted WebView from off-screen to
a full-screen view 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
terminal status and rendered pixels.
The hook surface is still headless from the consumer's perspective:
const { exportKey } = useExportKey();
const result = await exportKey(wallet);
// { status: "completed" | "cancelled" | "failed", error?: string }Native 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. Passkeys need the
delegate_permission/common.get_login_creds relation. Add
delegate_permission/common.handle_all_urls separately only if the app also
uses Android App Links:
[
{
"relation": ["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.
Intentionally unsupported
- "WithUI" SDK method variants (web-only — RN is headless)
- React appearance/theming and auth/signing modals
- 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
