@moon-x/react-sdk
v0.3.0
Published
React authentication hooks and components for MoonKey
Readme
@moon-x/react-sdk
React SDK for MoonX — embedded wallets with passkey-protected MPC signing for Ethereum and Solana, drop-in auth UI flows, and headless wallet methods.
npm install @moon-x/react-sdk
# or
pnpm add @moon-x/react-sdkQuick start
Wrap your app with MoonKeyProvider and import the CSS bundle once:
import { MoonKeyProvider, mainnet, sepolia } from "@moon-x/react-sdk";
import "@moon-x/react-sdk/style.css";
export default function App() {
return (
<MoonKeyProvider
publishableKey={process.env.NEXT_PUBLIC_MOONKEY_PUBLISHABLE_KEY!}
config={{
appearance: {
accentColor: "#6366f1",
backgroundColor: "#ffffff",
},
defaultChain: mainnet,
supportedChains: [mainnet, sepolia],
}}
>
<YourApp />
</MoonKeyProvider>
);
}Open the auth modal and read user state:
import { useMoonKey } from "@moon-x/react-sdk";
function LoginButton() {
const { ready, isAuthenticated, user, start, logout } = useMoonKey();
if (!ready) return null;
if (isAuthenticated) {
return <button onClick={() => logout()}>Sign out ({user?.id})</button>;
}
return <button onClick={() => start?.()}>Sign in</button>;
}Sign a message with one of the user's wallets:
import { useWallets } from "@moon-x/react-sdk";
import { useSignMessage } from "@moon-x/react-sdk/ethereum";
function SignDemo() {
const { wallets } = useWallets();
const { signMessage } = useSignMessage();
const onSign = async () => {
const wallet = wallets.find((w) => w.wallet_type === "ethereum");
if (!wallet) return;
const { signature } = await signMessage({
message: "Hello world",
wallet,
options: { uiOptions: { showWalletUI: true } }, // opens the modal
});
console.log(signature);
};
return <button onClick={onSign}>Sign</button>;
}Configuration
MoonKeyProvider accepts publishableKey (required) plus a config object:
| Field | Type | Purpose |
|---|---|---|
| appearance | AuthAppearance | High-level theming — accentColor, backgroundColor, displayMode ("light" \| "dark" \| "auto"), logo, loginHeaderTitle, borderRadius, fontFamily. |
| loginMethods | ("email" \| "google" \| "apple" \| "wallet")[] | Which auth methods to show in the modal. |
| walletChainType | "ethereum" \| "solana" \| "ethereum-or-solana" | Which wallet type to create at signup. |
| defaultChain / supportedChains | Chain / Chain[] | EVM chain config — top-level, not nested under ethereum. Chain is viem's type; common chains are re-exported by this package. |
| solana.rpcs | { [cluster]: { rpc, rpcSubscriptions? } } | Solana RPC endpoints per cluster. |
| walletConnect.projectId | string | WalletConnect v2 project ID for external-wallet flows. |
| emailConfig, passkeyEnrollConfig, signMessageConfig, signTransactionConfig, sendTransactionConfig, exportKeyConfig | various | Per-flow UI overrides — titles, button text, etc. |
| security | Record<string, never> | Reserved for future per-app security knobs. The previously-configurable assertionCacheTtlMs was removed in Phase 4 of the presence-token gating work — every sensitive op now does a fresh WebAuthn ceremony and mints scope-bound single-use JWTs via the iframe's internal orchestrator, so there is no parent-side cache left to configure. See Security below. |
| theme | MoonKeyThemeConfig | Low-level token overrides — fine-grained color palette, full borderRadius scale. Most apps don't need this; appearance is the recommended surface. |
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. Returns { state, loginWithOAuth, reset }. |
| useLogout() | { logout } — also clears local storage + iframe session. |
Passkeys
| Hook | What it does |
|---|---|
| usePasskeyStatus() | { status, refresh }. status.passkeys lists the user's enrolled passkeys with provider labels (e.g. "1Password on Chrome"). |
| useRegisterPasskey() | First-time passkey enrollment for a user who signed in via OTP/OAuth without one. |
| 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() | Two-mode: headless if you pass a key, modal-driven if you don't. |
| useConnectWallet() | External-wallet flow (MetaMask, Phantom, WalletConnect) for both chains. |
| useAttachOAuth() / useDetachOAuth() | Link / unlink an OAuth provider on an existing user. |
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:
import { useSignMessage, useSignTransaction, useSendTransaction } from "@moon-x/react-sdk/ethereum";
import { useSignMessage as useSignSolanaMessage } from "@moon-x/react-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.
Every signing hook accepts options.uiOptions.showWalletUI to toggle modal vs headless mode. With UI, the user sees the message/transaction in a modal and the biometric prompt fires only on the Sign tap. Headless skips the modal entirely.
Every sensitive op now does a fresh WebAuthn ceremony per call — the previous per-call requireFreshAssertion?: boolean flag was removed from the param types since it has no behavior left to opt into. See Security below.
Security
Every sensitive operation (signMessage, signTransaction, signTypedData, signHash, sign7702Authorization, sendTransaction, createWallet, importKey, exportKey, addPasskey, removePasskey) drives this server-verified ceremony per call:
- Server-issued challenge. SDK posts to
/auth/passkey/presence/begin, server inserts a row inapp_user_passkey_challengesand returns the WebAuthn options. - Fresh WebAuthn assertion. The parent runs
navigator.credentials.get; the user does a biometric. The assertor stripsresponse.userHandlefrom the server-bound payload (DEK hygiene) and surfaces the userHandle separately for the iframe's local AES-GCM unwrap. - Scope-bound, single-use JWT mint. SDK posts to
/auth/passkey/presence/verifywithpurpose: "internal"and the scope set this op needs (e.g.["keyshare_read", "sign"]). Server consumes the challenge, verifies the signature against the credential's stored public key, mints one short-lived JWT per scope with a uniquejti. - Per-endpoint enforcement. Each scoped JWT carries the
X-MoonX-Presenceheader on its matching gated endpoint. MoonX middleware pins the JWT'sopclaim to the endpoint, then burns thejtiinplatform.app_presence_jti_usedviaINSERT ... ON CONFLICT DO NOTHING. Replay of the same token is rejected aspresence token already consumed. Tokens have a 30-second TTL.
What this closes: captured userHandle + session JWT no longer unlocks DEK material offline. Even with both, an attacker can't fetch wraps / keyshares / drive the co-signer without producing a fresh WebAuthn signature for each op — which requires the credential's private key in the user's authenticator. See apps/platform/docs/notes/passkeys/presence-tokens.md (in the backend repo) for the full threat model and scope matrix.
The previously-configurable security.assertionCacheTtlMs and per-call requireFreshAssertion were removed entirely when presence-token gating shipped — they no longer exist on the public TypeScript surface. Every op is always-fresh by construction.
Theming
The simplest path is appearance:
<MoonKeyProvider
config={{
appearance: {
accentColor: "#6366f1",
backgroundColor: "#0f172a",
displayMode: "dark",
borderRadius: "md", // "none" | "sm" | "md" | "lg" — or { sm, md, lg }
fontFamily: "Inter, sans-serif",
logo: "https://example.com/logo.svg",
},
}}
>You can dynamically update appearance at runtime — useful for light/dark mode toggles:
const { setAppearance } = useMoonKey();
setAppearance({ displayMode: colorScheme });For finer control (overriding derived tokens like accentDark, background2, etc.), pass a theme: MoonKeyThemeConfig. The merge order is: base theme → appearance derivations → explicit theme overrides.
EVM chain helpers
The package re-exports gas / RPC utilities that work with any viem Chain:
import {
getEvmGasPrice,
getEvmMaxPriorityFeePerGas,
getEvmNonce,
estimateEvmGas,
estimateEvmGasReserve,
getRpcUrl,
setChainRpcUrl,
getChainById,
getDefaultChain,
isChainSupported,
validateChainConfig,
} from "@moon-x/react-sdk";Useful when you need a pre-flight gas reserve estimate, want to override an RPC URL at runtime, or are building a custom chain-picker.
React Native
For React Native apps, use @moon-x/react-native-sdk — same hook shape with a WebView-backed transport.
License
UNLICENSED. All rights reserved.
