Test smart contract deployments and interactions with OnchainTestKit
Install Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
Create Foundry project
mkdir smart-contracts && cd smart-contracts
forge init
Configure environment
.env
file:# Path to your smart contracts
E2E_CONTRACT_PROJECT_ROOT=../smart-contracts
your-project/
├── frontend/ # Your dApp
│ └── e2e/ # E2E tests
└── smart-contracts/ # Foundry project
├── foundry.toml
├── src/ # Contract source files
├── test/ # Contract unit tests
└── script/ # Deployment scripts
smart-contracts/foundry.toml
:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.20"
optimizer = true
optimizer_runs = 200
[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
mainnet = "${MAINNET_RPC_URL}"
// smart-contracts/src/SimpleToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract SimpleToken is ERC20, Ownable {
uint256 public constant MAX_SUPPLY = 1000000 * 10**18;
constructor() ERC20("Simple Token", "STK") Ownable(msg.sender) {
_mint(msg.sender, MAX_SUPPLY / 10);
}
function mint(address to, uint256 amount) public onlyOwner {
require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
_mint(to, amount);
}
}
cd smart-contracts
# Install dependencies
forge install foundry-rs/forge-std OpenZeppelin/openzeppelin-contracts
# Build contracts
forge build
smartContractManager
fixture is automatically available when E2E_CONTRACT_PROJECT_ROOT
is set:
test("deploy and test contract", async ({
page,
metamask,
smartContractManager,
node
}) => {
// Contract deployment and testing
})
test("should deploy SimpleToken contract using CREATE2", async ({
page,
smartContractManager,
node,
}) => {
if (!smartContractManager || !node) {
throw new Error("SmartContractManager or node not initialized")
}
// Deploy the SimpleToken contract
const salt =
"0x0000000000000000000000000000000000000000000000000000000000000001" as Hex
const deployer = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" as Address // Anvil's first account
const tokenAddress = await smartContractManager.deployContract({
name: "SimpleToken",
args: [],
salt,
deployer,
})
// Verify the contract was deployed
expect(tokenAddress).toBeDefined()
expect(tokenAddress).toMatch(/^0x[a-fA-F0-9]{40}$/)
// Create a public client to verify the deployment
const publicClient = createPublicClient({
chain: localhost,
transport: http(`http://localhost:${node.port}`),
})
// Check the contract code exists
const code = await publicClient.getBytecode({ address: tokenAddress })
expect(code).toBeDefined()
expect(code).not.toBe("0x")
console.log(`SimpleToken deployed at: ${tokenAddress}`)
})
test("should deploy at deterministic address with same salt", async ({
page,
smartContractManager,
node,
}) => {
if (!smartContractManager || !node) {
throw new Error("SmartContractManager or node not initialized")
}
const salt =
"0x0000000000000000000000000000000000000000000000000000000000000002" as Hex
const deployer = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" as Address
// Take a snapshot before deployment
const snapshotId = await node.snapshot()
// Deploy first time
const firstAddress = await smartContractManager.deployContract({
name: "SimpleToken",
args: [],
salt,
deployer,
})
// Revert to snapshot to simulate a fresh chain
await node.revert(snapshotId)
// Deploy again with same salt
const secondAddress = await smartContractManager.deployContract({
name: "SimpleToken",
args: [],
salt,
deployer,
})
// Addresses should be the same due to CREATE2
expect(firstAddress).toBe(secondAddress)
})
test("should interact with deployed contract", async ({
page,
smartContractManager,
node,
}) => {
if (!smartContractManager || !node) {
throw new Error("SmartContractManager or node not initialized")
}
// Deploy the contract
const salt =
"0x0000000000000000000000000000000000000000000000000000000000000003" as Hex
const deployer = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" as Address
const tokenAddress = await smartContractManager.deployContract({
name: "SimpleToken",
args: [],
salt,
deployer,
})
// Create a public client to interact with the contract
const publicClient = createPublicClient({
chain: localhost,
transport: http(`http://localhost:${node.port}`),
})
// Check the owner of the contract
const owner = await publicClient.readContract({
address: tokenAddress,
abi: [
{
name: "owner",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "address" }],
},
],
functionName: "owner",
}) as Address
console.log(`Contract owner: ${owner}`)
// Check the owner's balance (should have 10% of max supply from constructor)
const ownerBalance = await publicClient.readContract({
address: tokenAddress,
abi: [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
],
functionName: "balanceOf",
args: [owner],
})
expect(ownerBalance).toBe(BigInt("100000000000000000000000")) // 100k tokens (10% of 1M)
// Verify contract metadata
const [name, symbol, totalSupply] = await Promise.all([
publicClient.readContract({
address: tokenAddress,
abi: [
{
name: "name",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "string" }],
},
],
functionName: "name",
}),
publicClient.readContract({
address: tokenAddress,
abi: [
{
name: "symbol",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "string" }],
},
],
functionName: "symbol",
}),
publicClient.readContract({
address: tokenAddress,
abi: [
{
name: "totalSupply",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "uint256" }],
},
],
functionName: "totalSupply",
}),
])
expect(name).toBe("Simple Token")
expect(symbol).toBe("STK")
expect(totalSupply).toBe(BigInt("100000000000000000000000")) // 100k tokens initially minted
})
test("should connect wallet and interact with deployed contract", async ({
page,
metamask,
smartContractManager,
node,
}) => {
if (!metamask) {
throw new Error("MetaMask is not defined")
}
// Deploy contract first
const salt =
"0x0000000000000000000000000000000000000000000000000000000000000006" as Hex
const deployer = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" as Address
const tokenAddress = await smartContractManager.deployContract({
name: "SimpleToken",
args: [],
salt,
deployer,
})
// Connect wallet to the app
await page.getByTestId("ockConnectButton").first().click()
await page
.getByTestId("ockModalOverlay")
.first()
.getByRole("button", { name: "MetaMask" })
.click()
// Handle MetaMask connection
await metamask.handleAction(BaseActionType.CONNECT_TO_DAPP)
// Verify wallet is connected
await page.waitForSelector("text=/0x[a-fA-F0-9]{4}.*[a-fA-F0-9]{4}/", {
timeout: 10000,
})
// Now the user could interact with the deployed contract through the UI
console.log(`User can now interact with token at: ${tokenAddress}`)
})
test("should perform batch operations", async ({
page,
smartContractManager,
node,
}) => {
if (!smartContractManager || !node) {
throw new Error("SmartContractManager or node not initialized")
}
const deployer = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" as Address
// Deploy multiple contracts and execute multiple calls
await smartContractManager.setContractState(
{
deployments: [
{
name: "SimpleToken",
args: [],
salt: "0x0000000000000000000000000000000000000000000000000000000000000004" as Hex,
deployer,
},
],
calls: [],
},
node,
)
// Get the deployed contract address
const publicClient = createPublicClient({
chain: localhost,
transport: http(`http://localhost:${node.port}`),
})
// Verify deployment by checking for events or code
const logs = await publicClient.getLogs({
fromBlock: "latest",
toBlock: "latest",
})
expect(logs.length).toBeGreaterThan(0)
})
test("should test contract state persistence across snapshots", async ({
page,
smartContractManager,
node,
}) => {
if (!smartContractManager || !node) {
throw new Error("SmartContractManager or node not initialized")
}
// Deploy contract
const salt =
"0x0000000000000000000000000000000000000000000000000000000000000007" as Hex
const deployer = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" as Address
const tokenAddress = await smartContractManager.deployContract({
name: "SimpleToken",
args: [],
salt,
deployer,
})
// Create public client
const publicClient = createPublicClient({
chain: localhost,
transport: http(`http://localhost:${node.port}`),
})
const owner = await publicClient.readContract({
address: tokenAddress,
abi: [
{
name: "owner",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "address" }],
},
],
functionName: "owner",
}) as Address
// Take a snapshot
const snapshotId = await node.snapshot()
// Check initial state
const initialOwnerBalance = await publicClient.readContract({
address: tokenAddress,
abi: [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
],
functionName: "balanceOf",
args: [owner],
})
expect(initialOwnerBalance).toBe(BigInt("100000000000000000000000")) // 100k tokens
// Deploy another contract to change state
const salt2 =
"0x0000000000000000000000000000000000000000000000000000000000000008" as Hex
const secondTokenAddress = await smartContractManager.deployContract({
name: "SimpleToken",
args: [],
salt: salt2,
deployer,
})
// Verify the second contract was deployed
const secondContractCode = await publicClient.getBytecode({
address: secondTokenAddress,
})
expect(secondContractCode).toBeDefined()
expect(secondContractCode).not.toBe("0x")
// Revert to snapshot
await node.revert(snapshotId)
// Check that the second contract no longer exists
const codeAfterRevert = await publicClient.getBytecode({
address: secondTokenAddress,
})
expect(codeAfterRevert).toBeUndefined()
// Verify the first contract still exists and has the same state
const firstContractCodeAfterRevert = await publicClient.getBytecode({
address: tokenAddress,
})
expect(firstContractCodeAfterRevert).toBeDefined()
expect(firstContractCodeAfterRevert).not.toBe("0x")
const ownerBalanceAfterRevert = await publicClient.readContract({
address: tokenAddress,
abi: [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
],
functionName: "balanceOf",
args: [owner],
})
expect(ownerBalanceAfterRevert).toBe(initialOwnerBalance)
})
Use deterministic addresses
const SALTS = {
TOKEN: "0x01" as Hex,
STAKING: "0x02" as Hex,
GOVERNANCE: "0x03" as Hex,
}
Clean contract state
test.beforeEach(async ({ smartContractManager, node }) => {
// Fresh deployment for each test
await smartContractManager.deployContract({
name: "SimpleToken",
args: [],
salt: `0x${Date.now().toString(16)}` as Hex,
deployer: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" as Address,
})
})
Test both success and failure
test.describe("Token minting", () => {
test("owner can mint", async ({ /* ... */ }) => {
// Test successful mint
})
test("non-owner cannot mint", async ({ /* ... */ }) => {
// Test revert
})
})