Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Reference these guidelines when:
- Implementing Sign-In With Ethereum (SIWE)
- Building headless integrations, server-side operations, or AI agent wallets
- Using the Account class directly (no UI)
- Upgrading EOAs to smart accounts via EIP-7702 (preserving the existing address)
- Integrating with embedded wallet providers (Privy, Turnkey, etc) for EIP-7702
- Building stablecoin payment flows (USDC gas, batch payouts)
- Choosing between CrossPlatform and AppSpecific authentication modes
- Implementing a custom UI handler for app-specific mode
Expand All @@ -34,6 +36,7 @@ Reference these guidelines when:
- **API Key:** Required. Get one at [https://dashboard.jaw.id](https://dashboard.jaw.id)
- **EIP-1193 compatible:** Drop-in replacement for MetaMask or any wallet
- **Smart accounts:** ERC-4337 with passkey signers, gasless tx, batch ops, permissions
- **EIP-7702:** EOAs can upgrade to smart accounts while keeping their address via `Account.fromLocalAccount(config, localAccount, { eip7702: true })`
- **EntryPoint:** v0.8 only (for paymasters)

## Rule index
Expand All @@ -48,6 +51,7 @@ Reference these guidelines when:

- <rules/wagmi-setup.md> - Wagmi connector setup, providers, using standard wagmi hooks with JAW
- <rules/connect-disconnect.md> - useConnect, useDisconnect, connection with capabilities
- <rules/reown-appkit.md> - Adding JAW to an existing Reown AppKit project via WagmiAdapter

### 3. Core Operations

Expand All @@ -66,13 +70,17 @@ Reference these guidelines when:
- <rules/siwe.md> - Sign-In With Ethereum (SIWE) implementation
- <rules/gas-sponsoring.md> - Paymaster setup, sponsorship policies, multi-chain config

### 6. Advanced
### 6. EIP-7702

- <rules/eip7702-upgrade.md> - Upgrade EOA to smart account, provider integrations, authorization signing

### 7. Advanced

- <rules/account-api.md> - Headless Account class for AI agents, server-side, embedded wallets
- <rules/custom-ui-handler.md> - Building a custom UIHandler for app-specific mode
- <rules/provider-api.md> - Direct provider RPC methods reference and patterns

### 7. Reference
### 8. Reference

- <rules/error-handling.md> - EIP-1193 error codes, common errors, debugging
- <rules/typescript-types.md> - Key TypeScript interfaces and type patterns
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,25 @@ const account = await Account.import(config);
const account = await Account.restore(config, credentialId, publicKey);
```

#### Account.fromLocalAccount -- server-side (private key)
#### Account.fromLocalAccount -- server-side / embedded wallets

You MUST use `Account.fromLocalAccount` for server-side operations where WebAuthn is not available. This uses a private key instead of a passkey. `getMetadata()` returns `null` for local accounts since there is no passkey.
You MUST use `Account.fromLocalAccount` for server-side operations or embedded wallet integrations where WebAuthn is not available. `getMetadata()` returns `null` for local accounts since there is no passkey.

```typescript
import { privateKeyToAccount } from 'viem/accounts';

const localAccount = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);

// Default — new counterfactual smart account address
const account = await Account.fromLocalAccount(config, localAccount);

console.log('Address:', account.address);
// account.getMetadata() returns null for local accounts
// EIP-7702 — preserves the EOA address as the smart account address
const account = await Account.fromLocalAccount(config, localAccount, { eip7702: true });
// account.address === localAccount.address
```

When `{ eip7702: true }` is passed, the EOA's address is preserved via EIP-7702 delegation. The SDK handles authorization signing and owner registration automatically on the first transaction. Works with any Viem LocalAccount — private keys, Privy, Turnkey, etc. See <rules/eip7702-upgrade.md> for full details and provider examples.

### Static utility methods

```typescript
Expand Down Expand Up @@ -242,3 +247,35 @@ const account = await Account.create(config, { username: 'alice' });
// Correct -- restores existing account
const account = await Account.get(config, storedCredentialId);
```

### ENS subname issuance

#### Programmatic issuance (JustaName SDK)

If using the Account class API directly instead of the wagmi connector or the provier, use `@justaname.id/sdk` with `overrideSignatureCheck: true` to bypass SIWE authentication:

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

```typescript
import { JustaName } from '@justaname.id/sdk';

const justaName = JustaName.init({
networks: [{ chainId: 1, providerUrl: 'https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY' }],
ensDomains: [{ chainId: 1, ensDomain: 'myapp.eth', apiKey: 'your-api-key' }],
config: { domain: 'yourdapp.com', origin: 'https://yourdapp.com' },
});

await justaName.subnames.addSubname({
username: 'alice',
ensDomain: 'myapp.eth',
chainId: 1,
overrideSignatureCheck: true,
});
```

The same API key from `dashboard.jaw.id` works for both JAW SDK and JustaName SDK — you do NOT need a separate key.

You MUST provide `ensDomains` with `apiKey` in JustaName SDK init — subname creation will fail without it.
You MUST NOT use `@jaw.id/core` for programmatic subname creation — it does not have `addSubname`.
194 changes: 194 additions & 0 deletions .claude/jaw-sdk-best-practices/rules/eip7702-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
## Upgrade EOA to Smart Account (EIP-7702)

EIP-7702 allows an EOA to delegate its code to a smart contract implementation, gaining smart account features (batching, permissions, gas sponsorship) while keeping the same address.

### When to use

| Scenario | Use EIP-7702? |
|----------|--------------|
| User has existing EOA with history/balances they want to keep | Yes |
| Integrating with Privy, Turnkey, or other embedded wallet providers | Yes |
| Server-side automation with a known private key | Yes |
| New user with no existing address | No — use `Account.create()` for passkey accounts or `Account.fromLocalAccount()` without `eip7702` |

### How it works

1. **First transaction** — The SDK signs an EIP-7702 authorization, registers the permissions manager as an owner, and executes your call — all in a single UserOperation.
2. **Subsequent transactions** — Delegation is already active. The SDK skips authorization and owner setup, just sends your call.

The developer does not handle any of this manually. Pass `{ eip7702: true }` and the SDK handles the rest.

### Basic usage

You MUST pass `{ eip7702: true }` as the third argument to `Account.fromLocalAccount()` to preserve the EOA address. Without it, a new counterfactual smart account address is created.

```typescript
import { Account } from '@jaw.id/core';
import { privateKeyToAccount } from 'viem/accounts';

const localAccount = privateKeyToAccount('0x...');

const account = await Account.fromLocalAccount(
{ chainId: 8453, apiKey: 'YOUR_API_KEY' },
localAccount,
{ eip7702: true }
);

// account.address === localAccount.address
```

After creation, all Account methods work the same — `sendCalls`, `sendTransaction`, `signMessage`, `signTypedData`, `grantPermissions`, etc.

### Provider integrations

Any wallet provider that exposes a Viem `LocalAccount` works. The SDK handles authorization signing automatically — if the LocalAccount has `signAuthorization()`, it uses that (Tier 1). If not, it falls back to `hashAuthorization()` + `sign()` (Tier 2).

#### Privy (server)

```typescript
import { Account } from '@jaw.id/core';
import { PrivyClient } from '@privy-io/server-auth';
import { createViemAccount } from '@privy-io/server-auth/viem';

const privy = new PrivyClient(PRIVY_APP_ID, PRIVY_APP_SECRET);
const wallet = await privy.walletApi.getWallet({ id: walletId });

const localAccount = await createViemAccount({
walletId: wallet.id,
address: wallet.address,
privy,
});

const account = await Account.fromLocalAccount(
{ chainId: 8453, apiKey: 'YOUR_API_KEY' },
localAccount,
{ eip7702: true }
);
```

#### Privy (client)

```typescript
import { Account } from '@jaw.id/core';
import { toViemAccount, getEmbeddedConnectedWallet } from '@privy-io/react-auth';

const embeddedWallet = getEmbeddedConnectedWallet(wallets);
const localAccount = await toViemAccount({ wallet: embeddedWallet });

const account = await Account.fromLocalAccount(
{ chainId: 8453, apiKey: 'YOUR_API_KEY' },
localAccount,
{ eip7702: true }
);
```

#### Turnkey (server)

```typescript
import { Account } from '@jaw.id/core';
import { Turnkey } from '@turnkey/sdk-server';
import { createAccount } from '@turnkey/viem';

const turnkey = new Turnkey({
apiBaseUrl: 'https://api.turnkey.com',
defaultOrganizationId: orgId,
apiPublicKey: publicKey,
apiPrivateKey: privateKey,
});

const localAccount = await createAccount({
client: turnkey.apiClient(),
organizationId: orgId,
signWith: walletAddress,
});

const account = await Account.fromLocalAccount(
{ chainId: 8453, apiKey: 'YOUR_API_KEY' },
localAccount,
{ eip7702: true }
);
```

### With gas sponsoring

EIP-7702 accounts work with paymasters. Pass the paymaster URL in config:

```typescript
const account = await Account.fromLocalAccount(
{
chainId: 8453,
apiKey: 'YOUR_API_KEY',
paymasterUrl: 'https://api.pimlico.io/v2/8453/rpc?apikey=YOUR_PIMLICO_KEY',
},
localAccount,
{ eip7702: true }
);

// Gas is fully sponsored
const { id } = await account.sendCalls([
{ to: '0xRecipient...', value: parseEther('0.1') }
]);
```

### With permissions

Grant scoped permissions so a backend or agent can execute on behalf of the user:

```typescript
// Owner grants permission
const response = await ownerAccount.grantPermissions(
Math.floor(Date.now() / 1000) + 86400,
spenderSmartAccountAddress,
{
calls: [{ target: USDC_ADDRESS, selector: '0xa9059cbb' }],
spends: [{ token: USDC_ADDRESS, allowance: '1000000', unit: 'day' }],
}
);

// Spender executes using the permission
const { id } = await spenderAccount.sendCalls(
[{ to: USDC_ADDRESS, data: transferCalldata }],
{ permissionId: response.permissionId }
);
```

### EIP-7702 vs default fromLocalAccount

| | `fromLocalAccount(config, account)` | `fromLocalAccount(config, account, { eip7702: true })` |
|---|---|---|
| **Address** | New counterfactual address | Preserves the EOA address |
| **First tx** | Deploys via factory | Delegates via EIP-7702 authorization |
| **Identity** | New onchain identity | Same address, same history |
| **Use case** | Backend automation, session keys | Upgrade existing users, preserve reputation |

### Key rules

- You MUST pass `{ eip7702: true }` to preserve the EOA address — without it, a new address is created.
- You MUST NOT assume the LocalAccount needs `signAuthorization()` — the SDK falls back to `hashAuthorization()` + `sign()` automatically.
- You MUST NOT send the EIP-7702 authorization manually — the SDK handles it on the first transaction.
- You MUST NOT call `getMetadata()` on EIP-7702 accounts and expect passkey data — it returns `null`.
- You MUST grant permissions to the spender's **smart account address**, not their EOA address.
- You MUST use a paymaster that supports **EntryPoint v0.8** for gas sponsoring with EIP-7702.

### Common mistakes

Do NOT forget the `eip7702` flag:

```typescript
// Wrong — creates a new counterfactual address, not the EOA address
const account = await Account.fromLocalAccount(config, localAccount);

// Correct — preserves the EOA address
const account = await Account.fromLocalAccount(config, localAccount, { eip7702: true });
```

Do NOT grant permissions to the spender's EOA address:

```typescript
// Wrong — the spender's smart account sends the UserOp, not the EOA
await ownerAccount.grantPermissions(expiry, spenderEoa.address, permissions);

// Correct — use the spender's smart account address
const spenderAccount = await Account.fromLocalAccount(config, spenderKey);
await ownerAccount.grantPermissions(expiry, spenderAccount.address, permissions);
```
Loading
Loading