Skip to content
Closed
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
368 changes: 368 additions & 0 deletions docs/erc-4337.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
# ERC-4337 Account Abstraction on Arc Testnet

> **Status:** Testnet only. EntryPoint v0.7 is live on Arc Testnet. All addresses and behaviour documented here apply to the public Arc Testnet (chain ID `5042002`).

Arc is EVM-compatible and fully supports [ERC-4337](https://eips.ethereum.org/EIPS/eip-4337) account abstraction. This guide covers everything a developer needs to deploy and operate ERC-4337 smart accounts, Paymasters, and bundlers on Arc Testnet — including the specific constraints that differ from mainnet EVM chains.

---

## Table of Contents

- [What makes Arc ERC-4337 different](#what-makes-arc-erc-4337-different)
- [Network constants](#network-constants)
- [EntryPoint](#entrypoint)
- [Bundlers](#bundlers)
- [Smart accounts](#smart-accounts)
- [Paymasters](#paymasters)
- [USDC Paymaster pattern](#usdc-paymaster-pattern)
- [Deployment constraints](#deployment-constraints)
- [ERC-7562 storage rules](#erc-7562-storage-rules)
- [USDC as gas on Arc](#usdc-as-gas-on-arc)
- [Finality and UserOperation timing](#finality-and-useroperation-timing)
- [Worked example: gasless USDC bridge burn](#worked-example-gasless-usdc-bridge-burn)
- [Debugging checklist](#debugging-checklist)
- [Reference](#reference)

---

## What makes Arc ERC-4337 different

Arc Testnet is EVM-compatible, with two characteristics that affect ERC-4337 contract deployment and operation. Both have silent failure modes — no helpful revert message, just a rejected UserOperation or a surprising number.

| Characteristic | Detail | Why it matters for ERC-4337 |
|---|---|---|
| **USDC is the native gas token** | Arc's native token is USDC (18-decimal representation for gas accounting, 6-decimal ERC-20 interface) | Paymasters that convert native gas cost to an ERC-20 amount must account for this decimal split |
| **ERC-7562 validation rules** | Standard, but easy to trip on Arc because the reference Paymaster patterns get extended with extra state | `nonReentrant` or other global storage writes in `validatePaymasterUserOp` cause bundlers to drop UserOps with no on-chain error |

> **Note (updated):** Earlier revisions of this guide listed two additional deployment constraints — no `PUSH0` support and a single-`immutable` limit on constructor init code. Both dated from before an Arc Testnet network upgrade and **no longer apply**: the chain now executes `PUSH0` and deploys multi-`immutable` (`0x60c0`-prefix) init code without issue. Contracts compiled with default `solc ≥ 0.8.20` settings deploy normally; the `evmVersion: "paris"` workaround is no longer required (though contracts built with it remain fully compatible).

---

## Network constants

| Parameter | Value |
|---|---|
| Chain ID | `5042002` |
| RPC endpoint | `https://rpc.testnet.arc.io` |
| Block explorer | `https://testnet.arcscan.app` |
| Native gas token | USDC (18 dec for gas accounting) |
| USDC ERC-20 address | `0x3600000000000000000000000000000000000000` |
| USDC ERC-20 decimals | **6** (despite 18-decimal native gas representation) |
| Average block time | ~1 second |
| Finality type | Deterministic BFT (single-slot, no reorgs) |

---

## EntryPoint

The canonical ERC-4337 v0.7 EntryPoint is deployed at the same deterministic CREATE2 address as on every other EVM chain:

```
EntryPoint v0.7: 0x0000000071727De22E5E9d8BAf0edAc6f37da032
```

You can verify it is live:

```bash
cast call 0x0000000071727De22E5E9d8BAf0edAc6f37da032 \
"getNonce(address,uint192)" <your_address> 0 \
--rpc-url https://rpc.testnet.arc.io
```

---

## Bundlers

[Pimlico](https://pimlico.io) is the recommended bundler for Arc Testnet. Set your bundler URL to the Pimlico testnet endpoint for chain `5042002` and supply your `PIMLICO_API_KEY`.

Other ERC-4337-compatible bundlers can be configured pointing at `https://rpc.testnet.arc.io` with `chainId: 5042002`.

---

## Smart accounts

Arc Testnet supports any ERC-4337 v0.7-compatible smart account implementation. The SimpleAccount reference implementation from [eth-infinitism/account-abstraction](https://github.com/eth-infinitism/account-abstraction) works without modification.

Using [permissionless.js](https://docs.pimlico.io/permissionless) (recommended):

```typescript
import { createPublicClient, createWalletClient, http } from "viem";
import { toSimpleSmartAccount } from "permissionless/accounts";
import { privateKeyToAccount } from "viem/accounts";

const ARC_TESTNET = {
id: 5042002,
name: "Arc Testnet",
nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 },
rpcUrls: { default: { http: ["https://rpc.testnet.arc.io"] } },
};

const owner = privateKeyToAccount("0x...");

const publicClient = createPublicClient({
chain: ARC_TESTNET,
transport: http(),
});

const account = await toSimpleSmartAccount({
client: publicClient,
owner,
entryPoint: {
address: "0x0000000071727De22E5E9d8BAf0edAc6f37da032",
version: "0.7",
},
});

console.log("Smart account address:", account.address);
// Deterministic — same address across all chains for the same owner
```

### Funding a smart account

Smart accounts are ordinary addresses. Fund them by sending USDC to `account.address` using a standard ERC-20 transfer from any EOA:

```typescript
import { parseUnits } from "viem";

// Send 10 USDC (6 decimals) to the smart account
await walletClient.writeContract({
address: "0x3600000000000000000000000000000000000000", // Arc USDC
abi: ["function transfer(address to, uint256 amount) returns (bool)"],
functionName: "transfer",
args: [account.address, parseUnits("10", 6)],
});
```

---

## Paymasters

### USDC Paymaster pattern

Because Arc's native gas token is USDC, a USDC Paymaster on Arc achieves a clean user experience: users never need to hold any separate token — USDC covers both the protocol operation and the gas sponsorship.

The Paymaster maintains two USDC pools that must not be confused:

| Pool | Holder | Purpose |
|---|---|---|
| **Smart account wallet** | Smart account address | USDC the account will spend in its UserOp inner calls (e.g., a token transfer or bridge burn) |
| **Paymaster gas vault** | Paymaster contract (`balances[user]`) | USDC deducted by the Paymaster to reimburse the bundler for gas |

A Paymaster with a healthy gas vault balance cannot help a smart account that has no USDC in its own wallet for the inner call — these are independent.

### Deployment constraints

#### Do not use `nonReentrant` on `validatePaymasterUserOp`

The EntryPoint v0.7 calls `validatePaymasterUserOp` during the *validation phase*, before execution. ERC-7562 (the companion storage restriction spec) forbids **global storage writes** during validation for unstaked Paymasters.

The common `ReentrancyGuard` pattern writes a `_locked` boolean — a global storage slot — at the start of the function. This violates ERC-7562, causing Pimlico (and compliant bundlers) to silently reject the UserOperation at simulation time. No on-chain error is produced.

```solidity
// ❌ Rejected by ERC-7562-compliant bundlers for unstaked Paymasters
function validatePaymasterUserOp(...) external nonReentrant onlyEntryPoint returns (...) {

// ✅ Correct — onlyEntryPoint is sufficient (EntryPoint never re-enters validation)
function validatePaymasterUserOp(...) external onlyEntryPoint returns (...) {
```

`onlyEntryPoint` provides the same protection: only the EntryPoint can call this function, and the EntryPoint's own reentrancy guard prevents nested calls.

If you need reentrancy protection on `postOp` (which runs during the execution phase), standard `nonReentrant` is safe there.

### ERC-7562 storage rules

ERC-7562 defines which storage a Paymaster may read and write during validation. Key rules for unstaked Paymasters:

- ✅ May read/write `balances[userOp.sender]` (slot associated with the sender)
- ✅ May read/write `locked[userOp.sender]` (slot associated with the sender)
- ❌ May NOT write any slot that is not keyed to the sender or the Paymaster itself
- ❌ May NOT write the Paymaster's own global state (e.g., a reentrancy lock, a global counter)

Staked Paymasters have looser restrictions but require a stake deposit at the EntryPoint.

### Reservation pattern for concurrent UserOps

When a user may submit multiple UserOps in parallel (common in bridge UX), a naive balance check in `validatePaymasterUserOp` can double-spend. Use a reservation pattern:

```solidity
mapping(address => uint256) public balances;
mapping(address => uint256) public locked; // reserved by in-flight UserOps

function validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32, uint256 maxCost)
external onlyEntryPoint returns (bytes memory context, uint256 validationData)
{
uint256 maxUsdcCost = (maxCost * gasRate) / 1e12; // adjust for decimal diff
address user = userOp.sender;
uint256 available = balances[user] - locked[user];
require(available >= maxUsdcCost, "Paymaster: insufficient balance");
locked[user] += maxUsdcCost;
return (abi.encode(user, maxUsdcCost), 0);
}

function postOp(PostOpMode, bytes calldata context, uint256 actualGasCost, uint256 actualUserOpFeePerGas)
external onlyEntryPoint
{
(address user, uint256 reserved) = abi.decode(context, (address, uint256));
locked[user] -= reserved;
uint256 actual = (actualGasCost / actualUserOpFeePerGas) * gasRate / 1e12;
balances[user] -= actual;
usdc.transfer(feeRecipient, actual);
emit GasSponsored(user, actual, balances[user]);
}
```

---

## USDC as gas on Arc

Arc's native token is USDC. This has two important decimal representations:

| Context | Decimals | Used for |
|---|---|---|
| Native gas accounting (Wei equivalent) | **18** | Gas price, `block.basefee`, `tx.gasprice`, `maxCost` in `validatePaymasterUserOp` |
| ERC-20 USDC interface | **6** | `balanceOf`, `transfer`, `approve`, `depositForBurn` |

When a Paymaster converts `maxCost` (18-decimal native) to USDC (6-decimal ERC-20), divide by `1e12`:

```solidity
uint256 usdcCost = nativeWeiCost / 1e12;
```

For example, 500,000 gas at 1 gwei (`1e9 wei`) base fee = `500,000 × 1e9 = 5e14 wei` native → `5e14 / 1e12 = 500` USDC micro-units = **0.0005 USDC**.

Typical bridge UserOps cost 300k–500k gas at current Arc Testnet base fees, amounting to roughly **0.5–2 USDC** in gas.

---

## Finality and UserOperation timing

Arc Testnet uses Malachite BFT consensus with **deterministic single-slot finality** — there are no reorgs. A block is final as soon as it is produced.

This means:

- `minFinalityThreshold` in CCTP V2 `depositForBurn` calls should be set to **`2000`** (the "finalized" threshold) when Arc is the source chain. Using the lower "safe" threshold (`1000`) also works but is redundant given deterministic finality.
- Circle's Iris attestation API takes ~1–3 minutes to attest Arc burns (API-side processing, not chain finality).
- UserOps submitted to Pimlico are typically included within 1–3 blocks (~1–3 seconds). There is no "safe" vs "finalized" distinction — once included, a UserOp is permanent.

---

## Worked example: gasless USDC bridge burn

This example shows a complete ERC-4337 UserOperation on Arc Testnet that approves and calls `depositForBurn` (Circle CCTP V2) — all without the user holding any native USDC for gas.

```typescript
import { createSmartAccountClient } from "permissionless";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { toSimpleSmartAccount } from "permissionless/accounts";
import { encodeFunctionData, parseUnits, http, createPublicClient } from "viem";

const ENTRY_POINT = "0x0000000071727De22E5E9d8BAf0edAc6f37da032";
const ARC_USDC = "0x3600000000000000000000000000000000000000";
const TOKEN_MESSENGER = "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA"; // CCTP V2
const PAYMASTER = "<your_paymaster_address>";
const SEPOLIA_DOMAIN = 0;

const publicClient = createPublicClient({
transport: http("https://rpc.testnet.arc.io"),
});

const bundlerClient = createPimlicoClient({
transport: http(`https://api.pimlico.io/v2/5042002/rpc?apikey=${process.env.PIMLICO_API_KEY}`),
entryPoint: { address: ENTRY_POINT, version: "0.7" },
});

const account = await toSimpleSmartAccount({
client: publicClient,
owner,
entryPoint: { address: ENTRY_POINT, version: "0.7" },
});

const smartAccountClient = createSmartAccountClient({
account,
chain: ARC_TESTNET,
bundlerTransport: http(`https://api.pimlico.io/v2/5042002/rpc?apikey=${process.env.PIMLICO_API_KEY}`),
middleware: {
gasPrice: async () => (await bundlerClient.getUserOperationGasPrice()).fast,
sponsorUserOperation: async ({ userOperation }) => {
// Your Paymaster's sponsorUserOperation hook
return paymasterClient.sponsorUserOperation({ userOperation });
},
},
});

const burnAmount = parseUnits("10", 6); // 10 USDC
const mintRecipient = ("0x" + "00".repeat(12) + recipientAddress.slice(2)).padEnd(66, "0");

// Batch: approve + depositForBurn in a single UserOp
const txHash = await smartAccountClient.sendTransaction({
calls: [
{
to: ARC_USDC,
data: encodeFunctionData({
abi: ["function approve(address spender, uint256 amount)"],
functionName: "approve",
args: [TOKEN_MESSENGER, burnAmount],
}),
},
{
to: TOKEN_MESSENGER,
data: encodeFunctionData({
abi: ["function depositForBurn(uint256,uint32,bytes32,address,bytes32,uint256,uint32)"],
functionName: "depositForBurn",
args: [
burnAmount,
SEPOLIA_DOMAIN,
mintRecipient,
ARC_USDC,
"0x" + "00".repeat(32), // destinationCaller = anyone
0n, // maxFee
2000, // minFinalityThreshold (Arc = finalized)
],
}),
},
],
});

console.log("Burn tx:", `https://testnet.arcscan.app/tx/${txHash}`);
```

---

## Debugging checklist

| Symptom | Most likely cause | Fix |
|---|---|---|
| Bundler rejects UserOp silently, no on-chain error | `nonReentrant` on `validatePaymasterUserOp` (ERC-7562) | Remove `nonReentrant`; keep `onlyEntryPoint` |
| Bundler returns `AA31 paymaster deposit too low` | No ETH deposited at EntryPoint for the Paymaster | Call `entryPoint.depositTo{value: 0.05 ether}(paymasterAddress)` |
| UserOp validation passes but inner call reverts | Smart account wallet has insufficient USDC for the inner call | Fund the smart account's own wallet separately from the Paymaster gas vault |
| USDC amount appears 10¹² times too large | Using 18-decimal native cost directly as 6-decimal ERC-20 amount | Divide native wei cost by `1e12` to get ERC-20 USDC units |
| Attestation never arrives after burn | Circle Iris API processing delay (not a chain issue) | Poll `https://iris-api-sandbox.circle.com` every 5s for up to 20 min |

---

## Reference

### Deployed addresses (Arc Testnet)

| Contract | Address |
|---|---|
| EntryPoint v0.7 | `0x0000000071727De22E5E9d8BAf0edAc6f37da032` |
| USDC (ERC-20) | `0x3600000000000000000000000000000000000000` |
| CCTP V2 TokenMessenger | `0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA` |
| CCTP V2 MessageTransmitter | `0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275` |

### Useful links

| Resource | URL |
|---|---|
| Arc Testnet RPC | https://rpc.testnet.arc.io |
| Arc Testnet Explorer | https://testnet.arcscan.app |
| Arc Developer Docs | https://docs.arc.network/arc/concepts/welcome-to-arc |
| ERC-4337 Spec | https://eips.ethereum.org/EIPS/eip-4337 |
| ERC-7562 Spec | https://eips.ethereum.org/EIPS/eip-7562 |
| eth-infinitism/account-abstraction | https://github.com/eth-infinitism/account-abstraction |
| permissionless.js | https://docs.pimlico.io/permissionless |
| Circle CCTP V2 Docs | https://developers.circle.com/stablecoins/cctp-getting-started |
| Circle Testnet USDC Faucet | https://faucet.circle.com |