JAW SDK
JAW SDK provides passkey-authenticated smart accounts (ERC-4337) on any EVM chain. It is an EIP-1193 compatible provider — a drop-in replacement for MetaMask — with passkey signers, gasless transactions via paymasters, atomic batch operations, and delegated permissions (ERC-7715).
Three packages:
@jaw.id/wagmi— React/Next.js integration with wagmi hooks@jaw.id/core— Vanilla JS, Node.js, and headless server-side usage@jaw.id/ui— Pre-built UI components for AppSpecific auth mode
API key required from https://dashboard.jaw.id.
What You Probably Got Wrong
AI agents have stale training data. This section corrects the most common mistakes.
- Importing
useConnect/useDisconnectfrom wagmi → Import them from@jaw.id/wagmi, not fromwagmi. The JAW versions support capabilities (SIWE, subnames) that wagmi's hooks do not. - Calling
disconnect()with no args → You must pass an empty object:disconnect({}). Omitting it causes a runtime error. - Using
Date.now()for permission expiry → Expiry must be Unix seconds, not milliseconds. UseMath.floor(Date.now() / 1000) + durationInSeconds. - Setting both
selectorANDfunctionSignaturein call permissions → Use one or the other, never both. The SDK throws if you provide both. - Encoding calldata as a string like
'transfer(0xAlice, 1000000)'→ Always useencodeFunctionDatafrom viem. Raw strings are not valid calldata. - Assuming
sendCallswaits for confirmation →sendCallsreturns immediately with a user operation ID. You must pollgetCallStatusfor completion. - Hardcoding paymaster URLs → Import
JAW_PAYMASTER_URLfrom the SDK. Never hardcode the URL. - Using
personal_signwithout hex-encoding →personal_signrequires hex input viatoHex(). Usewallet_signinstead to avoid encoding issues. - Mixing Account API with wagmi in the same flow → Pick one approach per flow. The Account API (
@jaw.id/core) and wagmi hooks (@jaw.id/wagmi) should not be mixed in a single user flow. - Omitting transports for configured chains → Missing transports in
createConfigcauses silent connection failures. Every chain inchainsmust have a matching transport. - Using wagmi's
useSignMessage/useSignTypedData→ UseuseSignfrom@jaw.id/wagmiinstead. It is the unified hook for all signing.
Quick Start
Installation
React / Next.js:
npm install @jaw.id/wagmi wagmi viem @tanstack/react-query
Vanilla JS / Server-side:
npm install @jaw.id/core viem
AppSpecific mode (optional UI components):
npm install @jaw.id/ui
viem is a required peer dependency for all JAW packages.
Wagmi Configuration
import { createConfig, http } from "wagmi";
import { mainnet, base } from "wagmi/chains";
import { jaw } from "@jaw.id/wagmi";
export const config = createConfig({
chains: [mainnet, base],
connectors: [
jaw({
apiKey: process.env.NEXT_PUBLIC_JAW_API_KEY!,
appName: "My App",
appLogoUrl: "https://example.com/logo.png", // HTTPS, min 200x200
}),
],
transports: {
[mainnet.id]: http(),
[base.id]: http(),
},
});
App Provider Setup
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./config";
const queryClient = new QueryClient();
export default function App({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
);
}
Auth Modes
CrossPlatform (Default)
Operations happen in a keys.jaw.id popup. Wallets are portable across dApps.
const connector = jaw({ apiKey: "YOUR_API_KEY" });
AppSpecific
Operations happen in your app. Requires a uiHandler for rendering passkey prompts. White-label experience.
import { jaw, Mode } from "@jaw.id/wagmi";
import { ReactUIHandler } from "@jaw.id/ui";
const connector = jaw({
apiKey: "YOUR_API_KEY",
preference: {
mode: Mode.AppSpecific,
uiHandler: new ReactUIHandler(),
},
});
Connect and Disconnect
Always import from @jaw.id/wagmi, not from wagmi:
import { useConnect, useDisconnect } from "@jaw.id/wagmi";
import { useAccount } from "wagmi";
function ConnectButton() {
const { connect, connectors } = useConnect();
const { disconnect } = useDisconnect();
const { isConnected, address } = useAccount();
if (isConnected) {
return (
<div>
<p>Connected: {address}</p>
<button => disconnect({})}>Disconnect</button>
</div>
);
}
return (
<button => connect({ connector: connectors[0] })}>
Connect Wallet
</button>
);
}
For capabilities (SIWE, subnames), use wallet_connect method, not eth_requestAccounts.
Transactions
Single Transaction (Wagmi)
import { useSendTransaction } from "wagmi";
import { parseEther } from "viem";
function SendETH() {
const { sendTransaction, isPending } = useSendTransaction();
return (
<button
=>
sendTransaction({
to: "0xRecipientAddress",
value: parseEther("0.01"),
})
}
disabled={isPending}
>
{isPending ? "Sending..." : "Send 0.01 ETH"}
</button>
);
}
Batch Transactions (Wagmi)
All calls in a batch are atomic — they all succeed or all revert.
import { useSendCalls } from "wagmi";
import { parseEther, encodeFunctionData, erc20Abi } from "viem";
function BatchTransfer() {
const { sendCalls, isPending } = useSendCalls();
const handleBatch = () => {
sendCalls({
calls: [
{ to: "0xAliceAddress", value: parseEther("0.01") },
{ to: "0xBobAddress", value: parseEther("0.02") },
{
to: "0xUSDC_CONTRACT",
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: ["0xCharlieAddress", 1000000n], // 1 USDC (6 decimals)
}),
},
],
});
};
return (
<button disabled={isPending}>
{isPending ? "Processing..." : "Batch Transfer"}
</button>
);
}
Core Provider Transactions
// Single transaction
const txHash = await jaw.provider.request({
method: "eth_sendTransaction",
params: [
{
to: "0xRecipientAddress",
value: "0x2386F26FC10000", // 0.01 ETH in hex wei
},
],
});
// Batch transaction
const result = await jaw.provider.request({
method: "wallet_sendCalls",
params: [
{
calls: [
{ to: "0xAliceAddress", value: "0x2386F26FC10000" },
{ to: "0xBobAddress", value: "0x470DE4DF820000" },
],
},
],
});
Headless Account API
For server-side or heaadless flows using @jaw.id/core:
import { Account } from "@jaw.id/core";
import { parseEther, encodeFunctionData, erc20Abi } from "viem";
// Get existing account (returning user)
const account = await Account.get({
chainId: 8453, // Base
apiKey: "YOUR_API_KEY",
});
// Single transaction — waits for receipt, returns tx hash
const hash = await account.sendTransaction([
{ to: "0xRecipientAddress", value: parseEther("0.1") },
]);
// Batch transaction — returns immediately with operation ID
const { id, chainId } = await account.sendCalls([
{ to: "0xAliceAddress", value: parseEther("0.01") },
{
to: "0xUSDC_CONTRACT",
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: ["0xBobAddress", 5000000n],
}),
},
]);
// Poll for batch completion
const status = await account.getCallStatus(id);
// 100 = Pending, 200 = Completed, 400 = Offchain failure, 500 = Onchain revert
Signing
Use useSign from @jaw.id/wagmi — the unified hook for personal sign and typed data:
import { useSign } from "@jaw.id/wagmi";
function SignMessage() {
const { sign, isPending } = useSign();
// Personal sign — type 0x45 (EIP-191)
const handleSign = async () => {
const signature = await sign({ type: "0x45", message: "Hello JAW!" });
console.log("Signature:", signature);
};
// Typed data sign — type 0x01 (EIP-712)
const handleTypedData = async () => {
const signature = await sign({
type: "0x01",
typedData: {
domain: { name: "MyApp", version: "1", chainId: 1 },
types: {
Order: [
{ name: "amount", type: "uint256" },
{ name: "token", type: "address" },
],
},
primaryType: "Order",
message: { amount: 1000000n, token: "0xA0b8..." },
},
});
console.log("Typed signature:", signature);
};
return (
<div>
<button disabled={isPending}>
Sign Message
</button>
<button disabled={isPending}>
Sign Typed Data
</button>
</div>
);
}
The chainId parameter in useSign controls which chain's smart account signs. This is different from domain.chainId in EIP-712 typed data.
Provider-Level Signing
import { toHex } from "viem";
// personal_sign — MUST hex-encode the message
const sig = await jaw.provider.request({
method: "personal_sign",
params: [toHex("Hello JAW!"), address],
});
// wallet_sign — personal sign (type 0x45)
const sig2 = await jaw.provider.request({
method: "wallet_sign",
params: [{ type: "0x45", message: "Hello JAW!" }],
});
// wallet_sign — typed data (type 0x01)
const sig4 = await jaw.provider.request({
method: "wallet_sign",
params: [{ type: "0x01", typedData }],
});
// eth_signTypedData_v4 — must JSON.stringify the typed data
const sig3 = await jaw.provider.request({
method: "eth_signTypedData_v4",
params: [address, JSON.stringify(typedData)],
});
Permissions (ERC-7715)
Grant delegated permissions so a server can act on behalf of the user without repeated passkey prompts.
import { useGrantPermissions, useRevokePermissions } from "@jaw.id/wagmi";
function PermissionsManager() {
const { grantPermissions } = useGrantPermissions();
const { revokePermissions } = useRevokePermissions();
const handleGrant = async () => {
const result = await grantPermissions({
expiry: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, // 30 days (SECONDS)
signer: {
type: "account",
data: { id: "0xSpenderAddress" },
},
permissions: [
{
type: "call-permission",
data: {
to: "0xUSDC_CONTRACT",
functionSignature: "transfer(address,uint256)", // OR selector, not both
},
},
{
type: "spend-permission",
data: {
token: "0xUSDC_CONTRACT",
allowance: "10000000", // 10 USDC (string, in smallest unit)
period: "month",
},
},
],
});
// MUST store this — needed for server-side usage and revocation
const permissionId = result.permissionId;
console.log("Permission granted:", permissionId);
};
const handleRevoke = async (permissionId: string) => {
// Revocation is permanent and costs gas
await revokePermissions({ permissionId });
};
return (
<div>
<button Permission</button>
<button => handleRevoke("stored-permission-id")}>
Revoke Permission
</button>
</div>
);
}
For native ETH spend permissions, use the ERC-7528 sentinel address: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE.
Server-Side Permission Usage
import { Account } from "@jaw.id/core";
// Spender key MUST be in a secrets manager or HSM, never in source code
const account = Account.fromLocalAccount({
privateKey: process.env.SPENDER_PRIVATE_KEY!,
chainId: 8453,
apiKey: process.env.JAW_API_KEY!,
});
const { id } = await account.sendCalls(
[
{
to: "0xUSDC_CONTRACT",
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: ["0xMerchantAddress", 5000000n],
}),
},
],
{ permissionId: "stored-permission-id" },
);
Subscription Payments Pattern
- User grants permission via
useGrantPermissionswith expiry matching subscription duration - Store
permissionIdin your backend database - Server charges periodically using
Account.fromLocalAccount()with spender private key - User cancels via
useRevokePermissions - Monitor charge status with
getCallStatus, implement retry and alerting logic
Gas Sponsoring (Paymasters)
Stablecoin gas payments (e.g. pay gas in USDC) work out of the box — no paymaster configuration needed from developers. Users can pay gas in supported ERC-20 tokens natively.
For fully sponsored (gasless) transactions where the dApp covers gas, configure paymasters per chain in the connector. Requires a paymaster that supports EntryPoint v0.8 and EIP-7677. Compatible providers: Pimlico, Etherspot.
const connector = jaw({
apiKey: "YOUR_API_KEY",
paymasters: {
1: {
// Ethereum mainnet
url: "https://api.pimlico.io/v2/1/rpc?apikey=YOUR_PIMLICO_KEY",
context: { sponsorshipPolicyId: "your-policy-id" },
},
8453: {
// Base
url: "https://api.pimlico.io/v2/8453/rpc?apikey=YOUR_PIMLICO_KEY",
context: { sponsorshipPolicyId: "your-policy-id" },
},
},
});
Per-transaction override:
const result = await jaw.provider.request({
method: "wallet_sendCalls",
params: [
{
calls: [{ to: "0xRecipient", value: "0x2386F26FC10000" }],
paymasterService: {
url: "https://custom-paymaster.example.com",
context: { policyId: "override-policy" },
},
},
],
});
Account Lifecycle (Headless)
import { Account } from "@jaw.id/core";
// New user — triggers WebAuthn registration
const newAccount = await Account.create({
chainId: 8453,
apiKey: "YOUR_API_KEY",
});
// MUST store credentialId for future authentication
const credentialId = newAccount.credentialId;
// Returning user — pass stored credentialId
const returning = await Account.get({
chainId: 8453,
apiKey: "YOUR_API_KEY",
credentialId: credentialId,
});
// Server-side — no passkey, uses private key
const serverAccount = Account.fromLocalAccount({
privateKey: process.env.PRIVATE_KEY!,
chainId: 8453,
apiKey: process.env.JAW_API_KEY!,
});
// Gas estimation
const gas = await returning.estimateGas([
{ to: "0xRecipient", value: parseEther("0.1") },
]);
// Account metadata (null for local accounts)
const metadata = await returning.getMetadata();
SIWE (Sign-In with Ethereum)
Backend Endpoints Required
Your backend needs three endpoints:
GET /api/siwe/nonce— generate unique nonce withgenerateSiweNonce()from viemPOST /api/siwe/verify— verify signature withverifySiweMessage()andparseSiweMessage()POST /api/siwe/logout— invalidate session
Store nonces server-side and invalidate after use.
Frontend: Connect with SIWE
Use wallet_connect with the signInWithEthereum capability — not eth_requestAccounts:
const result = await jaw.provider.request({
method: "wallet_connect",
params: [
{
capabilities: {
signInWithEthereum: {
nonce: nonceFromServer,
chainId: "0x1", // MUST be hex string, not number
},
},
},
],
});
ENS Identity
ENS operations use @justaname.id/sdk — a separate package, not part of @jaw.id/*:
npm install @justaname.id/sdk
import { JustaName } from "@justaname.id/sdk";
const justaname = new JustaName({ apiKey: "YOUR_JUSTANAME_KEY" });
// Read records (no API key needed)
const records = await justaname.getRecords({ ens: "user.eth" });
// Reverse resolve
const name = await justaname.reverseResolve({ address: "0x..." });
// Update subname (requires SIWE auth + ensDomains + apiKey)
await justaname.updateSubname({
ens: "sub.domain.eth",
text: { "com.twitter": "@handle" },
coins: { ETH: "0xNewAddress" },
});
Error Handling
| Code | Meaning | Action |
|---|---|---|
4001 |
User rejected request | Normal flow — do NOT show error toast |
4100 |
Unauthorized | Check auth state, reconnect if needed |
4200 |
Unsupported method | Verify method name and provider version |
4900 |
Disconnected | Provider lost connection, prompt reconnect |
4901 |
Chain disconnected | Switch to a connected chain |
4902 |
Unrecognized chain | Add chain to config |
-32700 |
Parse error | Check request payload format |
-32603 |
Internal error | Retry or report bug |
try {
const result = await jaw.provider.request({
method: "eth_sendTransaction",
params: [tx],
});
} catch (error: any) {
if (error.code === 4001) {
// User rejected — this is normal, not an error
return;
}
if (error.code === 4100) {
// Not connected — check auth state BEFORE requests, not after
await reconnect();
return;
}
console.error("Transaction failed:", error.message);
}
Before any provider request, verify WebAuthn support:
if (!window.PublicKeyCredential) {
throw new Error("WebAuthn not supported in this browser");
}
Custom UI Handler (AppSpecific Mode)
Implement the UIHandler interface for full control over passkey UI:
import type {
UIHandler,
UIHandlerConfig,
UIRequest,
UIResponse,
} from "@jaw.id/core";
import { UIError } from "@jaw.id/core";
class CustomUIHandler implements UIHandler {
private config: UIHandlerConfig | null = null;
init(config: UIHandlerConfig) {
this.config = config;
}
canHandle(request: UIRequest): boolean {
return true; // Handle all request types
}
async request(request: UIRequest): Promise<UIResponse> {
// Show your custom modal/UI based on request.method
// Response ID MUST match request ID — mismatch causes SDK to hang
const userApproved = await showCustomModal(request);
if (!userApproved) {
throw UIError.userRejected();
}
return { id: request.id, result: { approved: true } };
}
cleanup() {
// MUST destroy modals, remove listeners, nullify config
this.config = null;
}
}
Request types to handle: wallet_connect, personal_sign, eth_signTypedData_v4, wallet_sendCalls, wallet_grantPermissions, wallet_revokePermissions, wallet_sign.
Key TypeScript Types
// Account configuration
interface AccountConfig {
chainId: number;
apiKey: string;
credentialId?: string;
}
// Transaction calls
interface TransactionCall {
to: `0x${string}`; // Must have 0x prefix
value?: bigint; // In wei
data?: `0x${string}`; // Encoded calldata
}
// Batch operation status
interface CallStatusResponse {
status: 100 | 200 | 400 | 500; // Pending | Completed | Offchain fail | Onchain revert
}
// Permission types
type SpendPeriod =
| "minute"
| "hour"
| "day"
| "week"
| "month"
| "year"
| "forever";
interface SpendPermissionDetail {
token: `0x${string}`;
allowance: string; // String, not bigint
period: SpendPeriod;
multiplier?: number;
}
interface CallPermissionDetail {
to: `0x${string}`;
functionSignature?: string; // OR selector, never both
selector?: `0x${string}`;
}
Configuration Reference
jaw({
// Required
apiKey: string,
// Optional
appName: string, // Default: 'DApp'
appLogoUrl: string, // HTTPS only, min 200x200px
ens: object, // ENS configuration
defaultChainId: number, // Initial chain
paymasters: Record<number, {...}>, // Per-chain paymaster config
showTestnets: boolean, // Required for testnet chains
preference: {
mode: Mode.CrossPlatform | Mode.AppSpecific,
uiHandler: UIHandler, // Required for AppSpecific
},
});
Security Considerations
- Never hardcode API keys — use environment variables (
process.env) - Spender private keys for permissions must live in a secrets manager or HSM
- Keep JAW API keys server-side only for headless flows
- Verify WebAuthn browser support before any passkey operation
- Never retry after user rejection (code
4001) - Check auth state before making requests, not after catching errors
- Permission revocation is permanent and costs gas — confirm with user first
- All addresses must have
0xprefix, all values in wei, all timestamps in seconds
References
- JAW Dashboard — API key management
- JAW SDK GitHub
- JAW Skills Source
- ERC-4337 Spec — Account Abstraction
- ERC-7715 Spec — Permission Requests
- EIP-7677 — Paymaster Web Service
- wagmi Documentation
- viem Documentation
- JustaName SDK — ENS identity