Documentation Index
Fetch the complete documentation index at: https://docs.chipipay.com/llms.txt
Use this file to discover all available pages before exploring further.
Overview
useChipiSession provides a unified API for managing session keys, enabling gasless transactions without requiring the owner’s signature for each operation.
This hook combines all session-related operations:
- Session creation (local keypair generation)
- Session registration (on-chain)
- Transaction execution (using session key)
- Session revocation (on-chain)
- Status checking (remaining calls, expiration)
Session keys only work with CHIPI wallets. READY wallets do not support session keys.
Prerequisites
Before using useChipiSession, you need:
- A CHIPI wallet (check
wallet.walletType === "CHIPI" or wallet.supportsSessionKeys)
- The user’s encryption key (PIN)
- An authentication token (from Clerk, Firebase, etc.)
Session Lifecycle
| State | Description |
|---|
none | No session exists |
created | Session created locally, not yet registered on-chain |
active | Session registered and usable |
expired | Session has expired (time or call limit) |
revoked | Session has been revoked on-chain |
Quick Start
import { useAuth } from "@clerk/nextjs";
import { useChipiWallet, useChipiSession } from "@chipi-stack/nextjs";
function SessionComponent() {
const { userId, getToken } = useAuth();
const [pin, setPin] = useState("");
const { wallet } = useChipiWallet({
externalUserId: userId,
getBearerToken: getToken,
});
const {
session,
sessionState,
hasActiveSession,
remainingCalls,
createSession,
registerSession,
executeWithSession,
isCreating,
isRegistering,
isExecuting,
} = useChipiSession({
wallet,
encryptKey: pin,
getBearerToken: getToken,
});
// Setup session (create + register)
const handleSetup = async () => {
await createSession();
await registerSession();
};
// Execute a transfer using the session
const handleTransfer = async () => {
await executeWithSession([{
contractAddress: "0x033068F6539f8e6e6b131e6B2B814e6c34A5224bC66947c47DaB9dFeE93b35fb",
entrypoint: "transfer",
calldata: [recipientAddress, amount, "0x0"],
}]);
};
return (
<div>
<p>Session: {sessionState}</p>
{hasActiveSession && <p>Calls remaining: {remainingCalls}</p>}
{!hasActiveSession ? (
<button onClick={handleSetup} disabled={isCreating || isRegistering}>
Setup Session
</button>
) : (
<button onClick={handleTransfer} disabled={isExecuting}>
Transfer (Gasless)
</button>
)}
</div>
);
}
Configuration Options
interface UseChipiSessionConfig {
// Required
wallet: SessionWallet | null | undefined;
encryptKey: string;
getBearerToken: () => Promise<string | null | undefined>;
// Optional
storedSession?: SessionKeyData | null;
onSessionCreated?: (session: SessionKeyData) => void | Promise<void>;
defaultDurationSeconds?: number; // Default: 21600 (6 hours)
defaultMaxCalls?: number; // Default: 1000
autoCheckStatus?: boolean; // Default: true
}
| Option | Type | Default | Description |
|---|
wallet | SessionWallet | Required | Wallet data (must be CHIPI type) |
encryptKey | string | Required | User’s PIN for signing |
getBearerToken | () => Promise<string> | Required | Auth token function |
storedSession | SessionKeyData | null | Previously stored session |
onSessionCreated | (session) => void | - | Callback to persist new session |
defaultDurationSeconds | number | 21600 | Session duration (6 hours) |
defaultMaxCalls | number | 1000 | Max calls per session |
autoCheckStatus | boolean | true | Auto-fetch on-chain status |
Return Values
Session Data
| Property | Type | Description |
|---|
session | SessionKeyData | null | Current session data |
sessionStatus | SessionDataResponse | On-chain session status |
sessionState | SessionState | Lifecycle state |
hasActiveSession | boolean | Whether session is usable |
isSessionExpired | boolean | Whether session has expired |
remainingCalls | number | undefined | Calls left in session |
supportsSession | boolean | Whether wallet supports sessions |
Actions
| Method | Returns | Description |
|---|
createSession(config?) | Promise<SessionKeyData> | Create new session locally |
registerSession(config?) | Promise<string> | Register on-chain (returns txHash) |
revokeSession() | Promise<string> | Revoke on-chain (returns txHash) |
executeWithSession(calls) | Promise<string> | Execute calls (returns txHash) |
clearSession() | void | Clear local session state |
refetchStatus() | Promise<void> | Refresh on-chain status |
Loading States
| Property | Description |
|---|
isCreating | Creating session locally |
isRegistering | Registering session on-chain |
isRevoking | Revoking session on-chain |
isExecuting | Executing transaction |
isLoadingStatus | Fetching on-chain status |
Persisting Sessions
SessionKeyData contains an encryptedPrivateKey. Where you store it matters — the SDK does not persist sessions itself; this is your responsibility.
Do not store SessionKeyData in Clerk unsafeMetadata. unsafeMetadata is client-writable and embedded in the Clerk session JWT, which means the encrypted session key is exposed to the browser on every session refresh. Combined with a low-entropy user PIN (e.g. 6 digits = 10⁶ combinations), the ciphertext is brute-forceable offline by anyone who obtains a valid session token.Use a server-side route that writes to Clerk privateMetadata (server-only, not in the JWT), or store the session in your own database. See the recommended pattern below.
Recommended: server-side route + privateMetadata
Persist SessionKeyData from a server route after verifying the user’s auth token. Chipi’s backend SDK ships JWKS verification (iss + aud validation) for Clerk, Firebase, BetterAuth, and generic providers — use it to authenticate the route. See the Gasless setup guides for JWKS configuration.
1. Server route — app/api/chipi/session/route.ts:
import { auth, clerkClient } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import type { SessionKeyData } from "@chipi-stack/types";
// GET — return the stored session (server-only, never exposed in JWT)
export async function GET() {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const client = await clerkClient();
const user = await client.users.getUser(userId);
const session = user.privateMetadata?.chipiSession as SessionKeyData | undefined;
return NextResponse.json({ session: session ?? null });
}
// POST — persist a newly created session
export async function POST(req: Request) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object" || !("session" in body)) {
return NextResponse.json({ error: "invalid_session_payload" }, { status: 400 });
}
const { session } = body as { session: SessionKeyData };
// updateUserMetadata performs a deep merge — using updateUser() here would
// replace the entire privateMetadata object and clobber any other keys.
const client = await clerkClient();
await client.users.updateUserMetadata(userId, {
privateMetadata: { chipiSession: session },
});
return NextResponse.json({ ok: true });
}
2. Client — load and persist via the route:
"use client";
import { useEffect, useState } from "react";
import { useChipiWallet, useChipiSession } from "@chipi-stack/nextjs";
import type { SessionKeyData } from "@chipi-stack/types";
function PersistentSession() {
const { wallet } = useChipiWallet({ ... });
const [storedSession, setStoredSession] = useState<SessionKeyData | null>(null);
// Hydrate from server on mount
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch("/api/chipi/session");
if (!r.ok) return;
const d = (await r.json()) as { session?: SessionKeyData | null };
if (!cancelled) setStoredSession(d.session ?? null);
} catch {
if (!cancelled) setStoredSession(null);
}
})();
return () => {
cancelled = true;
};
}, []);
const { session, hasActiveSession, createSession, registerSession } = useChipiSession({
wallet,
encryptKey: pin,
getBearerToken: getToken,
storedSession,
onSessionCreated: async (newSession) => {
const r = await fetch("/api/chipi/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session: newSession }),
});
if (!r.ok) throw new Error(`Failed to persist session: ${r.status}`);
},
});
// Session is restored on mount — and never leaks via the JWT
}
Reduce the brute-force surface
Even with server-side storage, the encryption strength of encryptedPrivateKey depends on the encryption key:
- PIN-only wallets — a 6-digit PIN has ~20 bits of entropy; an attacker who obtains the ciphertext can crack it offline in seconds. Treat the encrypted session key as sensitive even at rest.
- Passkey wallets (recommended) — the encryption key is hardware-backed via WebAuthn PRF and never leaves the device’s secure element. This is the production-grade default. See the PIN → passkey migration guide.
Minimal session whitelist
When registering a session, restrict allowedEntrypoints to the smallest set your flow actually needs. Avoid including broad approval entrypoints (approve, set_approval_for_all) unless required — a session with these permissions can drain tokens within the validity window if compromised.
Executing Transactions
The executeWithSession method accepts an array of Starknet calls:
// Single transfer
await executeWithSession([{
contractAddress: USDC_CONTRACT,
entrypoint: "transfer",
calldata: [recipient, amount, "0x0"],
}]);
// Batch multiple calls
await executeWithSession([
{
contractAddress: USDC_CONTRACT,
entrypoint: "approve",
calldata: [spender, amount, "0x0"],
},
{
contractAddress: DEX_CONTRACT,
entrypoint: "swap",
calldata: [...],
},
]);
Custom Session Configuration
Custom Duration
// Create session valid for 1 hour
await createSession({ durationSeconds: 3600 });
Custom Max Calls
// Register with 500 max calls
await registerSession({ maxCalls: 500 });
Allowed Entrypoints
Restrict which contract methods the session can call:
await registerSession({
allowedEntrypoints: [
"0x..." // Only allow specific function selectors
],
});
Error Handling
const { error, session } = useChipiSession({...});
// Check for errors
if (error) {
console.error("Session error:", error.message);
}
// Handle specific errors
const handleSetup = async () => {
try {
await createSession();
await registerSession();
} catch (err) {
if (err.message.includes("does not support sessions")) {
alert("Please use a CHIPI wallet for sessions");
} else if (err.message.includes("expired")) {
alert("Session has expired, please create a new one");
} else {
alert(`Error: ${err.message}`);
}
}
};
Complete Example
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@clerk/nextjs";
import { useChipiWallet, useChipiSession } from "@chipi-stack/nextjs";
import type { SessionKeyData } from "@chipi-stack/types";
export function GaslessTransfers() {
const { getToken, userId } = useAuth();
const [pin, setPin] = useState("");
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const { wallet, formattedBalance } = useChipiWallet({
externalUserId: userId,
getBearerToken: getToken,
});
// Hydrate session from a server-side route that reads Clerk privateMetadata
// (NOT unsafeMetadata — see the "Persisting Sessions" section above).
const [storedSession, setStoredSession] = useState<SessionKeyData | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch("/api/chipi/session");
if (!r.ok) return;
const d = (await r.json()) as { session?: SessionKeyData | null };
if (!cancelled) setStoredSession(d.session ?? null);
} catch {
if (!cancelled) setStoredSession(null);
}
})();
return () => {
cancelled = true;
};
}, []);
const {
session,
sessionState,
hasActiveSession,
remainingCalls,
supportsSession,
createSession,
registerSession,
executeWithSession,
revokeSession,
isCreating,
isRegistering,
isExecuting,
isRevoking,
error,
} = useChipiSession({
wallet,
encryptKey: pin,
getBearerToken: getToken,
storedSession,
onSessionCreated: async (newSession) => {
const r = await fetch("/api/chipi/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session: newSession }),
});
if (!r.ok) throw new Error(`Failed to persist session: ${r.status}`);
},
});
if (!wallet?.supportsSessionKeys) {
return <p>Sessions require a CHIPI wallet</p>;
}
return (
<div className="space-y-6">
{/* PIN Input */}
<input
type="password"
placeholder="Enter PIN"
value={pin}
onChange={(e) => setPin(e.target.value)}
maxLength={4}
/>
{/* Session Status */}
<div className="p-4 bg-gray-100 rounded">
<p>State: <strong>{sessionState}</strong></p>
{hasActiveSession && (
<p>Remaining calls: {remainingCalls}</p>
)}
</div>
{/* Session Setup */}
{!hasActiveSession && (
<button
onClick={async () => {
await createSession();
await registerSession();
}}
disabled={isCreating || isRegistering || !pin}
>
{isCreating ? "Creating..." : isRegistering ? "Registering..." : "Setup Session"}
</button>
)}
{/* Transfer Form */}
{hasActiveSession && (
<div className="space-y-4">
<input
placeholder="Recipient address"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
type="number"
placeholder="Amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<button
onClick={async () => {
await executeWithSession([{
contractAddress: "0x033068F6539f8e6e6b131e6B2B814e6c34A5224bC66947c47DaB9dFeE93b35fb",
entrypoint: "transfer",
calldata: [recipient, amount, "0x0"],
}]);
}}
disabled={isExecuting}
>
{isExecuting ? "Sending..." : "Send USDC (Gasless)"}
</button>
</div>
)}
{/* Revoke Session */}
{hasActiveSession && (
<button
onClick={revokeSession}
disabled={isRevoking}
className="text-red-600"
>
{isRevoking ? "Revoking..." : "Revoke Session"}
</button>
)}
{/* Error Display */}
{error && (
<p className="text-red-500">{error.message}</p>
)}
</div>
);
}