Prool | Pimlico Docs
Skip to content

Prool

This guide introduces how to setup and run tests in a mock environment using Prool.

Overview

Prool is a library by Wevm that lets you programmatically interact with Ethereum server instances like Nodes, Bundlers, and Paymasters.

We will be using Vitest as our testing framework and we will be testing against a fork of Base Mainnet.

Steps

Setup

Install the following dependencies:

npm install permissionless viem prool @pimlico/alto @pimlico/mock-paymaster get-port

Installing the following dev dependencies:

npm install --save-dev vitest

In order to run tests, add the following section to your package.json file:

package.json
{
    "scripts": {
        "test": "vitest"
    }
}

Create test helpers

Prool lets us run multiple test cases at once by creating separate isolated environments for each, with their own Bundler, Anvil, and Paymaster.

To make this easier, we’ll create a helper called testWithRpc that finds free ports and spins up the isolated test environment.

testWithRpc.ts
import getPort from "get-port";
import { test } from "vitest";
import { getInstances } from "./getInstances";
import { base } from "viem/chains";
 
let ports: number[] = [];
 
export const testWithRpc = test.extend<{
	rpc: {
		anvilRpc: string;
		altoRpc: string;
		paymasterRpc: string;
	};
}>({
	rpc: async ({}, use) => {
		const altoPort = await getPort({
			exclude: ports,
		});
		ports.push(altoPort);
		const paymasterPort = await getPort({
			exclude: ports,
		});
		ports.push(paymasterPort);
		const anvilPort = await getPort({
			exclude: ports,
		});
		ports.push(anvilPort);
 
		const anvilRpc = `http://localhost:${anvilPort}`;
		const altoRpc = `http://localhost:${altoPort}`;
		const paymasterRpc = `http://localhost:${paymasterPort}`;
 
		const forkUrl = process.env.FORK_RPC_URL || base.rpcUrls.default.http[0];
 
		const instances = await getInstances({
			forkUrl,
			anvilPort,
			altoPort,
			paymasterPort,
		});
 
		await use({
			anvilRpc,
			altoRpc,
			paymasterRpc,
		});
 
		await Promise.all([...instances.map((instance) => instance.stop())]);
		const usedPorts = [altoPort, anvilPort, paymasterPort];
		ports = ports.filter((port) => !usedPorts.includes(port));
	},
});

Writing test cases

In this example, we will mock a test by sending a simple sponsored userOperation.

We use the testWithRpc helper to setup our test environment. The testWithRpc method returns the rpc endpoints that we should use when creating the Anvil, Alto, Paymaster clients.

basic.test.ts
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import {
	createPublicClient,
	createTestClient,
	http,
	parseEther,
	zeroAddress,
} from "viem";
import { assert, describe } from "vitest";
import { toSimpleSmartAccount } from "permissionless/accounts";
import { createSmartAccountClient } from "permissionless";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { foundry } from "viem/chains";
import { testWithRpc } from "./testWithRpc";
 
describe("Basic test cases", () => {
    testWithRpc("Can send a sponsored userOperation", async ({ rpc }) => {
		const { anvilRpc, altoRpc, paymasterRpc } = rpc;
 
		// Setup clients.
		const publicClient = createPublicClient({
			chain: foundry,
			transport: http(anvilRpc),
		});
 
		const pimlicoClient = createPimlicoClient({
			chain: foundry,
			transport: http(paymasterRpc),
		});
 
		const account = await toSimpleSmartAccount({
			client: publicClient,
			owner: privateKeyToAccount(generatePrivateKey()),
		});
 
		const smartAccountClient = createSmartAccountClient({
			account,
			bundlerTransport: http(altoRpc),
			paymaster: pimlicoClient,
			userOperation: {
				estimateFeesPerGas: async () =>
					(await pimlicoClient.getUserOperationGasPrice()).fast,
			},
		});
 
		// Send userOperation and wait for receipt.
		const userOpHash = await smartAccountClient.sendUserOperation({
			calls: [
				{
					to: zeroAddress,
					value: 0n,
					data: "0x",
				},
			],
		});
 
		const receipt = await smartAccountClient.waitForUserOperationReceipt({
			hash: userOpHash,
		});
 
		// UserOperation should be included successfully.
		expect(receipt.success).toBe(true);
	});
});

Testing with ERC-20 paymaster

We can also test the ERC-20 paymaster flow. The @pimlico/mock-paymaster package exposes two helper functions/variables:

  • sudoMintTokens: Helper function to mint tokens to a given address.
  • erc20Address: Helper constant that returns the address of the supported ERC-20 token.
erc20.test.ts
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import {
	createPublicClient,
	erc20Abi,
	getContract,
	http,
	parseEther,
	zeroAddress,
} from "viem";
import { expect, describe } from "vitest";
import { toSimpleSmartAccount } from "permissionless/accounts";
import { testWithRpc } from "../utils/testWithRpc";
import { createSmartAccountClient } from "permissionless";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { prepareUserOperationForErc20Paymaster } from "permissionless/experimental/pimlico";
import { foundry } from "viem/chains";
import { erc20Address, sudoMintTokens } from "@pimlico/mock-paymaster";
 
describe("Basic test cases", () => {
	testWithRpc("Can send a sponsored ERC20 userOperation", async ({ rpc }) => {
		const { anvilRpc, altoRpc, paymasterRpc } = rpc;
 
		// Setup clients.
		const publicClient = createPublicClient({
			chain: foundry,
			transport: http(anvilRpc),
		});
 
		const pimlicoClient = createPimlicoClient({
			chain: foundry,
			transport: http(paymasterRpc),
		});
 
		const account = await toSimpleSmartAccount({
			client: publicClient,
			owner: privateKeyToAccount(generatePrivateKey()),
		});
 
		// Creating smart account client based on this page
		// http://docs.pimlico.io/guides/how-to/erc20-paymaster/how-to/use-paymaster
		const smartAccountClient = createSmartAccountClient({
			chain: foundry,
			account,
			bundlerTransport: http(altoRpc),
			paymaster: pimlicoClient,
			userOperation: {
				estimateFeesPerGas: async () =>
					(await pimlicoClient.getUserOperationGasPrice()).fast,
				prepareUserOperation:
					prepareUserOperationForErc20Paymaster(pimlicoClient),
			},
		});
 
		// Fund the SmartAccount with ERC-20 tokens.
		await sudoMintTokens({
			amount: parseEther("1"),
			to: account.address,
			anvilRpc,
		});
 
		// Check smartAccount balance before and after sending transaction to confirm balance decreased.
		const erc20 = getContract({
			address: erc20Address,
			abi: erc20Abi,
			client: publicClient,
		});
 
		const balanceBefore = await erc20.read.balanceOf([account.address]);
 
		// Send a transaction and wait for receipt.
		const userOpHash = await smartAccountClient.sendUserOperation({
			calls: [
				{
					to: zeroAddress,
					value: 0n,
					data: "0x",
				},
			],
			paymasterContext: {
				token: erc20Address,
			},
		});
 
		const receipt = await smartAccountClient.waitForUserOperationReceipt({
			hash: userOpHash,
		});
 
		const balanceAfter = await erc20.read.balanceOf([account.address]);
 
		// UserOperation should be included successfully.
		expect(receipt.success).toBe(true);
		expect(balanceAfter).toBeLessThan(balanceBefore);
	});
});

Combined code

If you want to see the complete code that combines all of the previous steps, we uploaded it to a separate repository. If you're looking to run it, remember to replace the API key with your own!

Tips and tricks

  • The testWithRpc helper creates isolated instances for a specific test case meaning vitest will run multiple tests at once.
  • The setup outlined in this guide is the same one used in permissionless.js's E2E tests. for more detailed examples, refer to the test cases in permissionless.js. For example this test case for the pm_sponsorUserOperation endpoint.
  • To debug the Alto/Anvil/Paymaster instance, you can print logs to stdout by calling the .on function. For an example of this, please refere to this snippet.