@calm-xyz/react
v0.4.0
Published
Client SDK for the Calm fiat-to-crypto onramp API.
Readme
@calm-xyz/react
React SDK for the Calm fiat-to-crypto onramp API. Drops a single modal into your app that handles onboarding, terms of service, identity verification, and virtual-account provisioning for USDC deposits.
import "@calm-xyz/react/styles.css";
import { CalmOnramp } from "@calm-xyz/react";
import { PrivyCalmProvider } from "@calm-xyz/react/privy";
<PrivyCalmProvider calmKey={CALM_PUBLISHABLE_KEY} currency="usd">
<CalmOnramp>
<button>Deposit</button>
</CalmOnramp>
</PrivyCalmProvider>;That's the whole integration. The button you pass becomes the modal trigger.
Install
bun add @calm-xyz/react @tanstack/react-query@^5 wagmi
# or: npm install @calm-xyz/react @tanstack/react-query@^5 wagmiPeer dependencies:
react@^18 || ^19— required.@tanstack/react-query@^5— the SDK uses react-query internally, but partners commonly already have it.@privy-io/react-auth@^2.25— required only if you import from@calm-xyz/react/privy.@dynamic-labs/sdk-react-core@^4+@dynamic-labs/ethereum@^4— required only if you import from@calm-xyz/react/dynamic.wagmi@^3— required only if you import from@calm-xyz/react/wagmi.
Import the stylesheet once at the top of your app:
import "@calm-xyz/react/styles.css";The stylesheet is scoped under .calm-root and won't collide with your app's CSS.
Auth model
The SDK is browser-only and uses an HttpOnly session cookie. Three entrypoints map to three authentication modalities:
- Privy — the SDK reads the identity token from
@privy-io/react-authand trades it for a session cookie. - Dynamic — the SDK reads the JWT from
@dynamic-labs/sdk-react-coreand trades it for a session cookie. Verified server-side against your Dynamic environment's JWKS. - Wagmi — the SDK runs a Sign-In With Ethereum (EIP-4361) handshake via wagmi's hooks: cold start asks the user to sign once, refresh-cookie renewals are silent.
Whichever you pick: cookie is HttpOnly + SameSite=None + Partitioned (works across iframes), refreshed automatically every ~50 minutes, and attached on every SDK fetch via credentials: "include". The wire authentication contract is the same — useCalm() looks identical regardless of modality.
Providers
<PrivyCalmProvider>
Convenience wrapper that reads the wallet address + identity token from Privy's hooks. Must be nested inside a <PrivyProvider>.
import { PrivyProvider } from "@privy-io/react-auth";
import { PrivyCalmProvider } from "@calm-xyz/react/privy";
<PrivyProvider appId={PRIVY_APP_ID} config={{ loginMethods: ["email", "wallet"] }}>
<PrivyCalmProvider calmKey={CALM_PUBLISHABLE_KEY} currency="usd">
{/* …your app… */}
</PrivyCalmProvider>
</PrivyProvider>;Props:
| Prop | Type | |
|---|---|---|
| calmKey | string | Your Calm publishable key (calm_public_(live\|sandbox)_…). Get one by registering an app at calmtreasury.xyz. |
| currency | "usd" \| "gbp" \| "eur" | Currency for the deposit virtual account. |
| initialChain | Chain (optional) | Initial destination chain. Defaults to 999 (HyperEVM). Switch at runtime via useCalm().setChain(...). |
| initialMode | "hypercore" \| "hyperevm" (optional) | Initial Hyperliquid execution layer. Defaults to "hypercore" on chain 999. Silently ignored when initialChain !== 999. |
| baseUrl | string? | Calm API base URL. Defaults to https://api.calmtreasury.xyz. |
Always renders children. Use useReady() from @calm-xyz/react/privy to gate your UI on whether the Calm session cookie has been minted — i.e. Privy is ready + authenticated + identity-token issued and createSession has resolved successfully. Until then, opening the modal would briefly show skeletons until wallet hooks land.
import { useReady } from "@calm-xyz/react/privy";
function App() {
const ready = useReady();
if (!ready) return <div>Loading…</div>;
return <CalmOnramp>...</CalmOnramp>;
}<DynamicCalmProvider>
Convenience wrapper for apps using Dynamic's embedded-wallet stack. Reads the connected primary wallet + JWT from Dynamic's hooks. Must be nested inside a <DynamicContextProvider> configured with EthereumWalletConnectors (from @dynamic-labs/ethereum).
import { DynamicContextProvider } from "@dynamic-labs/sdk-react-core";
import { EthereumWalletConnectors } from "@dynamic-labs/ethereum";
import { DynamicCalmProvider } from "@calm-xyz/react/dynamic";
<DynamicContextProvider
settings={{
environmentId: DYNAMIC_ENVIRONMENT_ID,
walletConnectors: [EthereumWalletConnectors],
}}
>
<DynamicCalmProvider calmKey={CALM_PUBLISHABLE_KEY} currency="usd">
{/* …your app… */}
</DynamicCalmProvider>
</DynamicContextProvider>;Props:
| Prop | Type | |
|---|---|---|
| calmKey | string | Your Calm publishable key. |
| currency | Currency | See above. |
| initialChain | Chain (optional) | See above. |
| initialMode | Mode (optional) | See above. |
| baseUrl | string? | See above. |
Always renders children. Use useReady() from @calm-xyz/react/dynamic to gate your UI on whether the Calm session cookie has been minted — i.e. Dynamic is loaded + the user is logged in and createSession has resolved successfully.
import { useReady } from "@calm-xyz/react/dynamic";
function App() {
const ready = useReady();
if (!ready) return <div>Loading…</div>;
return <CalmOnramp>...</CalmOnramp>;
}Behind the scenes: sendTransaction calls await primaryWallet.getWalletClient().sendTransaction(...), gated by isEthereumWallet() so non-EVM connectors (Solana, Bitcoin) throw a clear error instead of a runtime crash. setSourceChain wraps Dynamic's useSwitchNetwork(). The Dynamic JWT is read via getAuthToken() at session-creation time only.
<WagmiCalmProvider>
Drops into any wagmi-based app. Reads the connected account + chain from wagmi's hooks and auto-wires the three wallet callbacks — partners just supply calmKey + currency. Works under any wagmi wrapper (Reown AppKit, RainbowKit, ConnectKit).
import { WagmiCalmProvider } from "@calm-xyz/react/wagmi";
// Inside your <WagmiProvider> + <QueryClientProvider> tree:
<WagmiCalmProvider calmKey={CALM_PUBLISHABLE_KEY} currency="usd">
{children}
</WagmiCalmProvider>;Props:
| Prop | Type | |
|---|---|---|
| calmKey | string | Your Calm publishable key. |
| currency | Currency | See above. |
| initialChain | Chain (optional) | See above. |
| initialMode | Mode (optional) | See above. |
| baseUrl | string? | See above. |
Always renders children. Use useReady() from @calm-xyz/react/wagmi to gate your UI on whether the Calm session cookie has been minted — i.e. wagmi has a connected account and createSession has resolved (cold start prompts once for a SIWE signature; renewals are silent).
import { useReady } from "@calm-xyz/react/wagmi";
function App() {
const ready = useReady();
if (!ready) return <div>Loading…</div>;
return <CalmOnramp>...</CalmOnramp>;
}Cold start prompts the user once for a signature (EIP-4361); subsequent renewals use the refresh cookie and never prompt.
<CalmProvider> (low-level)
The provider both modality wrappers compose into. Use directly if you have a custom authentication setup.
import { CalmProvider } from "@calm-xyz/react";
<CalmProvider
address={user.address}
createSession={async () => myCreateSession()}
sendTransaction={async (transaction) => myWallet.send(transaction)}
setSourceChain={async (chainId) => myWallet.switchChain(chainId)}
currency="usd"
>
{children}
</CalmProvider>;The createSession callback is responsible for hitting the Calm session endpoint and returning the parsed { wallet, expires_at } body. Throw ApiError on failure so partners can branch on error.code.
useCalm()
Read or update destination state from anywhere inside the provider tree:
const { chain, setChain, mode, setMode } = useCalm();
// switch the destination chain — the SDK posts the change to the
// server-side mirror automatically
setChain(8453);
// on chain 999, choose where USDC lands
if (chain === 999) {
setMode("hyperevm"); // EVM layer instead of the L1 spot account
}mode is null on any chain other than 999. setMode throws when called with chain !== 999 — branch on chain before calling.
Wallet switch. When the active wallet changes (sign-out → sign-in as a different account), call setChain(...) to re-sync the new wallet's destination chain to the server.
Sign out
The SDK is downstream of your wallet/identity stack — sign a user out at that layer first. The SDK observes the address/identity going away (e.g. PrivyCalmProvider returns null once usePrivy().authenticated flips to false) and tears down its session naturally.
If you want to clear the server-side session cookie immediately rather than waiting for the 1h TTL, call useCalm().logout().
Call this after your wallet stack's logout, not before.
Components
<CalmOnramp>
A dialog containing the full onramp. Wrap any trigger element:
import { CalmOnramp } from "@calm-xyz/react";
<CalmOnramp>
<button>Deposit</button>
</CalmOnramp>;The trigger renders untouched. Clicking it opens the modal, which routes through whichever view matches the user's current onboarding state:
picker ──▶ register ──▶ tos ──▶ kyc ──▶ review ──┐
│
▼
va_detailsOther terminal/intermediate views: awaiting_questionnaire, awaiting_ubo, paused, offboarded, rejected. The modal animates between views and handles all server round-trips.
Errors
Every SDK hook surfaces errors as ApiError. Branch on code to drive recovery UI:
import { ApiError, useSession } from "@calm-xyz/react";
const { error } = useSession({ address });
if (error?.code === "refresh_wallet_mismatch") {
// The browser holds a session cookie for a different wallet.
// Tear down the prior session at your wallet/identity layer
// (e.g. usePrivy().logout()), then remount CalmProvider.
}ApiError carries code (the API's stable machine-readable identifier), status (HTTP status), and message (human-readable, may reword).
Types
import {
ApiError, // error class, branch on `.code` / `.status`
} from "@calm-xyz/react";
import type {
CalmOnrampProps,
CalmProviderProps,
CalmContext, // useCalm() return type
CreateSession,
SendTransaction,
SendTransactionInput,
SetSourceChain,
Chain, // 1 | 8453 | 42161 | 999
Mode, // "hyperevm" | "hypercore"
Currency, // "usd" | "gbp" | "eur"
SessionResponse, // { wallet, expires_at }
UseSessionParameters,
UseSessionReturnType,
} from "@calm-xyz/react";
import type { PrivyCalmProviderProps } from "@calm-xyz/react/privy";
import type { WagmiCalmProviderProps } from "@calm-xyz/react/wagmi";Styling
- Tailwind utilities are emitted with a
calm-prefix and scoped under.calm-root— no collisions with your app. prefers-color-scheme: darkis supported automatically.
Override design tokens by setting CSS variables on a parent of the modal:
:root {
--calm-accent: 200 100% 45%;
--calm-radius: 0.75rem;
}Server-rendering
<CalmOnramp> is a client component. In Next.js App Router, render it from a parent with "use client":
"use client";
import "@calm-xyz/react/styles.css";
// …The CSS import should live in your root layout.tsx (it's safe to import from a server component — it's just a static asset).
Versioning
The SDK major version tracks the Calm API URL prefix it targets:
| SDK | API |
|---|---|
| 0.x (current) | /v1/... (pre-1.0 SDK still iterates the public surface) |
| 1.x | /v1/... |
| 2.x | /v2/... |
Within a major, the SDK and API are mix-and-matchable — patch and minor releases on either side are additive.
License
MIT
