# Jaw

> Build passkey-authenticated smart accounts on EVM chains with JAW SDK. ERC-4337 smart accounts with WebAuthn passkey signers, gasless transactions, batch operations, and ERC-7715 permissions. Use when building dApps with passwordless wallet auth, subscription payments, stablecoin transfers, or gasless UX. Supports wagmi (React), vanilla JS, and headless server-side.

- Skill: `justaname-id/jaw` (Agent Skill, multi-file: 10 files)
- Install (CLI): `npx skillmds@latest add justaname-id/jaw`
- Raw SKILL.md: https://api.skillmd.com/api/skills/justaname-id/jaw/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: Apache-2.0
- Author: justaname-id (https://skillmd.com/u/justaname-id)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/justaname-id/jaw

---


# 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`/`useDisconnect` from wagmi** → Import them from `@jaw.id/wagmi`, not from `wagmi`. 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. Use `Math.floor(Date.now() / 1000) + durationInSeconds`.
- **Setting both `selector` AND `functionSignature` in 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 use `encodeFunctionData` from viem. Raw strings are not valid calldata.
- **Assuming `sendCalls` waits for confirmation** → `sendCalls` returns immediately with a user operation ID. You must poll `getCallStatus` for completion.
- **Hardcoding paymaster URLs** → Import `JAW_PAYMASTER_URL` from the SDK. Never hardcode the URL.
- **Using `personal_sign` without hex-encoding** → `personal_sign` requires hex input via `toHex()`. Use `wallet_sign` instead 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 `createConfig` causes silent connection failures. Every chain in `chains` must have a matching transport.
- **Using wagmi's `useSignMessage`/`useSignTypedData`** → Use `useSign` from `@jaw.id/wagmi` instead. It is the unified hook for all signing.

## Quick Start

### Installation

React / Next.js:

```bash
npm install @jaw.id/wagmi wagmi viem @tanstack/react-query
```

Vanilla JS / Server-side:

```bash
npm install @jaw.id/core viem
```

AppSpecific mode (optional UI components):

```bash
npm install @jaw.id/ui
```

`viem` is a required peer dependency for all JAW packages.

### Wagmi Configuration

```typescript
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

```tsx
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.

```typescript
const connector = jaw({ apiKey: "YOUR_API_KEY" });
```

### AppSpecific

Operations happen in your app. Requires a `uiHandler` for rendering passkey prompts. White-label experience.

```typescript
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`:

```tsx
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 onClick={() => disconnect({})}>Disconnect</button>
      </div>
    );
  }

  return (
    <button onClick={() => connect({ connector: connectors[0] })}>
      Connect Wallet
    </button>
  );
}
```

For capabilities (SIWE, subnames), use `wallet_connect` method, not `eth_requestAccounts`.

## Transactions

### Single Transaction (Wagmi)

```tsx
import { useSendTransaction } from "wagmi";
import { parseEther } from "viem";

function SendETH() {
  const { sendTransaction, isPending } = useSendTransaction();

  return (
    <button
      onClick={() =>
        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.

```tsx
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 onClick={handleBatch} disabled={isPending}>
      {isPending ? "Processing..." : "Batch Transfer"}
    </button>
  );
}
```

### Core Provider Transactions

```typescript
// 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`:

```typescript
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:

```tsx
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 onClick={handleSign} disabled={isPending}>
        Sign Message
      </button>
      <button onClick={handleTypedData} 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

```typescript
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.

```tsx
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 onClick={handleGrant}>Grant Permission</button>
      <button onClick={() => handleRevoke("stored-permission-id")}>
        Revoke Permission
      </button>
    </div>
  );
}
```

For native ETH spend permissions, use the ERC-7528 sentinel address: `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`.

### Server-Side Permission Usage

```typescript
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

1. User grants permission via `useGrantPermissions` with expiry matching subscription duration
2. Store `permissionId` in your backend database
3. Server charges periodically using `Account.fromLocalAccount()` with spender private key
4. User cancels via `useRevokePermissions`
5. 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.

```typescript
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:

```typescript
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)

```typescript
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 with `generateSiweNonce()` from viem
- `POST /api/siwe/verify` — verify signature with `verifySiweMessage()` and `parseSiweMessage()`
- `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`:

```typescript
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/*`:

```bash
npm install @justaname.id/sdk
```

```typescript
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                        |

```typescript
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:

```typescript
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:

```typescript
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

```typescript
// 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

```typescript
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 `0x` prefix, all values in wei, all timestamps in seconds

## References

- [JAW Dashboard](https://dashboard.jaw.id) — API key management
- [JAW SDK GitHub](https://github.com/AyushBhatt1/jaw-sdk)
- [JAW Skills Source](https://github.com/JustaName-id/jaw-skills)
- [ERC-4337 Spec](https://eips.ethereum.org/EIPS/eip-4337) — Account Abstraction
- [ERC-7715 Spec](https://eips.ethereum.org/EIPS/eip-7715) — Permission Requests
- [EIP-7677](https://eips.ethereum.org/EIPS/eip-7677) — Paymaster Web Service
- [wagmi Documentation](https://wagmi.sh)
- [viem Documentation](https://viem.sh)
- [JustaName SDK](https://github.com/JustaName-id/JustaName-sdk) — ENS identity

