ERC-4337 Account Abstraction for React — gasless transactions, social login, and smart accounts without the complexity.
Built on Privy (auth) · Pimlico (bundler + paymaster) · Permissionless (smart accounts) · Polygon Amoy (default chain)
Normally, setting up ERC-4337 means wiring together Privy, Permissionless, Pimlico, viem, wagmi, and writing ~200 lines of boilerplate hooks yourself — dealing with race conditions, polyfills, gas estimation, UserOperation formatting, and paymaster sponsorship.
This package collapses all of that into three exports: a provider, a hook, and a transaction hook.
Without erc4337-kit: With erc4337-kit:
───────────────────── ─────────────────────
200 lines of setup → <ChainProvider> (5 lines)
Privy + wagmi + QueryClient useSmartAccount() (1 line)
Smart account init race fix useStoreOnChain() (1 line)
Pimlico gas estimation
UserOperation formatting
Error parsing
- React 18 or 19
- Vite (Next.js support coming)
- Node.js 18+
- A Privy App ID (free at dashboard.privy.io)
- A Pimlico API Key (free at dashboard.pimlico.io)
- An Alchemy RPC URL (free at dashboard.alchemy.com)
# Step 1: install the package
npm install erc4337-kit
# Step 2: install peer dependencies
npm install @privy-io/react-auth @privy-io/wagmi viem wagmi @tanstack/react-query
# Step 3: install browser polyfills (viem needs these)
npm install buffer processimport { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
define: {
global: 'globalThis', // required for viem
},
resolve: {
alias: {
'@noble/curves/nist.js': '@noble/curves/nist', // required for permissionless
},
},
})If you're using Tailwind v4, add
tailwindcss from '@tailwindcss/vite'to plugins as normal — it's compatible.
<head>
<!-- ... your other meta tags ... -->
<!-- REQUIRED: add this before <script src="/src/main.jsx"> -->
<script type="module">
import { Buffer } from 'buffer'
import process from 'process'
window.Buffer = Buffer
window.process = process
</script>
</head>Why?
viemandpermissionlessuse Node.js globals (Buffer,process) that don't exist in the browser. This polyfill must load before your app or you'll getReferenceError: Buffer is not defined.
VITE_PRIVY_APP_ID= # from dashboard.privy.io → your app → App ID
VITE_PIMLICO_API_KEY= # from dashboard.pimlico.io → API Keys
VITE_RPC_URL= # from dashboard.alchemy.com → Polygon Amoy → HTTPS URL
VITE_CONTRACT_ADDRESS= # your deployed contract address (after you deploy)Never commit
.envto git. Add it to.gitignore.
Put this in src/main.jsx. It sets up Privy, QueryClient, and Wagmi in one shot.
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ChainProvider, polygonAmoy } from 'erc4337-kit'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ChainProvider
privyAppId={import.meta.env.VITE_PRIVY_APP_ID}
chain={polygonAmoy}
rpcUrl={import.meta.env.VITE_RPC_URL}
loginMethods={['google', 'email']} // optional, this is the default
appearance={{ theme: 'dark', accentColor: '#7c3aed' }} // optional
>
<App />
</ChainProvider>
</React.StrictMode>,
)import { useSmartAccount, polygonAmoy } from 'erc4337-kit'
function App() {
const {
login, // Function — opens Privy login modal
logout, // Function — clears all state
authenticated, // boolean — user is logged in
user, // Privy user object (has .email.address, .google.email)
smartAccountAddress, // string — the user's smart account address (0x...)
smartAccountClient, // SmartAccountClient — use this to send transactions
isReady, // boolean — smart account is initialized, safe to transact
isLoading, // boolean — still setting up
error, // string | null — human-readable error message
} = useSmartAccount({
pimlicoApiKey: import.meta.env.VITE_PIMLICO_API_KEY,
rpcUrl: import.meta.env.VITE_RPC_URL,
chain: polygonAmoy,
})
if (!authenticated) return <button onClick={login}>Sign in</button>
if (isLoading) return <p>Setting up your wallet…</p>
if (error) return <p style={{ color: 'red' }}>Error: {error}</p>
return (
<div>
<p>Smart account: {smartAccountAddress}</p>
<button onClick={logout}>Sign out</button>
</div>
)
}
smartAccountAddressis deterministic — the same user always gets the same address across sessions. It is a smart contract address, not the user's EOA (embedded wallet). Store this in your database, not the Privy user ID, if you need to link on-chain records to users.
import { useStoreOnChain, sha256Hash } from 'erc4337-kit'
const MY_ABI = [{
name: 'storeRecord',
type: 'function',
inputs: [{ name: 'dataHash', type: 'bytes32' }],
}]
function SubmitForm({ smartAccountClient }) {
const {
submit, // async (args: any[]) => string | null — returns txHash
txHash, // string | null
recordId, // string | null — decoded bytes32 from first event log
isLoading, // boolean
isSuccess, // boolean
error, // string | null
reset, // Function — resets all state back to null
} = useStoreOnChain({
smartAccountClient,
contractAddress: import.meta.env.VITE_CONTRACT_ADDRESS,
abi: MY_ABI,
functionName: 'storeRecord',
})
const handleSubmit = async (rawData) => {
const hash = await sha256Hash(JSON.stringify(rawData)) // hash locally
await submit([hash]) // send on-chain
}
return (
<div>
<button onClick={() => handleSubmit({ text: 'my data' })} disabled={isLoading}>
{isLoading ? 'Storing…' : 'Submit'}
</button>
{isSuccess && <p>Stored! Tx: <a href={`https://amoy.polygonscan.com/tx/${txHash}`}>{txHash?.slice(0,10)}…</a></p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
)
}import { encodeFunctionData } from 'viem'
const handleAddTodo = async (task) => {
const calldata = encodeFunctionData({
abi: contractABI,
functionName: 'addTodo',
args: [task],
})
// ✅ Correct — use sendTransaction with encoded calldata
const hash = await smartAccountClient.sendTransaction({
to: contractAddress,
data: calldata,
value: 0n, // no ETH/MATIC being sent
})
console.log('tx hash:', hash)
}Critical: Do NOT use
smartAccountClient.writeContract(). The smart account client usessendTransactionwithencodeFunctionData. CallingwriteContractthrowsaccount.encodeCalls is not a function.
For reading, create a standard publicClient from viem. Reading is free (no gas, no smart account needed).
import { createPublicClient, http } from 'viem'
import { polygonAmoy } from 'erc4337-kit'
const publicClient = createPublicClient({
chain: polygonAmoy,
transport: http(import.meta.env.VITE_RPC_URL),
})
// For user-specific data, pass account: smartAccountAddress
const todos = await publicClient.readContract({
address: contractAddress,
abi: contractABI,
functionName: 'getTodos',
args: [],
account: smartAccountAddress, // required for mapping(address => ...) returns
})
account: smartAccountAddressis required when your contract usesmsg.senderto look up data. Without it, the read returns data for address0x000...000instead.
import { polygonAmoy, polygon, sepolia, baseSepolia } from 'erc4337-kit'| Export | Network | Use for |
|---|---|---|
polygonAmoy |
Polygon Amoy testnet (chain ID 80002) | Development and testing |
polygon |
Polygon mainnet | Production |
sepolia |
Ethereum Sepolia testnet | Ethereum testing |
baseSepolia |
Base Sepolia testnet | Base chain testing |
Any chain supported by both Pimlico and Privy works — these are just the re-exported convenience constants.
Your contract works with this package without modification. There is one rule you must understand:
msg.sender in your contract will be the user's Smart Account address, not their EOA.
This is correct and expected. Your mappings, ownership checks, and identity logic should use msg.sender as normal — it will consistently resolve to the user's smart account address every session.
A minimal compatible contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract YourApp {
// msg.sender = user's Smart Account (consistent, deterministic)
mapping(address => bytes32[]) private _records;
function storeRecord(bytes32 dataHash) external {
_records[msg.sender].push(dataHash);
}
function getRecords() external view returns (bytes32[] memory) {
return _records[msg.sender];
}
}A template with more complete patterns is included at node_modules/erc4337-kit/src/contracts/BaseStorage.sol.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
privyAppId |
string |
Yes | — | Your Privy App ID |
chain |
Chain (viem) |
Yes | — | Target blockchain |
rpcUrl |
string |
Yes | — | Alchemy / Infura RPC URL |
loginMethods |
string[] |
No | ['google', 'email'] |
Privy login methods |
appearance |
object |
No | { theme: 'light' } |
Privy modal appearance |
Config:
| Field | Type | Required | Description |
|---|---|---|---|
pimlicoApiKey |
string |
Yes | Pimlico API key |
rpcUrl |
string |
Yes | RPC URL matching your chain |
chain |
Chain (viem) |
Yes | Must match ChainProvider |
Returns:
| Field | Type | Description |
|---|---|---|
login |
Function |
Opens Privy login modal |
logout |
Function |
Clears all state and logs out |
authenticated |
boolean |
True when user is logged in |
user |
PrivyUser | null |
Privy user object |
smartAccountAddress |
string | null |
The user's smart account address |
smartAccountClient |
SmartAccountClient | null |
For sending transactions |
pimlicoClient |
PimlicoClient | null |
For gas price reads |
isReady |
boolean |
True when safe to call sendTransaction |
isLoading |
boolean |
True during initialization |
error |
string | null |
Human-readable error |
Config:
| Field | Type | Required | Description |
|---|---|---|---|
smartAccountClient |
SmartAccountClient |
Yes | From useSmartAccount() |
contractAddress |
string |
Yes | Deployed contract address |
abi |
Abi |
Yes | Contract ABI (just the functions you need) |
functionName |
string |
Yes | Function to call |
Returns:
| Field | Type | Description |
|---|---|---|
submit |
async (args: any[]) => string | null |
Sends the transaction, returns txHash |
txHash |
string | null |
Transaction hash after success |
recordId |
string | null |
bytes32 decoded from first event log |
isLoading |
boolean |
True while submitting |
isSuccess |
boolean |
True after successful submission |
error |
string | null |
Human-readable error |
reset |
Function |
Clears all state back to null |
const hash = await sha256Hash('any string') // → '0x7f3a...' (66 chars)
const hash = await sha256HashFile(fileObject) // → '0xabcd...' (66 chars)Both return a 0x-prefixed hex string that is bytes32-compatible. Hashing happens in the browser using the Web Crypto API — no data leaves the device.
The polyfill script is missing from index.html, or it's placed after your app script. It must come first in <head>.
Check all three env vars are set and correct. Add console.log(error) from useSmartAccount to see the exact message. Most commonly: wrong Pimlico API key, or Polygon Amoy not enabled in your Pimlico dashboard.
You called smartAccountClient.writeContract(). Use smartAccountClient.sendTransaction() with encodeFunctionData() from viem instead. See Option B in usage above.
You're missing account: smartAccountAddress in publicClient.readContract(). Without it, reads go out as address 0x0 which returns empty mappings.
Your Pimlico API key is wrong, or Polygon Amoy isn't enabled in your Pimlico project dashboard. The erc4337-kit error message will say this in plain English.
Your Pimlico paymaster balance is empty. The free tier works for testnet — log in and check your dashboard balance.
A previous UserOperation from this smart account is still pending in the bundler mempool. Wait 30–60 seconds and retry.
- Move from Polygon Amoy to Polygon mainnet (change
chainandrpcUrl) - Upgrade Pimlico to a paid plan (free tier is testnet only)
- Set
PRIVATE_KEYand deployment keys only in server env, never inVITE_prefixed vars - Audit your Solidity contract before mainnet
- Add
waitForTransactionReceiptcalls where confirmation matters - Handle the
errorstate fromuseSmartAccountvisibly in your UI - Add
.envto.gitignore
Found a bug? Have a feature request? Contributions are welcome!
- Fork the repo
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- npm: https://www.npmjs.com/package/erc4337-kit
- GitHub: https://github.com/atharvabaodhankar/erc4337-kit
- Privy dashboard: https://dashboard.privy.io
- Pimlico dashboard: https://dashboard.pimlico.io
- Alchemy: https://dashboard.alchemy.com
- Polygon Amoy explorer: https://amoy.polygonscan.com
- ERC-4337 spec: https://eips.ethereum.org/EIPS/eip-4337
- 🐛 Issues: https://github.com/atharvabaodhankar/erc4337-kit/issues
- 💬 Discussions: https://github.com/atharvabaodhankar/erc4337-kit/discussions
MIT © Atharva Baodhankar