Skip to content

evm_revert to an older snapshot fails after inner snapshots have been reverted #552

@KitHat

Description

@KitHat

Component

Anvil

Have you ensured that all of these are up to date?

  • Foundry
  • Foundryup

What version of Foundry are you on?

used through hardhat

What version of Foundryup are you on?

No response

What command(s) is the bug in?

No response

Operating System

macOS (Apple Silicon)

Describe the bug

When a test takes a "root" snapshot, then takes and reverts multiple inner (nested) snapshots, a subsequent evm_revert back to the original root snapshot fails. This breaks the afterEach cleanup pattern (and by extension loadFixture) that many Hardhat test suites rely on for test isolation.

The same test passes on the standard Hardhat built-in network.

Steps to reproduce

Environment

  • Hardhat plugin: @parity/hardhat-polkadot ^0.2.7
  • Hardhat: with @nomicfoundation/hardhat-toolbox ^5.0.0
  • Solidity: 0.8.28
  • Anvil-Polkadot: eacd434
Testcase
import { expect } from "chai";
import hre, { ethers } from "hardhat";

// Low-level JSON-RPC helpers — Hardhat wraps these behind
// `network.provider.send`, which works identically on both the built-in
// network and an external JSON-RPC endpoint.

async function snapshot(): Promise<string> {
    return await hre.network.provider.send("evm_snapshot", []);
}

async function revert(snapshotId: string): Promise<boolean> {
    return await hre.network.provider.send("evm_revert", [snapshotId]);
}

describe("evm_snapshot / evm_revert — contract storage", function () {
    // ------------------------------------------------------------------ //
    //  Shared setup — deploy once, snapshot/revert per-test
    // ------------------------------------------------------------------ //

    let contract: any; // SimpleOwnable instance
    let deployer: any;
    let other: any;

    let deploySnapshotId: string;

    before(async function () {
        [deployer, other] = await ethers.getSigners();

        const Factory = await ethers.getContractFactory("SimpleOwnable", deployer);
        contract = await Factory.deploy();
        await contract.waitForDeployment();

        // Snapshot the clean post-deploy state.
        deploySnapshotId = await snapshot();
    });

    afterEach(async function () {
        // Revert to post-deploy state after every test, then re-snapshot
        // (mirrors loadFixture semantics: snapshot is consumed on revert).
        const ok = await revert(deploySnapshotId);
        expect(ok, "evm_revert should return true").to.equal(true);
        deploySnapshotId = await snapshot();
    });

    // ------------------------------------------------------------------ //
    //  Multiple snapshot/revert cycles (nested snapshots)
    // ------------------------------------------------------------------ //

    it("should handle nested snapshot/revert correctly", async function () {
        expect(await contract.counter()).to.equal(0n);

        // Snapshot S1 at counter=0
        const s1 = await snapshot();

        await contract.connect(deployer).increment();
        expect(await contract.counter()).to.equal(1n);

        // Snapshot S2 at counter=1
        const s2 = await snapshot();

        await contract.connect(deployer).increment();
        await contract.connect(deployer).increment();
        expect(await contract.counter()).to.equal(3n);

        // Revert to S2 → counter should be 1
        expect(await revert(s2)).to.equal(true);
        expect(await contract.counter()).to.equal(
            1n,
            "counter should be 1 after revert to S2"
        );

        // Revert to S1 → counter should be 0
        expect(await revert(s1)).to.equal(true);
        expect(await contract.counter()).to.equal(
            0n,
            "counter should be 0 after revert to S1"
        );
    });
});
Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// Minimal ownable contract to test evm_snapshot / evm_revert storage restoration.
/// Deliberately simple — no OpenZeppelin dependency — so the test is self-contained.
contract SimpleOwnable {
    address public owner;
    uint256 public counter;
    mapping(address => uint256) public balances;

    error Unauthorized();

    event OwnerChanged(address indexed oldOwner, address indexed newOwner);
    event CounterUpdated(uint256 newValue);

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        if (msg.sender != owner) revert Unauthorized();
        _;
    }

    function transferOwnership(address newOwner) external onlyOwner {
        address old = owner;
        owner = newOwner;
        emit OwnerChanged(old, newOwner);
    }

    function renounceOwnership() external onlyOwner {
        address old = owner;
        owner = address(0);
        emit OwnerChanged(old, address(0));
    }

    function increment() external {
        counter += 1;
        emit CounterUpdated(counter);
    }

    function setCounter(uint256 value) external onlyOwner {
        counter = value;
        emit CounterUpdated(value);
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions