Debugging and testing Solidity: setting up Foundry, Hardhat, Anvil

Pre-release checks drag on, and some failures surface only with users. We set up a smart contract testing environment turnkey: network isolation, reproducible scenarios, clear reports for the team. Release verification speeds up significantly, and the class of errors reaching mainnet narrows to isolated cases.

Blockchain Development Services

Frequently Asked Questions

Latest works

  • Development of a web application for FEEDME
    Development of a web application for FEEDME
    1376
  • Development of an online store for the company FURNORO
    Development of an online store for the company FURNORO
    1350
  • B2B Advance company logo design
    B2B Advance company logo design
    798
  • Development of a web application for Enviok
    Development of a web application for Enviok
    1088
  • AIDER company logo development
    AIDER company logo development
    1034
  • CRM development for Chasseurs
    CRM development for Chasseurs
    1130

Why the smart contract test environment breaks before release

A local run is green, but the very first transaction on deployment fails with revert.

The cause is almost never the contract logic, but a mismatch of environments: the contract test environment relies on a pinned network snapshot that has diverged from the production state — different dependency addresses, balances, granted roles.

The second layer is time and call order. In tests, the timestamp is moved manually to reach the desired logic branch, but in production it is real — the transaction falls into a window where the condition is not yet met.

Nonce and the multisig queue add transaction failures on deployment where locally the step was simply run in a different order.

This also leads to flaky tests: some of them call external nodes, hit request limits and connection drops, while an unpinned network snapshot produces different results from run to run.

Add manual steps — environment variables, deployer keys, granting permissions via the console: half of them are not reproduced in production, and the missed step can only be found by comparing environments.

We break down how this is addressed: with a reproducible contract test environment, pinned network snapshots, isolated external dependencies, and moving manual steps into deployment scripts.

The goal is not to have more tests, but for the run result to match behavior on the production network — then a failure on deployment becomes a rare event, not the norm.

What changes after implementation: test speed and failure classes

Before a dedicated testing loop, runs took place on a shared test network: account and storage state changed from run to run, and some failures were not reproducible on the developer's machine. Each such case meant an hour of manual investigation and a blind re-run.

A local emulator node with a fixed state snapshot removes this variable. The run starts from the same block and the same dataset, so a failure is reproduced against the snapshot — the share of reproducible failures approaches all cases rather than half.

The combination of build cache and parallel execution of independent tests in Foundry delivers speed.

A suite that previously took tens of minutes and ran once a day now fits into minutes and runs on every change — regressions are caught before merging into the main branch, not after rollout.

A separate class of failures is closed by a single environment description: dependency versions, initial balances, time step, and role permissions are defined by configuration and stored in the repository alongside the contract. The “works locally, not on the staging environment” discrepancy stops being a separate story.

Release preparation is accelerated by testing against the same snapshot: before rollout there is one run with a coverage report and a list of changed contracts.

A failure stops the release, and reverting to the previous snapshot brings the staging environment to a known state without manual data edits.

Run options: local network, mainnet fork, shared staging environment

The run environment determines what can be caught before deployment to the network, so the staging mode is chosen for the task, not out of habit. A local network runs on a single machine: state resets to the beginning, and the run is reproduced exactly.

A mainnet fork reads the state of external contracts at a pinned block height — this is how integrations are tested without deploying the environment manually.

The build and debugging tooling is set up for the chosen mode: this determines how a failed run is retried and how complete the path coverage report is.

Mode When it fits Limitations
Local network Running logic, unit tests, debugging an individual contract No external contracts; in-memory state does not survive restart
Mainnet fork Integrations with external protocols on real data Depends on an external node; block height must be pinned, otherwise runs diverge
Shared environment for teams Acceptance, demonstration, parallel work of several developers and contractors Requires an access and state reset policy: one participant's mistake breaks the other participants' runs

Environment isolation is what distinguishes the modes in practice. A shared environment for teams lives for a long time, so it relies on a policy: a separate branch per task, a state snapshot before acceptance, automatic return to a clean state on a schedule. Without this, state “drifts” between runs, and a failing test stops proving anything.

How the mode is chosen

On Foundry, we build a fast local loop and fork runs, while the shared environment is reserved for long-running scenarios and client demos. In practice, the modes are combined: local network on every change, mainnet fork before merging, staging environment at acceptance.

How implementation proceeds: from code audit to staging environment handover

We structure the work order so that the smart contract test environment appears before the first production transaction, not after an incident.

First we review what already exists in the project, then design the environments, and only then write tests — otherwise the tests lock in the current behavior along with its bugs.

  1. Environment audit. We check compiler and dependency versions, look at where keys and network addresses are stored, which tests already exist and which logic branches are not covered. We separately note steps that are not reproducible on a clean machine.
  2. Staging environment design. We define the set of environments: a local node for fast runs, an isolated network for integration, a mainnet fork — on the real state of contracts. Each has its own variables, keys, and access boundaries.
  3. Environment setup. We consolidate the Foundry and Hardhat configuration into profiles with a deterministic state snapshot: any run starts from the same point, and a failure is reproduced.
  4. Connecting tests. Unit tests for invariants, fuzzing of boundary values, checks for roles and repeated calls. We integrate the run into the build loop: without green tests, the code does not move forward.
  5. Testing on real scenarios. We reproduce production sequences on the fork — issuance, transfer, state migration, external call failures — and compare gas usage with the expected value.
  6. Run policy. We define which tests are mandatory before release, who runs them, and how rollback is formalized.
  7. Staging environment handover. We hand over the repository with configuration, environment descriptions, and access — the team maintains the environment itself.

What we hand over: configurations, designs, and policies

We deliver not a folder with tests, but a reproducible staging environment: it deploys with one command and produces the same result locally, in the shared environment, and in the continuous integration pipeline.

  • Staging environment configurations — Foundry and Hardhat profiles, local node settings: block time, gas limits, initial balances, pinning the mainnet fork to a specific block.
  • Environment designs — a map of environments: where contracts live, which external services are replaced with mocks, where real addresses are connected, how the environments are isolated from each other.
  • Access policies — roles for deployment, proxy upgrades, and protocol parameter changes; keys are stored in a secure vault and never enter the repository.
  • Test data sets — states for scenarios: liquidity pool, user positions, boundary amounts, addresses with zero and maximum balance.
  • Staging environment documentation — startup procedure, coverage map: which paths are covered by unit tests, which by fork scenarios, which invariants are checked separately.
  • Handover policies — how to update tests when contracts change, who adds new checks, at which stage the run blocks the build.

Everything is stored in the repository alongside the code and versioned with it. A new engineer joins the project without a verbal walkthrough of “how we do things here.”

Case study: a staging environment for a settlement protocol — metrics before and after

A settlement protocol with several interconnected contracts was tested on a copy of the mainnet, but scenarios were assembled manually. A full run took about two hours, some runs were cancelled, and some failures surfaced only after deployment — on real balances. When the staging environment was built on Foundry, we recorded metrics before and after.

What got in the way before

The environment was brought up manually: address counters, roles, limits, initial balances. Out of ten failures, three were reproducible — the rest depended on the accumulated state of the copy, and time went into debugging the scenario rather than fixing the contract.

Deployment consisted of eleven manual steps: migrations, permissions, parameters, address verification. Once a step with role granting was missed — settlements stopped until manual intervention.

What happened after

The staging environment brings up the same state with a repeatable script: the set of accounts, limits, and balances matches from run to run.

The run was reduced from two hours to seven minutes, failure reproducibility grew from three to ten out of ten cases, and deployment was left with one step — confirmation. Regression is caught before changes are merged, not after release.

Metric Before After
Full test run ~2 hours ~7 minutes
Reproducible failures out of ten 3 10
Manual steps in deployment 11 1
Coverage of settlement scenarios selective all settlement branches
Rollback to previous state by checklist with one command

What to do if smart contracts are already deployed on mainnet?

Deployed smart contracts do not hinder debugging — what hinders it is the absence of a staging environment that replicates their state.

A local node in fork mode is brought up on top of mainnet: it reads code and data at a specific block number, after which it behaves like a regular smart contract test environment.

There is no need to rewrite the logic: balances, roles, and storage structures remain exactly as they were on the network at the time of the snapshot.

The key detail is that the network state snapshot is taken at a fixed block, not the latest one. Otherwise reproducibility is lost between runs: pool balances, oracle responses, and the set of active participants change — and the test fails not because of a code error.

Pinning the block makes the run deterministic, and an incident is investigated on the same state in which it occurred.

Porting tests happens without modifying the contracts themselves: tests access already deployed addresses through interfaces, not through redeployment. We only add the tooling — account preparation, role granting, calls on behalf of real participants.

This is how scenarios that are not reproducible on an empty staging environment are tested: external protocols, accumulated limits, transaction history.

Contract upgrades are tested there as well. For proxy patterns, the new implementation is run against the snapshot: storage slot layout, initialization, behavior of old methods. The staging environment will show a variable shift before data corruption reaches the network.

Technical concerns: isolation, data, reproducibility

The staging environment is brought up as a separate environment: its own local network, its own test keys, its own mainnet state snapshot.

We obtain data in two ways — a snapshot at a fixed block number, when real balances and the behavior of already deployed contracts are needed, or synthetic accounts created by the deployment script.

Mainnet keys and addresses do not enter the environment, so it is physically impossible to corrupt production state with a test run.

Repeatability rests on fixing the input conditions: compiler version, optimizer settings, pinned dependencies, and snapshot block number are stored in the repository alongside the tests.

Any engineer brings up the same environment with one command, and the run is reproduced in an automated check on every change — the “it passes for me, not for you” discrepancy stops being a working scenario.

Staging environment updates and team access are part of maintenance, not a one-time setup. We describe the policy: how often the snapshot is updated, who adds test accounts, how access is granted, and what happens when the tool version changes.

How isolated is the staging environment from the production network?

Completely. The environment has no access to production keys and signs transactions only with test keys. Even if there is an error in the deployment script, changes will remain inside the local network, and the state snapshot is updated manually.

Will another developer reproduce my run?

Yes, if dependencies, compiler version, and snapshot block number are pinned. All input conditions are stored in the repository, so the environment is assembled with one command and produces an identical result on any machine.

Who maintains the environment further?

We hand over configurations, the snapshot update policy, and instructions for the team. Maintenance is a separate arrangement: we update tool versions, add scenarios, and make sure the staging environment builds after contract changes.

Let's discuss the task and come back with a work plan

To come back with a scope estimate rather than generalities, we need a short input: how your release cycle is structured now and where it fails.

It is enough to describe the task in free form — we will clarify the rest with questions and show which tests will cover specific risks.

  • Contract composition: which modules are in production, what is interconnected, where shared libraries and upgradeable parts are.
  • Current test loop: which tests already exist, what they run on, what is still checked manually before rollout.
  • Team roles: who writes contracts, who reviews, who deploys, and who needs access to deployed environments.
  • Access: repository, build and rollout configuration, test networks, deployment permissions.
  • Release bottlenecks: where it breaks most often, how long the test run and rollback take, which tests are skipped under deadlines.
  • Reporting: whether coverage metrics, gas usage reports, and a formal test trail for external audit are needed.

Next we assemble an isolated staging environment tied to your setup and define the launch order: stages, scope of work, and points at which you see the result. Describe the task — we will come back with a scope estimate and a work plan.