diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5d82c4f..e53738c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -249,6 +249,12 @@ "description": "Hyperliquid perpetual futures DEX — order placement (market/limit/trigger/TWAP), position management, leverage up to 50x, WebSocket streaming, vault strategies, and L1 architecture. REST and WebSocket APIs with wallet signing authentication. Python SDK and TypeScript patterns.", "category": "Trading" }, + { + "name": "jaw", + "source": "./skills/jaw", + "description": "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.", + "category": "Infrastructure" + }, { "name": "jupiter", "source": "./skills/jupiter", @@ -493,7 +499,7 @@ "name": "solidity-auditor", "source": "./skills/solidity-auditor", "description": "Security audit of Solidity code while you develop. Trigger on \"audit\", \"check this contract\", \"review for security\". Modes - default (full repo), DEEP (+ adversarial reasoning), or a specific filename.", - "category": "Security" + "category": "Uncategorized" }, { "name": "solidity-security", diff --git a/skills/jaw/SKILL.md b/skills/jaw/SKILL.md new file mode 100644 index 0000000..8f444c8 --- /dev/null +++ b/skills/jaw/SKILL.md @@ -0,0 +1,787 @@ +--- +name: jaw +description: "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." +license: Apache-2.0 +compatibility: Claude Code, Cursor, Windsurf, Cline +metadata: + author: JustaName-id + version: "1.0" + chain: multichain + category: Infrastructure +tags: + - smart-accounts + - passkeys + - erc-4337 + - account-abstraction + - gasless + - batch-transactions + - erc-7715 + - permissions + - wagmi + - webauthn +--- + +# 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 . + +## 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 ( + + {children} + + ); +} +``` + +## 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 ( +
+

Connected: {address}

+ +
+ ); + } + + return ( + + ); +} +``` + +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 ( + + ); +} +``` + +### 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 ( + + ); +} +``` + +### 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 ( +
+ + +
+ ); +} +``` + +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 ( +
+ + +
+ ); +} +``` + +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 { + // 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, // 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 diff --git a/skills/jaw/docs/troubleshooting.md b/skills/jaw/docs/troubleshooting.md new file mode 100644 index 0000000..985013d --- /dev/null +++ b/skills/jaw/docs/troubleshooting.md @@ -0,0 +1,213 @@ +# JAW SDK Troubleshooting + +Common issues when integrating JAW SDK's passkey-authenticated smart accounts, wagmi connector, and headless Account API. + +## Silent Connection Failures + +**Symptom:** `connect()` resolves without error but `useAccount()` shows `isConnected: false`. No wallet address returned. + +**Cause:** Missing transports in `createConfig`. Every chain in the `chains` array must have a corresponding entry in `transports`. + +**Fix:** + +```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! })], + transports: { + // EVERY chain must have a transport — missing one causes silent failure + [mainnet.id]: http(), + [base.id]: http(), + }, +}); +``` + +## AppSpecific Mode Throws "No UIHandler Provided" + +**Symptom:** Runtime error when using `Mode.AppSpecific`: `Error: UIHandler is required for AppSpecific mode`. + +**Cause:** AppSpecific mode requires a `uiHandler` to render passkey prompts within your app. CrossPlatform mode (default) uses the `keys.jaw.id` popup instead. + +**Fix:** + +```typescript +import { jaw, Mode } from '@jaw.id/wagmi'; +import { ReactUIHandler } from '@jaw.id/ui'; + +const connector = jaw({ + apiKey: process.env.NEXT_PUBLIC_JAW_API_KEY!, + preference: { + mode: Mode.AppSpecific, + uiHandler: new ReactUIHandler(), // Required for AppSpecific + }, +}); +``` + +Install the UI package: `npm install @jaw.id/ui` + +## Permission Expiry Using Milliseconds Instead of Seconds + +**Symptom:** Permission expires immediately or far in the future. `grantPermissions` succeeds but the permission is unusable. + +**Cause:** `expiry` expects Unix seconds, not milliseconds. `Date.now()` returns milliseconds. + +**Fix:** + +```typescript +// WRONG — milliseconds +const expiry = Date.now() + 60 * 60 * 24 * 30; + +// CORRECT — Unix seconds +const expiry = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30; // 30 days +``` + +## Testnet Chains Not Appearing + +**Symptom:** Testnet chains (Sepolia, Base Goerli, etc.) are configured in `chains` but not available in the connector. Chain switching to testnets fails. + +**Cause:** Testnet visibility is disabled by default in the JAW connector. + +**Fix:** Set `showTestnets: true` in the connector config: + +```typescript +const connector = jaw({ + apiKey: process.env.NEXT_PUBLIC_JAW_API_KEY!, + showTestnets: true, // Required to expose testnet chains +}); +``` + +## WebAuthn Not Available + +**Symptom:** Passkey creation or authentication fails silently, or throws `NotSupportedError`. + +**Cause:** The browser does not support WebAuthn, or the page is not in a secure context (HTTPS or `localhost`). + +**Fix:** Feature-detect before any passkey operation: + +```typescript +if (!window.PublicKeyCredential) { + // Show fallback UI — passkeys are not supported + throw new Error('WebAuthn not supported in this browser'); +} + +// For conditional UI (autofill), also check: +const isConditionalAvailable = + await PublicKeyCredential.isConditionalMediationAvailable?.(); +``` + +Ensure your development environment uses `localhost` (not `127.0.0.1` or a raw IP over HTTP). + +## SIWE Failing with Numeric chainId + +**Symptom:** `wallet_connect` with `signInWithEthereum` capability returns an invalid signature or throws a parse error. + +**Cause:** The `chainId` in the SIWE capability must be a hex string (e.g., `'0x1'`), not a number (`1`). + +**Fix:** + +```typescript +// WRONG — numeric chainId +const result = await jaw.provider.request({ + method: 'wallet_connect', + params: [{ + capabilities: { + signInWithEthereum: { + nonce: nonceFromServer, + chainId: 1, // Will fail + }, + }, + }], +}); + +// CORRECT — hex string chainId +const result = await jaw.provider.request({ + method: 'wallet_connect', + params: [{ + capabilities: { + signInWithEthereum: { + nonce: nonceFromServer, + chainId: '0x1', // Hex string required + }, + }, + }], +}); +``` + +## Batch Status Assumed Complete Before Polling + +**Symptom:** Code reads transaction receipt immediately after `sendCalls` and gets `undefined` or stale data. + +**Cause:** `sendCalls` returns immediately with a user operation ID. The batch is not yet mined. You must poll `getCallStatus` for completion. + +**Fix:** + +```typescript +const { id } = await account.sendCalls([ + { to: '0xRecipient...', value: parseEther('0.01') }, +]); + +// WRONG — assuming immediate completion +// const receipt = getTransactionReceipt(id); // undefined + +// CORRECT — poll for status +let status = await account.getCallStatus(id); +while (status.status === 100) { // 100 = Pending + await new Promise((r) => setTimeout(r, 2000)); + status = await account.getCallStatus(id); +} + +if (status.status === 200) { + console.log('Batch completed successfully'); +} else if (status.status === 400) { + console.error('Offchain failure (bundler rejected)'); +} else if (status.status === 500) { + console.error('Onchain revert'); +} +``` + +## ENS Methods Called on JAW SDK Instead of @justaname.id/sdk + +**Symptom:** Calling ENS-related methods on `@jaw.id/core` or `@jaw.id/wagmi` throws `method not found` or similar errors. + +**Cause:** ENS operations (record lookup, reverse resolution, subname management) use `@justaname.id/sdk` — a separate package. JAW SDK handles wallet and account operations only. + +**Fix:** + +```bash +npm install @justaname.id/sdk +``` + +```typescript +// WRONG — JAW SDK does not have ENS methods +// import { getRecords } from '@jaw.id/core'; + +// CORRECT — use JustaName SDK +import { JustaName } from '@justaname.id/sdk'; + +const justaname = new JustaName({ apiKey: 'YOUR_JUSTANAME_KEY' }); +const records = await justaname.getRecords({ ens: 'user.eth' }); +``` + +## disconnect() Called Without Empty Object Argument + +**Symptom:** `disconnect()` throws a runtime error: `Cannot read properties of undefined`. + +**Cause:** The JAW `useDisconnect` hook (from `@jaw.id/wagmi`) requires an empty object argument. This differs from wagmi's native `useDisconnect`. + +**Fix:** + +```typescript +import { useDisconnect } from '@jaw.id/wagmi'; + +const { disconnect } = useDisconnect(); + +// WRONG +disconnect(); // Throws runtime error + +// CORRECT +disconnect({}); // Must pass empty object +``` diff --git a/skills/jaw/examples/batch-transactions/README.md b/skills/jaw/examples/batch-transactions/README.md new file mode 100644 index 0000000..5bb87ba --- /dev/null +++ b/skills/jaw/examples/batch-transactions/README.md @@ -0,0 +1,217 @@ +# Batch Transactions with JAW + Wagmi + +Working TypeScript/React example for sending atomic batch transactions (ETH + ERC-20 transfers) using `useSendCalls` with a JAW smart account. + +## Dependencies + +```bash +npm install @jaw.id/wagmi wagmi viem @tanstack/react-query +``` + +## Batch Transfer Component + +```tsx +// components/BatchTransfer.tsx +"use client"; + +import { useState } from 'react'; +import { useAccount } from 'wagmi'; +import { useSendCalls, useCallsStatus } from 'wagmi/experimental'; +import { + parseEther, + encodeFunctionData, + erc20Abi, + formatEther, + type Hex, +} from 'viem'; + +// Base USDC — verify on-chain before using in production +const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const; + +type BatchState = 'idle' | 'awaiting-signature' | 'pending' | 'completed' | 'failed'; + +export function BatchTransfer() { + const { isConnected, address } = useAccount(); + const { sendCalls, isPending: isSending } = useSendCalls(); + const [batchState, setBatchState] = useState('idle'); + const [callsId, setCallsId] = useState(null); + const [error, setError] = useState(null); + + const [ethRecipient, setEthRecipient] = useState(''); + const [ethAmount, setEthAmount] = useState(''); + const [usdcRecipient, setUsdcRecipient] = useState(''); + const [usdcAmount, setUsdcAmount] = useState(''); + + if (!isConnected) { + return

Connect your wallet first.

; + } + + async function handleBatch() { + if (!ethRecipient || !ethAmount || !usdcRecipient || !usdcAmount) return; + + setBatchState('awaiting-signature'); + setError(null); + setCallsId(null); + + try { + // Convert USDC amount to 6-decimal units + const usdcUnits = BigInt(Math.floor(parseFloat(usdcAmount) * 1e6)); + + sendCalls( + { + calls: [ + { + to: ethRecipient as `0x${string}`, + value: parseEther(ethAmount), + }, + { + to: USDC_BASE, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [usdcRecipient as `0x${string}`, usdcUnits], + }), + }, + ], + }, + { + onSuccess(id) { + setCallsId(id); + setBatchState('pending'); + }, + onError(err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + if (message.includes('User rejected') || message.includes('user denied')) { + setBatchState('idle'); + return; + } + setBatchState('failed'); + setError(message); + }, + } + ); + } catch (err) { + setBatchState('failed'); + setError(err instanceof Error ? err.message : 'Unknown error'); + } + } + + function reset() { + setBatchState('idle'); + setCallsId(null); + setError(null); + } + + const buttonLabels: Record = { + idle: 'Send Batch', + 'awaiting-signature': 'Confirm in wallet...', + pending: 'Processing batch...', + completed: 'Batch completed', + failed: 'Batch failed', + }; + + return ( +
+

Batch Transfer on Base

+

From: {address}

+ +
+ ETH Transfer +
+ + setEthRecipient(e.target.value)} + placeholder="0x..." + disabled={batchState !== 'idle'} + /> +
+
+ + setEthAmount(e.target.value)} + placeholder="0.01" + type="text" + inputMode="decimal" + disabled={batchState !== 'idle'} + /> +
+
+ +
+ USDC Transfer +
+ + setUsdcRecipient(e.target.value)} + placeholder="0x..." + disabled={batchState !== 'idle'} + /> +
+
+ + setUsdcAmount(e.target.value)} + placeholder="10.00" + type="text" + inputMode="decimal" + disabled={batchState !== 'idle'} + /> +
+
+ + + + {batchState === 'pending' && callsId && ( +

Batch submitted. Operation ID: {callsId}

+ )} + + {batchState === 'failed' && error && ( +
+

{error}

+ +
+ )} +
+ ); +} +``` + +## Usage + +```tsx +// app/batch/page.tsx +"use client"; + +import { BatchTransfer } from '@/components/BatchTransfer'; + +export default function BatchPage() { + return ( +
+

Batch Transactions

+ +
+ ); +} +``` + +## Notes + +- All calls in a batch are atomic -- they all succeed or all revert. If the USDC transfer fails, the ETH transfer is also reverted. +- `sendCalls` returns immediately with a user operation ID. The batch is not yet mined. Use `useCallsStatus` or poll `getCallStatus` to track completion. +- USDC on Base uses 6 decimals. Always convert to smallest units: `10.00 USDC = 10000000n`. +- The USDC contract address (`0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`) is for Base mainnet. Verify on-chain with `cast code
--rpc-url https://mainnet.base.org` before using in production. +- User rejection (closing the passkey prompt) silently resets to idle -- no error toast. +- Always use `encodeFunctionData` from viem for contract calls. Never pass raw string calldata. diff --git a/skills/jaw/examples/headless-payments/README.md b/skills/jaw/examples/headless-payments/README.md new file mode 100644 index 0000000..455226c --- /dev/null +++ b/skills/jaw/examples/headless-payments/README.md @@ -0,0 +1,136 @@ +# Headless USDC Payments with JAW Core + +Working TypeScript example for server-side USDC payments using the `Account` class from `@jaw.id/core`. No browser, no passkey prompts -- suitable for cron jobs, webhooks, and backend payment flows. + +## Dependencies + +```bash +npm install @jaw.id/core viem +``` + +## Prerequisites + +- A JAW API key from [dashboard.jaw.id](https://dashboard.jaw.id) +- A spender private key with an active ERC-7715 permission grant from the user +- The permission ID stored from the client-side `grantPermissions` call +- The spender account must have been funded or have paymaster sponsorship configured + +## Payment Script + +```typescript +// scripts/charge-subscription.ts +import { Account } from '@jaw.id/core'; +import { encodeFunctionData, erc20Abi, formatUnits } from 'viem'; + +// Base USDC — verify on-chain before using in production +const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const; + +interface ChargeParams { + merchantAddress: `0x${string}`; + amountUSDC: number; // Human-readable (e.g., 9.99) + permissionId: string; +} + +async function chargeSubscription({ + merchantAddress, + amountUSDC, + permissionId, +}: ChargeParams) { + // 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, // Base + apiKey: process.env.JAW_API_KEY!, + }); + + // Convert to 6-decimal USDC units + const amountUnits = BigInt(Math.floor(amountUSDC * 1e6)); + + console.log( + `Charging ${formatUnits(amountUnits, 6)} USDC to ${merchantAddress}` + ); + + // Send the transfer using the granted permission + const { id, chainId } = await account.sendCalls( + [ + { + to: USDC_BASE, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [merchantAddress, amountUnits], + }), + }, + ], + { permissionId } + ); + + console.log(`Submitted. Operation ID: ${id}, Chain: ${chainId}`); + + // Poll for completion — sendCalls returns immediately + let status = await account.getCallStatus(id); + const maxAttempts = 30; + let attempts = 0; + + while (status.status === 100 && attempts < maxAttempts) { + await new Promise((r) => setTimeout(r, 2000)); + status = await account.getCallStatus(id); + attempts++; + } + + switch (status.status) { + case 200: + console.log('Payment completed successfully'); + return { success: true, operationId: id }; + case 400: + console.error('Offchain failure — bundler rejected the operation'); + return { success: false, error: 'offchain_failure', operationId: id }; + case 500: + console.error('Onchain revert — check permission allowance and expiry'); + return { success: false, error: 'onchain_revert', operationId: id }; + default: + console.error(`Timeout after ${maxAttempts} polling attempts`); + return { success: false, error: 'timeout', operationId: id }; + } +} + +// Example usage +async function main() { + const result = await chargeSubscription({ + merchantAddress: '0xMerchantAddress...' as `0x${string}`, + amountUSDC: 9.99, + permissionId: 'permission-id-from-client', + }); + + if (!result.success) { + // Implement retry logic, alerting, or dead-letter queue + console.error(`Payment failed: ${result.error}`); + process.exit(1); + } +} + +main().catch(console.error); +``` + +## Environment Variables + +```bash +# .env (never commit this file) +JAW_API_KEY=your_jaw_api_key +SPENDER_PRIVATE_KEY=0x... # Must match the signer address in the permission grant +``` + +## Usage + +```bash +npx tsx scripts/charge-subscription.ts +``` + +## Notes + +- `Account.fromLocalAccount` creates a server-side account that uses a private key instead of WebAuthn passkeys. No browser required. +- The private key used here must match the `signer.data.id` address that was specified when the user called `grantPermissions` on the client. +- `sendCalls` returns immediately with an operation ID. You must poll `getCallStatus` to confirm completion. Status codes: `100` = Pending, `200` = Completed, `400` = Offchain failure, `500` = Onchain revert. +- Permission grants have an `expiry` (Unix seconds) and optional `allowance` limits. If the permission is expired or the allowance is exhausted, the transaction will revert on-chain (status `500`). +- Never hardcode private keys in source code. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault) or HSM in production. +- The USDC contract address is for Base mainnet. Verify on-chain before using in production. diff --git a/skills/jaw/examples/permissions/README.md b/skills/jaw/examples/permissions/README.md new file mode 100644 index 0000000..6d51a36 --- /dev/null +++ b/skills/jaw/examples/permissions/README.md @@ -0,0 +1,207 @@ +# ERC-7715 Permissions with JAW + +Working TypeScript/React example for granting, querying, and revoking delegated permissions using JAW SDK's ERC-7715 implementation. + +## Dependencies + +```bash +npm install @jaw.id/wagmi wagmi viem @tanstack/react-query +``` + +## Prerequisites + +- A JAW wagmi provider setup (see the `wagmi-connect` example) +- A connected wallet via `useConnect` from `@jaw.id/wagmi` +- A spender address (EOA or server key) that will execute transactions on the user's behalf + +## Permissions Manager Component + +```tsx +// components/PermissionsManager.tsx +"use client"; + +import { useState } from 'react'; +import { useAccount } from 'wagmi'; +import { + useGrantPermissions, + useRevokePermissions, +} from '@jaw.id/wagmi'; + +// Base USDC — verify on-chain before using in production +const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const; + +// ERC-7528 sentinel for native ETH spend permissions +const NATIVE_ETH = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' as const; + +export function PermissionsManager() { + const { isConnected } = useAccount(); + const { grantPermissions, isPending: isGranting } = useGrantPermissions(); + const { revokePermissions, isPending: isRevoking } = useRevokePermissions(); + + const [permissionId, setPermissionId] = useState(null); + const [error, setError] = useState(null); + + if (!isConnected) { + return

Connect your wallet first.

; + } + + async function handleGrantUSDC() { + setError(null); + + try { + const result = await grantPermissions({ + // MUST be Unix seconds, not milliseconds + expiry: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, // 30 days + signer: { + type: 'account', + data: { + id: '0xSpenderAddress...' as `0x${string}`, // Your server's signing key + }, + }, + permissions: [ + { + type: 'call-permission', + data: { + to: USDC_BASE, + // Use functionSignature OR selector, never both + functionSignature: 'transfer(address,uint256)', + }, + }, + { + type: 'spend-permission', + data: { + token: USDC_BASE, + allowance: '100000000', // 100 USDC (string, 6 decimals) + period: 'month', + }, + }, + ], + }); + + // MUST store permissionId — needed for server-side usage and revocation + setPermissionId(result.permissionId); + console.log('Permission granted:', result.permissionId); + + // In production, send permissionId to your backend: + // await fetch('/api/permissions', { + // method: 'POST', + // body: JSON.stringify({ permissionId: result.permissionId }), + // }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + if (message.includes('User rejected') || message.includes('user denied')) { + return; // User cancelled — not an error + } + setError(message); + } + } + + async function handleGrantNativeETH() { + setError(null); + + try { + const result = await grantPermissions({ + expiry: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7 days + signer: { + type: 'account', + data: { + id: '0xSpenderAddress...' as `0x${string}`, + }, + }, + permissions: [ + { + type: 'spend-permission', + data: { + token: NATIVE_ETH, // ERC-7528 sentinel for native ETH + allowance: '100000000000000000', // 0.1 ETH in wei (string) + period: 'week', + }, + }, + ], + }); + + setPermissionId(result.permissionId); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + if (!message.includes('User rejected')) { + setError(message); + } + } + } + + async function handleRevoke() { + if (!permissionId) return; + setError(null); + + try { + // Revocation is permanent and costs gas — confirm with user first + await revokePermissions({ permissionId }); + console.log('Permission revoked:', permissionId); + setPermissionId(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + } + + return ( +
+

ERC-7715 Permissions

+ +
+

Grant Permissions

+ + +
+ + {permissionId && ( +
+

Active Permission

+

ID: {permissionId}

+ +

Revocation is permanent and costs gas.

+
+ )} + + {error && ( +
+

{error}

+
+ )} +
+ ); +} +``` + +## Usage + +```tsx +// app/permissions/page.tsx +"use client"; + +import { PermissionsManager } from '@/components/PermissionsManager'; + +export default function PermissionsPage() { + return ( +
+

Manage Permissions

+ +
+ ); +} +``` + +## Notes + +- **Expiry must be Unix seconds**, not milliseconds. `Math.floor(Date.now() / 1000) + duration` is the correct pattern. Using `Date.now()` directly sets an expiry thousands of years in the future. +- **Use `functionSignature` OR `selector`**, never both. The SDK throws if you provide both in a call permission. +- **Allowance is a string**, not a `bigint`. Specify it in the token's smallest unit (6 decimals for USDC, 18 for ETH). +- **Native ETH permissions** use the ERC-7528 sentinel address: `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`. +- **Store the `permissionId`** server-side immediately after granting. It is required for both server-side execution (`account.sendCalls(..., { permissionId })`) and client-side revocation. +- **Revocation is permanent** and costs gas. Once revoked, the permission cannot be reinstated -- the user must grant a new permission. +- **Spend periods**: `minute`, `hour`, `day`, `week`, `month`, `year`, `forever`. The allowance resets at the start of each period. diff --git a/skills/jaw/examples/wagmi-connect/README.md b/skills/jaw/examples/wagmi-connect/README.md new file mode 100644 index 0000000..021a0cd --- /dev/null +++ b/skills/jaw/examples/wagmi-connect/README.md @@ -0,0 +1,123 @@ +# Connect Wallet with JAW + Wagmi + +Working TypeScript/React example for connecting and disconnecting a JAW passkey-authenticated smart account using the wagmi connector. + +## Dependencies + +```bash +npm install @jaw.id/wagmi wagmi viem @tanstack/react-query +``` + +## Wagmi Config + +```typescript +// lib/config.ts +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', + }), + ], + transports: { + [mainnet.id]: http(), + [base.id]: http(), + }, +}); +``` + +## Provider Setup + +```tsx +// app/providers.tsx +"use client"; + +import { WagmiProvider } from 'wagmi'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { config } from '@/lib/config'; +import type { ReactNode } from 'react'; + +const queryClient = new QueryClient(); + +export function Providers({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +## Connect/Disconnect Component + +```tsx +// components/ConnectWallet.tsx +"use client"; + +import { useConnect, useDisconnect } from '@jaw.id/wagmi'; +import { useAccount } from 'wagmi'; + +export function ConnectWallet() { + const { connect, connectors, isPending, error } = useConnect(); + const { disconnect } = useDisconnect(); + const { isConnected, address, chain } = useAccount(); + + if (isConnected && address) { + return ( +
+

Connected: {address}

+

Chain: {chain?.name ?? 'Unknown'} ({chain?.id})

+ +
+ ); + } + + return ( +
+ + {error &&

{error.message}

} +
+ ); +} +``` + +## Usage + +```tsx +// app/page.tsx +"use client"; + +import { ConnectWallet } from '@/components/ConnectWallet'; + +export default function HomePage() { + return ( +
+

JAW Wagmi Demo

+ +
+ ); +} +``` + +## Notes + +- Import `useConnect` and `useDisconnect` from `@jaw.id/wagmi`, not from `wagmi`. The JAW versions support capabilities (SIWE, ENS subnames) that wagmi's hooks do not. +- `disconnect({})` requires an empty object argument. Calling `disconnect()` with no args throws a runtime error. +- The `connectors[0]` approach works when JAW is the only connector. If you have multiple connectors, filter by name or use a connector picker UI. +- Every chain in the `chains` array must have a matching entry in `transports`. Missing transports cause silent connection failures. +- Check `window.PublicKeyCredential` before connecting to verify WebAuthn support in the browser. diff --git a/skills/jaw/resources/error-codes.md b/skills/jaw/resources/error-codes.md new file mode 100644 index 0000000..68ea65d --- /dev/null +++ b/skills/jaw/resources/error-codes.md @@ -0,0 +1,68 @@ +# JAW SDK Error Codes Reference + +> **Last verified:** March 2026 (`@jaw.id/wagmi` v1.x, `@jaw.id/core` v1.x) + +Error codes from JAW SDK's EIP-1193 provider, JSON-RPC layer, and smart account operations. + +## EIP-1193 Provider Errors + +| Code | Name | Cause | Fix | +|------|------|-------|-----| +| `4001` | User Rejected | User cancelled the passkey prompt or signing request | Silent reset to idle. Not an error -- never show an error toast. | +| `4100` | Unauthorized | Provider request made without a connected account | Check `isConnected` from `useAccount()` before any provider calls. Reconnect if needed. | +| `4200` | Unsupported Method | RPC method not supported by the JAW provider | Verify the method name. Use `wallet_sign` instead of `personal_sign` when possible. | +| `4900` | Disconnected | Provider lost connection to the backend | Prompt user to reconnect. Check network connectivity. | +| `4901` | Chain Disconnected | Connected but the active chain is unavailable | Switch to a different chain with `useSwitchChain()`. | +| `4902` | Unrecognized Chain | Requested chain not in the wagmi config | Add the chain to `chains` and `transports` in `createConfig`. | + +## JSON-RPC Errors + +| Code | Name | Cause | Fix | +|------|------|-------|-----| +| `-32700` | Parse Error | Malformed JSON in the request payload | Validate request format. Use `encodeFunctionData` from viem for calldata. | +| `-32600` | Invalid Request | Request object missing required fields | Check `method` and `params` fields are present. | +| `-32601` | Method Not Found | RPC method does not exist | Verify method name. JAW supports `wallet_sendCalls`, `wallet_sign`, `wallet_connect`, etc. | +| `-32602` | Invalid Params | Wrong parameter types or missing required params | Check parameter order and types. `personal_sign` requires hex-encoded message. | +| `-32603` | Internal Error | Unexpected server-side failure | Retry with exponential backoff. Report if persistent. | + +## JAW-Specific Errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `UIHandler is required for AppSpecific mode` | `Mode.AppSpecific` set without providing a `uiHandler` | Add `uiHandler: new ReactUIHandler()` to connector config, or switch to `Mode.CrossPlatform`. | +| `Cannot read properties of undefined` (on disconnect) | `disconnect()` called without empty object argument | Call `disconnect({})` instead of `disconnect()`. | +| `WebAuthn not supported` | Browser lacks `PublicKeyCredential` API | Feature-detect with `window.PublicKeyCredential` before passkey operations. Use HTTPS or `localhost`. | +| `Response ID mismatch` | Custom `UIHandler.request()` returned mismatched `id` | Ensure `response.id === request.id` in your UIHandler implementation. | +| `Both selector and functionSignature provided` | Permission grant includes both `selector` and `functionSignature` | Use one or the other in `call-permission`, never both. | +| `Invalid expiry` | Permission expiry is in the past or uses milliseconds | Use Unix seconds: `Math.floor(Date.now() / 1000) + durationInSeconds`. | + +## Smart Account Operation Errors + +| Error Pattern | Cause | Fix | +|---------------|-------|-----| +| `insufficient funds` | Account balance too low for transaction + gas | Fund the smart account or configure a paymaster for gas sponsorship. | +| `execution reverted` | Smart contract reverted the call | Decode revert reason from error data. Check contract inputs and permissions. | +| `AA21 didn't pay prefund` | Paymaster rejected or account cannot cover gas | Verify paymaster configuration. Check EntryPoint v0.8 compatibility. | +| `AA25 invalid account nonce` | Nonce conflict from concurrent operations | Wait for pending operations to complete before submitting new ones. | +| `permission expired` | ERC-7715 permission past its expiry timestamp | Grant a new permission with a future expiry. | +| `allowance exceeded` | Spend permission allowance exhausted for the current period | Wait for the next period reset, or grant a new permission with higher allowance. | + +## Batch Operation Status Codes + +| Status | Meaning | Action | +|--------|---------|--------| +| `100` | Pending | Continue polling `getCallStatus`. Operation is still being processed. | +| `200` | Completed | Batch succeeded. All calls executed on-chain. | +| `400` | Offchain Failure | Bundler rejected the operation. Check gas, nonce, and paymaster config. | +| `500` | Onchain Revert | Batch reverted on-chain. Check contract inputs, permissions, and allowances. | + +## HTTP Status Codes (JAW API) + +| Status | Meaning | Action | +|--------|---------|--------| +| `200` | Success | Process response | +| `400` | Bad Request | Check request format and required fields | +| `401` | Unauthorized | Verify API key is valid and not expired | +| `403` | Forbidden | API key does not have access to the requested resource | +| `429` | Rate Limited | Implement exponential backoff and retry | +| `500` | Internal Server Error | Retry with backoff. Report if persistent. | diff --git a/skills/jaw/resources/sdk-reference.md b/skills/jaw/resources/sdk-reference.md new file mode 100644 index 0000000..6ffcfe8 --- /dev/null +++ b/skills/jaw/resources/sdk-reference.md @@ -0,0 +1,110 @@ +# JAW SDK Reference + +> **Last verified:** March 2026 (`@jaw.id/wagmi` v1.x, `@jaw.id/core` v1.x, `@jaw.id/ui` v1.x) + +Key imports, hooks, methods, and types across JAW SDK packages. + +## Packages + +| Package | Purpose | Install | +|---------|---------|---------| +| `@jaw.id/wagmi` | React/Next.js wagmi connector and hooks | `npm install @jaw.id/wagmi wagmi viem @tanstack/react-query` | +| `@jaw.id/core` | Vanilla JS, Node.js, headless server-side | `npm install @jaw.id/core viem` | +| `@jaw.id/ui` | Pre-built UI for AppSpecific auth mode | `npm install @jaw.id/ui` | + +## Hooks from @jaw.id/wagmi (NOT from wagmi) + +These hooks must be imported from `@jaw.id/wagmi`. The wagmi equivalents do not support JAW capabilities. + +| Hook | Import | Purpose | +|------|--------|---------| +| `useConnect()` | `@jaw.id/wagmi` | Connect with passkey. Supports SIWE and subname capabilities. | +| `useDisconnect()` | `@jaw.id/wagmi` | Disconnect. Must call `disconnect({})` with empty object. | +| `useSign()` | `@jaw.id/wagmi` | Unified signing: personal messages and EIP-712 typed data. | +| `useGrantPermissions()` | `@jaw.id/wagmi` | Grant ERC-7715 delegated permissions. | +| `useRevokePermissions()` | `@jaw.id/wagmi` | Revoke a granted permission (permanent, costs gas). | + +## Hooks from wagmi (safe to use directly) + +| Hook | Import | Purpose | +|------|--------|---------| +| `useAccount()` | `wagmi` | Connection state, address, chain info | +| `useSendTransaction()` | `wagmi` | Single transaction | +| `useSendCalls()` | `wagmi/experimental` | Batch (atomic) transactions | +| `useCallsStatus()` | `wagmi/experimental` | Poll batch operation status | +| `useSwitchChain()` | `wagmi` | Switch active chain | +| `useBalance()` | `wagmi` | Read account balance | +| `useReadContract()` | `wagmi` | Read contract state | + +## useConnect() Return Values + +| Property/Method | Type | Description | +|----------------|------|-------------| +| `connect()` | `(opts: { connector }) => void` | Initiate passkey connection | +| `connectors` | `Connector[]` | Available connectors (JAW connector at index 0 if only one) | +| `isPending` | `boolean` | Connection in progress | +| `error` | `Error \| null` | Connection error | + +## useSign() Return Values + +| Property/Method | Type | Description | +|----------------|------|-------------| +| `sign()` | `(opts) => Promise` | Sign message or typed data | +| `isPending` | `boolean` | Signing in progress | + +## Account Class (@jaw.id/core) + +### Static Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `Account.create()` | `(config: AccountConfig) => Promise` | Create new account (triggers WebAuthn registration) | +| `Account.get()` | `(config: AccountConfig) => Promise` | Get existing account (returning user) | +| `Account.fromLocalAccount()` | `(config: LocalAccountConfig) => Account` | Server-side account from private key (no WebAuthn) | + +### Instance Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `sendTransaction()` | `(calls: TransactionCall[]) => Promise` | Send single tx. Waits for receipt, returns tx hash. | +| `sendCalls()` | `(calls: TransactionCall[], opts?) => Promise<{ id, chainId }>` | Send batch. Returns immediately with operation ID. | +| `getCallStatus()` | `(id: string) => Promise` | Poll batch status: 100/200/400/500. | +| `estimateGas()` | `(calls: TransactionCall[]) => Promise` | Estimate gas for calls. | +| `getMetadata()` | `() => Promise` | Account metadata (null for local accounts). | + +### Account Properties + +| Property | Type | Description | +|----------|------|-------------| +| `address` | `` `0x${string}` `` | Smart account address | +| `chainId` | `number` | Active chain ID | +| `credentialId` | `string \| undefined` | WebAuthn credential ID (store for returning users) | + +## Provider RPC Methods + +| Method | Purpose | Notes | +|--------|---------|-------| +| `eth_requestAccounts` | Basic connect (no capabilities) | Use `wallet_connect` for SIWE/subnames | +| `wallet_connect` | Connect with capabilities (SIWE, ENS) | Preferred over `eth_requestAccounts` | +| `eth_sendTransaction` | Single transaction | Standard EIP-1193 | +| `wallet_sendCalls` | Batch atomic transactions | Returns operation ID, poll with `wallet_getCallsStatus` | +| `wallet_getCallsStatus` | Poll batch status | Status: 100/200/400/500 | +| `personal_sign` | Sign message (hex-encoded) | Must use `toHex()` from viem | +| `wallet_sign` | Sign message (no encoding needed) | Preferred over `personal_sign` | +| `eth_signTypedData_v4` | Sign EIP-712 typed data | Must `JSON.stringify` the typed data | +| `wallet_grantPermissions` | Grant ERC-7715 permissions | Expiry in Unix seconds | +| `wallet_revokePermissions` | Revoke permissions | Permanent, costs gas | + +## jaw() Connector Config + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `apiKey` | `string` | Required | JAW API key from dashboard.jaw.id | +| `appName` | `string` | `'DApp'` | App name shown in passkey prompts | +| `appLogoUrl` | `string` | None | HTTPS URL, min 200x200px | +| `defaultChainId` | `number` | First chain | Initial active chain | +| `showTestnets` | `boolean` | `false` | Required to expose testnet chains | +| `paymasters` | `Record` | None | Per-chain paymaster URLs and context | +| `ens` | `object` | None | ENS configuration | +| `preference.mode` | `Mode` | `Mode.CrossPlatform` | Auth mode (CrossPlatform or AppSpecific) | +| `preference.uiHandler` | `UIHandler` | None | Required for AppSpecific mode | diff --git a/skills/jaw/resources/typescript-types.md b/skills/jaw/resources/typescript-types.md new file mode 100644 index 0000000..dec1212 --- /dev/null +++ b/skills/jaw/resources/typescript-types.md @@ -0,0 +1,189 @@ +# JAW SDK TypeScript Types Reference + +> **Last verified:** March 2026 (`@jaw.id/wagmi` v1.x, `@jaw.id/core` v1.x) + +Key TypeScript interfaces and types used across JAW SDK packages. + +## AccountConfig + +Configuration for creating or retrieving a passkey-authenticated account. + +```typescript +interface AccountConfig { + chainId: number; // EVM chain ID (e.g., 1 for mainnet, 8453 for Base) + apiKey: string; // JAW API key from dashboard.jaw.id + credentialId?: string; // WebAuthn credential ID (required for returning users) +} +``` + +## LocalAccountConfig + +Configuration for server-side accounts using a private key instead of WebAuthn. + +```typescript +interface LocalAccountConfig { + privateKey: string; // Hex-encoded private key (0x-prefixed) + chainId: number; // EVM chain ID + apiKey: string; // JAW API key +} +``` + +## TransactionCall + +A single call in a transaction or batch operation. + +```typescript +interface TransactionCall { + to: `0x${string}`; // Target address (must have 0x prefix) + value?: bigint; // Amount in wei (use parseEther from viem) + data?: `0x${string}`; // Encoded calldata (use encodeFunctionData from viem) +} +``` + +## CallStatusResponse + +Response from `getCallStatus` when polling batch operation status. + +```typescript +interface CallStatusResponse { + status: 100 | 200 | 400 | 500; +} +``` + +Status codes: + +| Status | Meaning | Description | +|--------|---------|-------------| +| `100` | Pending | Operation is being processed by the bundler | +| `200` | Completed | All calls executed successfully on-chain | +| `400` | Offchain Failure | Bundler rejected the operation (gas, nonce, or paymaster issue) | +| `500` | Onchain Revert | Batch reverted on-chain (contract error, permission issue) | + +## PermissionsDetail + +Top-level structure for an ERC-7715 permission grant request. + +```typescript +interface PermissionsDetail { + expiry: number; // Unix timestamp in SECONDS (not milliseconds) + signer: { + type: 'account'; + data: { + id: `0x${string}`; // Spender address (EOA that will execute on user's behalf) + }; + }; + permissions: PermissionEntry[]; +} + +type PermissionEntry = + | { type: 'call-permission'; data: CallPermissionDetail } + | { type: 'spend-permission'; data: SpendPermissionDetail }; +``` + +## CallPermissionDetail + +Restricts which contract functions the spender can call. + +```typescript +interface CallPermissionDetail { + to: `0x${string}`; // Target contract address + functionSignature?: string; // e.g., 'transfer(address,uint256)' + selector?: `0x${string}`; // e.g., '0xa9059cbb' (first 4 bytes of keccak) +} +``` + +Use `functionSignature` OR `selector`, never both. The SDK throws if both are provided. + +## SpendPermissionDetail + +Restricts how much of a token the spender can transfer per time period. + +```typescript +interface SpendPermissionDetail { + token: `0x${string}`; // ERC-20 address, or ERC-7528 sentinel for native ETH + allowance: string; // Amount in smallest unit (string, not bigint) + period: SpendPeriod; // Reset interval + multiplier?: number; // Optional multiplier for the period +} + +type SpendPeriod = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year' | 'forever'; +``` + +Native ETH sentinel address (ERC-7528): `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE` + +## UIHandler + +Interface for custom passkey UI in AppSpecific mode. Implement this to fully control the user experience. + +```typescript +interface UIHandler { + init(config: UIHandlerConfig): void; + canHandle(request: UIRequest): boolean; + request(request: UIRequest): Promise; + cleanup(): void; +} + +interface UIHandlerConfig { + // SDK-provided configuration passed during initialization +} + +interface UIRequest { + id: string; // Unique request ID + method: string; // RPC method being requested + params: unknown; // Method-specific parameters +} + +interface UIResponse { + id: string; // MUST match request.id — mismatch causes SDK to hang + result: unknown; // Method-specific result +} +``` + +Request methods to handle: `wallet_connect`, `personal_sign`, `eth_signTypedData_v4`, `wallet_sendCalls`, `wallet_grantPermissions`, `wallet_revokePermissions`, `wallet_sign`. + +## UIError + +Error class for signaling user rejection from a custom UIHandler. + +```typescript +import { UIError } from '@jaw.id/core'; + +// In your UIHandler.request() implementation: +if (!userApproved) { + throw UIError.userRejected(); // Triggers EIP-1193 code 4001 +} +``` + +## PaymasterConfig + +Per-chain paymaster configuration for gas sponsorship. + +```typescript +interface PaymasterConfig { + url: string; // Paymaster RPC endpoint (must support EIP-7677) + context?: { + sponsorshipPolicyId?: string; // Policy ID from your paymaster provider + [key: string]: unknown; + }; +} + +// Usage in jaw() connector config: +jaw({ + apiKey: 'YOUR_API_KEY', + paymasters: { + [mainnet.id]: { url: '...', context: { sponsorshipPolicyId: '...' } }, + [base.id]: { url: '...', context: { sponsorshipPolicyId: '...' } }, + }, +}); +``` + +## Mode Enum + +Authentication mode for the JAW connector. + +```typescript +import { Mode } from '@jaw.id/wagmi'; + +Mode.CrossPlatform // Default. Passkey operations in keys.jaw.id popup. Portable. +Mode.AppSpecific // Passkey operations in your app. Requires uiHandler. White-label. +``` diff --git a/skills/jaw/templates/jaw-provider.tsx b/skills/jaw/templates/jaw-provider.tsx new file mode 100644 index 0000000..8f1d03a --- /dev/null +++ b/skills/jaw/templates/jaw-provider.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { WagmiProvider, createConfig, http } from "wagmi"; +import { mainnet, base, arbitrum, optimism } from "wagmi/chains"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { jaw } from "@jaw.id/wagmi"; +import type { ReactNode } from "react"; + +const config = createConfig({ + chains: [mainnet, base, arbitrum, optimism], + connectors: [ + jaw({ + apiKey: process.env.NEXT_PUBLIC_JAW_API_KEY!, + appName: "My App", + appLogoUrl: "https://example.com/logo.png", // HTTPS, min 200x200px + // ENS subname issuance during onboarding: + // ens: "yourdomain.eth", + // Uncomment for testnet support: + // showTestnets: true, + + // Gas sponsorship — configure per chain + // Requires a paymaster that supports EntryPoint v0.8 and EIP-7677 + // paymasters: { + // [base.id]: { + // url: `https://api.pimlico.io/v2/${base.id}/rpc?apikey=${process.env.NEXT_PUBLIC_PIMLICO_KEY}`, + // context: { sponsorshipPolicyId: 'your-policy-id' }, + // }, + // }, + + // AppSpecific mode — passkey prompts rendered in your app (white-label) + // Requires @jaw.id/ui or a custom UIHandler implementation + // preference: { + // mode: Mode.AppSpecific, + // uiHandler: new ReactUIHandler(), + // }, + }), + ], + // IMPORTANT: every chain in `chains` must have a transport here. + // Missing transports cause silent connection failures. + transports: { + [mainnet.id]: http(), + [base.id]: http(), + [arbitrum.id]: http(), + [optimism.id]: http(), + }, +}); + +const queryClient = new QueryClient(); + +export function Providers({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +}