Lightweight E2E Provider for Web3 DApps - A virtual EIP-1193 provider that enables deterministic, high-performance E2E testing.
This library provides a man-in-the-middle injected provider. Your DApp keeps using the EIP-1193 interface, while reads go to Anvil and writes are signed locally.
graph LR
DApp["DApp UI"]
CP1["E2E Provider"]
CP2["E2E Provider"]
SIGN["Sign logic / Impersonation"]
ANVIL["Anvil RPC"]
DApp -->|Read| CP1
CP1 -->|eth_call, eth_getBalance, ...| ANVIL
DApp -->|Write| CP2
CP2 -->|eth_sendTx, eth_sign, ...| SIGN
SIGN --> ANVIL
Read operations (eth_call, eth_getBalance, …) → forwarded to Anvil RPC
Write operations (eth_sendTransaction, eth_sign, …) → signed locally, then sent to Anvil
You get production-realistic chains with test-level control.
- Incredible Test Speed: No browser extension overhead, controlled RPC latency, fully virtualized wallet interactions
- Framework Agnostic: Works with Cypress, Playwright, Selenium, or any E2E testing tool
- Zero External Dependencies: Built entirely on viem types and native fetch
- Total Control: Simulate edge cases like RPC errors, specific error codes, delayed signatures, chain switching failures
- CI/CD Friendly: Runs effortlessly in headless browsers and Docker containers
Real-world performance comparison using identical test suites on the same Next.js boilerplate:
| Metric | Synpress | Walletless | Speedup |
|---|---|---|---|
| Real | 34.50s | 2.10s | 16x |
| User | 24.24s | 2.98s | 8x |
| Sys | 5.34s | 0.75s | 7x |
Test suite:
- Connect wallet via RainbowKit modal
- Connect + execute 1 ETH transfer
pnpm add @wonderland/walletlessimport { e2eConnector } from "@wonderland/walletless";
import { createConfig, http } from "wagmi";
import { mainnet } from "wagmi/chains";
const isE2E = process.env.CI === "true";
export const config = createConfig({
chains: [mainnet],
connectors: isE2E
? [e2eConnector()]
: [
/* real wallets */
],
transports: {
[mainnet.id]: isE2E ? http("http://localhost:8545") : http(),
},
});import { e2eConnector } from "@wonderland/walletless";
import { createConfig, http } from "wagmi";
import { arbitrum, mainnet } from "wagmi/chains";
export const config = createConfig({
chains: [mainnet, arbitrum],
connectors: [
e2eConnector({
chains: [mainnet, arbitrum],
rpcUrls: {
1: "http://localhost:8545",
42161: "http://localhost:8546",
},
account: "0xYourPrivateKey...",
debug: true,
}),
],
transports: {
[mainnet.id]: http("http://localhost:8545"),
[arbitrum.id]: http("http://localhost:8546"),
},
});import { createE2EProvider } from "@wonderland/walletless";
import { mainnet } from "viem/chains";
const provider = createE2EProvider();
// Use the provider directly
const accounts = await provider.request({ method: "eth_requestAccounts" });
const balance = await provider.request({
method: "eth_getBalance",
params: [accounts[0], "latest"],
});import {
ANVIL_ACCOUNTS,
createE2EProvider,
disconnect,
setChain,
setRejectSignature,
setRejectTransaction,
setSigningAccount,
} from "@wonderland/walletless";
// Switch by viem Account object (for custom accounts)
import { privateKeyToAccount } from "viem/accounts";
const provider = createE2EProvider();
// Switch signing account by index (0-9)
setSigningAccount(provider, 0); // First Anvil account
setSigningAccount(provider, 5); // Sixth Anvil account
// Switch by Anvil address (looks up matching private key)
setSigningAccount(provider, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8");
// Switch by raw private key
setSigningAccount(provider, "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d");
setSigningAccount(provider, privateKeyToAccount("0x..."));
// Access Anvil accounts directly
console.log(ANVIL_ACCOUNTS[0].address); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
console.log(ANVIL_ACCOUNTS[0].privateKey);
// Disconnect (emits disconnect event)
disconnect(provider);
// Reject signatures (throws 4001 "User Rejected Request" error)
setRejectSignature(provider, true);
// Reject transactions (throws 4001 "User Rejected Request" error)
setRejectTransaction(provider, true);import { createE2EProvider, setChain } from "@wonderland/walletless";
import { arbitrum, mainnet, optimism } from "viem/chains";
// Create provider with multiple chains and per-chain RPC URLs
const provider = createE2EProvider({
chains: [mainnet, arbitrum, optimism],
rpcUrls: {
1: "http://localhost:8545",
42161: "http://localhost:8546",
10: "http://localhost:8547",
},
});
// Get current chain (first in array is default)
const chainId = await provider.request({ method: "eth_chainId" });
console.log(chainId); // "0x1" (mainnet)
// Switch to Arbitrum - provider now uses arbitrum RPC URL
setChain(provider, arbitrum.id);
// Verify switch
const newChainId = await provider.request({ method: "eth_chainId" });
console.log(newChainId); // "0xa4b1" (Arbitrum)
// Switching to unsupported chain throws an error
try {
setChain(provider, 137); // Polygon - not in chains array
} catch (e) {
console.log(e.message); // "Chain 137 is not supported. Supported chains: 1, 42161, 10"
}Chain switching validates the target chain, updates the RPC URL, recreates the wallet client, updates state, and emits chainChanged.
When you need to switch accounts or chains during tests while using the wagmi connector, pass your provider to the connector:
import {
createE2EProvider,
e2eConnector,
setChain,
setSigningAccount,
} from "@wonderland/walletless";
import { createConfig, http } from "wagmi";
import { arbitrum, mainnet } from "wagmi/chains";
// Create provider externally so you can control it
const provider = createE2EProvider({
chains: [mainnet, arbitrum],
rpcUrls: {
1: "http://localhost:8545",
42161: "http://localhost:8546",
},
});
export const config = createConfig({
chains: [mainnet, arbitrum],
connectors: [e2eConnector({ provider })],
transports: {
[mainnet.id]: http("http://localhost:8545"),
[arbitrum.id]: http("http://localhost:8546"),
},
});
// In your tests, switch accounts - wagmi will be notified automatically
setSigningAccount(provider, 3); // Switch to 4th Anvil account
// Switch chains during tests (also switches RPC endpoint)
setChain(provider, arbitrum.id); // Switch to ArbitrumNote: If your wagmi config is created inside a React component (common with RainbowKit or dynamic chain setups), you'll need to use
useRefto maintain a stable provider reference. Otherwise, each re-render creates a new provider instance, and calls tosetSigningAccount()won't affect the provider that wagmi is actually using.
// tests/swap.spec.ts
import { expect, test } from "@playwright/test";
test("should swap tokens", async ({ page }) => {
await page.goto("/swap");
await page.getByTestId("token-input").fill("1.0");
await page.getByTestId("swap-button").click();
// Transaction is automatically signed and executed
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});To instantiate an e2eWallet compatible with rainbowkit, you can do the following :
import { Wallet, WalletDetailsParams } from "@rainbow-me/rainbowkit";
import { e2eConnector } from "@wonderland/walletless";
export const e2eWallet = (): Wallet => ({
id: "e2e",
name: "E2E Test Wallet",
iconUrl:
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%234F46E5" width="100" height="100" rx="20"/><text x="50" y="65" font-size="50" text-anchor="middle" fill="white">E2E</text></svg>',
iconBackground: "#4F46E5",
installed: true,
createConnector: (walletDetails: WalletDetailsParams) => {
// Create the E2E connector (this returns a CreateConnectorFn)
const connector = e2eConnector({
rpcUrls: {
[sepolia.id]: "http://localhost:8545",
[mainnet.id]: "http://localhost:8546",
},
chains: [sepolia, mainnet],
});
// Wrap it in the format expected by RainbowKit
return createConnector((config) => ({
...connector(config),
...walletDetails,
}));
},
});It will create a custom rainbowkit wallet using walletless as connector. Then your wagmi config would look like this:
const connectors = connectorsForWallets(
[
{
groupName: "Recommended",
wallets: isE2E ? [e2eWallet] : [injectedWallet],
},
],
{
appName: "Web3 React boilerplate",
projectId: PROJECT_ID,
},
);
export const config = createConfig({
chains: [sepolia, mainnet],
transports: {
[sepolia.id]: isE2E ? http("http://localhost:8545") : http(),
[mainnet.id]: isE2E ? http("http://localhost:8546") : http(),
},
connectors,
// ...rest of your wagmi config...
});The connector accepts either a pre-constructed provider or configuration options:
Option 1: Pass a provider (recommended when you need setSigningAccount())
| Parameter | Type | Description |
|---|---|---|
provider |
E2EProvider |
Pre-constructed provider for external control |
Option 2: Let the connector create the provider internally
| Parameter | Type | Default | Description |
|---|---|---|---|
chains |
Chain[] |
[mainnet] |
Supported chains (first chain is default) |
rpcUrls |
Record<number, string> |
{} |
Per-chain RPC URLs mapping chainId to URL. Falls back to http://localhost:8545. |
account |
Hex | Account |
Anvil's first test private key | Private key or viem Account for signing |
debug |
boolean |
false |
Enable debug logging |
All parameters are optional with sensible Anvil defaults:
| Parameter | Type | Default | Description |
|---|---|---|---|
chains |
Chain[] |
[mainnet] |
Supported chains (first chain is default) |
rpcUrls |
Record<number, string> |
{} |
Per-chain RPC URLs mapping chainId to URL. Falls back to http://localhost:8545. |
account |
Hex | Account |
Anvil's first test private key | Private key or viem Account for signing |
debug |
boolean |
false |
Enable debug logging |
| Input Type | Example | Description |
|---|---|---|
| Index (0-9) | setSigningAccount(provider, 0) |
Use Anvil's nth default account |
| Address | setSigningAccount(provider, "0x70997...") |
Look up matching Anvil account |
| Private Key | setSigningAccount(provider, "0x59c69...") |
Use any private key (66 chars) |
| viem Account | setSigningAccount(provider, viemAccount) |
Use a viem Account object directly |
This library is designed to work by default with Anvil, a fast local Ethereum development node from Foundry.
# Install Foundry (includes anvil, forge, cast, chisel)
curl -L https://foundry.paradigm.xyz | bash
# Run foundryup to install the latest version
foundryup
# Verify installation
anvil --version# Start Anvil with defaults (port 8545, 10 accounts, 10000 ETH each, chainId 31337)
anvil# Fork mainnet
anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
# Fork at a specific block
anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY --fork-block-number 19000000
# Fork Arbitrum
anvil --fork-url https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY --chain-id 42161# Single fork on custom port
anvil --port 8546
# Multiple forks (run in separate terminals)
anvil --port 8545 --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY # Mainnet
anvil --port 8546 --fork-url https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY # Arbitrum
anvil --port 8547 --fork-url https://opt-mainnet.g.alchemy.com/v2/YOUR_KEY # Optimism# Set chain ID (useful when not forking)
anvil --chain-id 1337
# Set block time (auto-mining interval in seconds)
anvil --block-time 12
# Disable auto-mining (mine on demand)
anvil --no-mining# Generate 20 accounts with 50000 ETH each
anvil --accounts 20 --balance 50000
# Use custom mnemonic
anvil --mnemonic "test test test test test test test test test test test junk"For the full reference, see the Anvil documentation.
# Install dependencies
pnpm install
# Build
pnpm build
# Run tests
pnpm test
# Lint
pnpm lint
# Format
pnpm format:fixMIT License - see LICENSE for details.
Contributions are welcome! Please read our contributing guidelines before submitting a PR.