Build a Trade Escrow Smart Contract

Write and deploy a Solidity escrow contract that automates trade payments.

Introduction

30 Second Summary

Every time goods change hands across borders, someone has to trust someone else with the money first. Banks charge fees and add days of delay to act as the middleman.

In this project, you will build a smart contract that locks funds in escrow and automatically releases payment when trade conditions are met. You will deploy it on a local blockchain and watch real ETH move between wallets.

What You'll Build

You will execute a complete trade lifecycle on-chain. A buyer deposits ETH, confirms delivery, and the seller gets paid instantly with no intermediary involved.

By the end of this project, you'll have:

  • A working escrow contract where you deposit 1 ETH, confirm delivery, and watch the seller's MetaMask balance increase in real-time.
  • Revocable role-based access control where you can block a compromised account with one command and prove they can no longer touch the funds.
  • A full on-chain audit trail where every deposit, confirmation, and refund emits an event that external accounting and tax systems can subscribe to.
  • Secret Mission: A Factory contract that lets any user deploy their own custom escrow in one transaction.

Are there any prerequisites?

Basic JavaScript knowledge is all you need. No blockchain experience is required. This project includes full setup for every tool.

Before We Start

To build a smart contract, you need a local blockchain environment. Without it, you cannot compile Solidity code or interact with a chain.

In this step, you will install MetaMask for visualizing fund movements, set up a Hardhat 3 project for compiling and testing contracts, and configure VS Code for Solidity development.

In this step, get ready to:
  • Install MetaMask and create a development wallet.
  • Initialize a Hardhat 3 TypeScript project with OpenZeppelin Contracts.
  • Install the Hardhat VS Code extension and verify your setup compiles.
Install MetaMask and create a wallet

MetaMask is a browser extension that acts as your wallet for interacting with the Ethereum blockchain. You will use it later to watch ETH move between test accounts in real-time.

  • Open your browser and navigate to metamask.io/download.
  • Click the download button for your browser (Chrome, Firefox, Brave, Edge, or Opera are supported).
  • Follow the browser's prompts to add the MetaMask extension.

Once installed, MetaMask opens a setup wizard automatically.

  • Select Create a new wallet.
  • Create a password for your wallet.
  • MetaMask will display your Secret Recovery Phrase (12 words). Write it down on paper and store it offline. Do not save it digitally.
  • Confirm the phrase by selecting the words in the correct order.

You should see the MetaMask fox icon in your browser toolbar. This confirms the extension is active.

What is a Secret Recovery Phrase?

Your Secret Recovery Phrase is the master key for your wallet. Anyone who has it can access your funds. For this project you are using test ETH on a local blockchain, so there is no real money at risk. Still, practicing secure storage habits now builds good muscle memory.

Don't see the MetaMask icon?

Some browsers hide newly installed extensions. Click the puzzle piece icon (Extensions menu) in your browser toolbar and pin MetaMask so it stays visible.

help me find my MetaMask extension icon

Initialize your Hardhat 3 project

Hardhat is the development framework that compiles your Solidity contracts, runs tests, and simulates a local blockchain. You will scaffold a new project using its built-in template.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir trade-escrow
cd trade-escrow
  • Confirm the folder exists by running:
ls

You should see the trade-escrow folder listed in the output (if you ran ls from Desktop) or an empty listing (if you already moved into it).

  • Initialize your Hardhat project by running this command:
npx hardhat --init

What happens when you run this?

This command launches an interactive wizard. It will prompt you to select a project template. Select A TypeScript Hardhat project using Node Test Runner and Viem when the option appears.

Hardhat then installs all required dependencies automatically. This may take a minute or two depending on your internet speed.

  • When the wizard displays the project type prompt, select A TypeScript Hardhat project using Node Test Runner and Viem.
  • Accept the default answers for any remaining prompts.

Once initialization completes, you should see a success message and your terminal is back at the prompt.

  • Install OpenZeppelin Contracts (the security library you will use later) by running:
npm install @openzeppelin/contracts

What is OpenZeppelin?

OpenZeppelin Contracts is a library of audited, battle-tested Solidity components. Instead of writing your own security primitives from scratch, you inherit from well-tested implementations. You will use their ReentrancyGuard and AccessControl in later steps.

Seeing permission errors during install?

If npm install fails with EACCES or permission denied, your Node.js global directory may not have the right permissions. Try running the install again with sudo on macOS/Linux, or open your terminal as Administrator on Windows.

help me fix npm permission errors on my system

Install the VS Code extension and verify your setup

The Hardhat VS Code extension gives you Solidity syntax highlighting, inline error detection, and code completion. It makes writing .sol files much faster.

  • Open VS Code. Press Cmd+Space (macOS) or the Windows key (Windows), type Visual Studio Code, and press Enter.
  • Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Search for Hardhat in the search bar.
  • Find the extension named Hardhat by Nomic Foundation (publisher ID: NomicFoundation.hardhat-solidity).
  • Click Install.

The extension activates immediately. Now verify your entire setup works end-to-end.

  • Back in your terminal (still inside the trade-escrow folder), think about what you expect to happen when you compile a project with no contracts yet.

What do you think you will see when you run the compile command on an empty project?

  • Run the compile command:
npx hardhat compile

You should see Nothing to compile in the output. This confirms Hardhat is installed and configured correctly. There are no contracts yet, so there is nothing to compile.

Seeing an error instead of 'Nothing to compile'?

Make sure you are inside the trade-escrow directory (the folder containing hardhat.config.ts). Run ls and confirm you see hardhat.config.ts in the listing.

If you see a Node.js version error, confirm your version is v22.13.0 or greater by running node --version.

help me debug why npx hardhat compile is failing

✔️ Awesome, I've got everything!

Great. Double check your hardhat.config.ts matches the expected configuration below, then move on.

import { defineConfig } from "hardhat/config";
import hardhatToolboxViem from "@nomicfoundation/hardhat-toolbox-viem";

export default defineConfig({
  plugins: [hardhatToolboxViem],
});

ⓧ I'd like to double check the full code

Open hardhat.config.ts in your trade-escrow folder. It should contain exactly this:

import { defineConfig } from "hardhat/config";
import hardhatToolboxViem from "@nomicfoundation/hardhat-toolbox-viem";

export default defineConfig({
  plugins: [hardhatToolboxViem],
});

This file was auto-generated by the Hardhat init wizard. It imports the Viem toolbox plugin and registers it in the config. If yours looks different, delete it and re-run npx hardhat --init selecting the Viem template again.

Your blockchain development environment is fully set up. Next, you will write the base escrow contract that models the real trade payment flow.

Set Up Your Blockchain Development Environment

Smart contracts live on a blockchain, but you don't need the real Ethereum network to develop them. You need a local environment that compiles Solidity code and simulates a blockchain on your own machine.

In this step, you'll install MetaMask (your window into blockchain wallets), create a Hardhat 3 project (your Solidity compiler and local blockchain), and add OpenZeppelin Contracts (pre-audited security libraries you'll use later).

In this step, get ready to:
  • Install MetaMask and create a wallet for viewing blockchain transactions.
  • Initialize a Hardhat 3 project with OpenZeppelin Contracts.
  • Install the Hardhat VS Code extension and verify the toolchain compiles.
Install the MetaMask browser extension

MetaMask is a browser extension that acts as a crypto wallet. Later in the project, you'll use it to visually track ETH moving between buyer and seller accounts on your local blockchain.

  • Open your browser and navigate to metamask.io/download.
  • Click the download button for your browser (Chrome, Firefox, Brave, or Edge are all supported).
  • In the browser's extension store, click Add to Chrome (or equivalent for your browser).
  • Confirm by clicking Add extension in the popup.

MetaMask opens automatically after installation.

  • Select Create a new wallet.
  • Choose the Secret Recovery Phrase option (the traditional self-custody method).
  • Create a strong password when prompted.
  • Write down your 12-word Secret Recovery Phrase on paper and store it somewhere secure offline.
  • Confirm the phrase by selecting the words in the correct order when asked.

You should now see the MetaMask wallet interface in your browser with a 0 ETH balance.

What is a Secret Recovery Phrase?

Your Secret Recovery Phrase is a 12-word backup key that controls access to your wallet. Anyone with these words can access your funds, so never share them or store them digitally.

For this project you'll only use test ETH on a local blockchain, so there's no real money at risk. Still, practising good security habits from the start builds muscle memory for production wallets.

Don't see MetaMask after installing?

Click the puzzle-piece (extensions) icon in your browser's toolbar, then click the pin icon next to MetaMask to keep it visible.

If you're using Brave and MetaMask appears unresponsive, go to Settings then Web3 and change Default Ethereum wallet to None. Restart Brave afterwards.

help me troubleshoot MetaMask installation in my browser

Initialize the Hardhat 3 project

Hardhat is the development framework that compiles your Solidity contracts, runs a local blockchain, and executes tests. You'll create a fresh project directory and let Hardhat scaffold the full TypeScript environment.

  • Open your terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir trade-escrow
cd trade-escrow
  • Initialize the Hardhat project by running:
npx hardhat --init

What will Hardhat ask you?

The init command prompts you with configuration options. When asked for the project type, select A TypeScript Hardhat project using Node Test Runner and Viem.

Accept the remaining defaults. Hardhat will install all required dependencies automatically, which may take a minute or two.

Once the init finishes, you'll see a project structure with hardhat.config.ts, a contracts/ folder, and a test/ folder.

  • Confirm the project files exist by running:
ls

You should see contracts, test, hardhat.config.ts, package.json, and node_modules in the output.

  • Install OpenZeppelin Contracts by running:
npm install @openzeppelin/contracts

You should see a successful install message with no errors. OpenZeppelin is now available in your node_modules folder.

Why OpenZeppelin?

OpenZeppelin Contracts is a library of audited, battle-tested smart contract components. Instead of writing security primitives from scratch (where a single typo can cost millions), you import proven implementations.

You'll use their ReentrancyGuard and AccessControl contracts in Step 4 to harden your escrow.

Seeing permission errors during install?

Make sure you're inside the trade-escrow folder (not your Desktop). Run pwd to check your current directory.

If npm complains about permissions, avoid using sudo. Instead, check that Node.js was installed for your user, not system-wide.

help me fix npm permission errors in my Hardhat project

Install the Hardhat VS Code extension and verify

The Hardhat VS Code extension gives you Solidity syntax highlighting, inline error detection, and go-to-definition for your smart contracts.

  • Switch to VS Code.
  • Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Search for Hardhat in the search bar.
  • Find the extension named Hardhat by Nomic Foundation (marketplace ID NomicFoundation.hardhat-solidity).
  • Click Install.

The extension activates immediately. You'll see Solidity files get syntax highlighting when you open them.

  • Open the trade-escrow folder in VS Code by clicking File then Open Folder and selecting the trade-escrow folder on your Desktop.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).

Before you run the compile command, what do you think it will output given that there are no .sol files in your contracts/ folder yet?

  • Verify the entire toolchain works by running:
npx hardhat compile

You should see the output Nothing to compile. This confirms Hardhat is installed and configured correctly, even though there are no contracts yet.

✔️ I see Nothing to compile

Your environment is fully working. Hardhat found the config, loaded the toolbox-viem plugin, and is ready to compile Solidity contracts.

ⓧ I see an error

Check that you're inside the trade-escrow folder (the one containing hardhat.config.ts).

  • Run ls and confirm you see hardhat.config.ts listed.
  • If you don't see it, run cd ~/Desktop/trade-escrow and try the compile command again.
  • If you see a "Cannot find module" error, run npm install to reinstall dependencies, then try compiling again.

help me fix Hardhat compile errors in my new project

What does this config file do?

Open hardhat.config.ts in the VS Code explorer. You'll see it imports defineConfig from Hardhat and the hardhat-toolbox-viem plugin.

The toolbox bundles everything you need: a Solidity compiler, a test runner (Node.js built-in test runner), the Viem library for blockchain interactions, and contract verification tools. One plugin import, and you're fully equipped.

Your blockchain development environment is ready. Next up, you'll write the base escrow contract that models a real trade payment flow.

Write the Base Escrow Contract

Your Hardhat project compiles and your tools are ready. Now you need the actual contract that models a real trade payment.

In a traditional trade, a buyer sends money and hopes the seller delivers. If something goes wrong, expensive lawyers and slow courts get involved. Your smart contract eliminates that trust problem by holding funds in code until conditions are met.

In this step, get ready to:
  • Create the escrow contract with a state machine that tracks the trade lifecycle.
  • Add functions for depositing, confirming delivery, and issuing refunds.
  • Compile the contract and verify it produces no errors.
Create the contract and define the state machine

Every escrow follows a predictable lifecycle: waiting for payment, waiting for delivery, then either completed or refunded. A state machine encodes these stages directly into the contract so invalid transitions are impossible.

  • Create a new file called contracts/TradeEscrow.sol inside your project's contracts/ folder.
  • Add the contract shell with the state enum and storage variables by pasting this code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract TradeEscrow {
    // Tracks the escrow lifecycle
    enum State {
        AWAITING_PAYMENT,
        AWAITING_DELIVERY,
        COMPLETE,
        REFUNDED
    }

    address public buyer;
    address public seller;
    address public arbiter;
    uint256 public amount;
    uint256 public deadline;
    State public state;
}

What does this code do?

The enum State defines four stages the escrow can be in. Once the contract moves to COMPLETE or REFUNDED, the trade is over and no more actions are possible.

The address fields store the wallet addresses of all three parties. The amount tracks how much ETH is locked, and deadline sets when the buyer can reclaim funds if delivery never happens.

  • Now add the constructor inside the contract, just above the closing }. The constructor initializes all parties and sets the starting state:
    // Runs once at deployment to lock in trade parties
    constructor(
        address _buyer,
        address _seller,
        address _arbiter,
        uint256 _deadline
    ) {
        buyer = _buyer;
        seller = _seller;
        arbiter = _arbiter;
        deadline = _deadline;
        state = State.AWAITING_PAYMENT;
    }

What does the constructor do?

The constructor runs once when the contract is deployed. It locks in the buyer, seller, and arbiter addresses plus the refund deadline. The escrow starts in AWAITING_PAYMENT because no ETH has arrived yet.

  • Save contracts/TradeEscrow.sol.
  • Verify the skeleton compiles by running:
npx hardhat compile

You should see a success message confirming 1 Solidity file compiled. This proves your contract structure is valid before you add the trade functions.

Seeing a compilation error?

  • Check that the file is inside the contracts/ folder, not at the project root.
  • Make sure the pragma line reads pragma solidity ^0.8.24; with the semicolon at the end.
  • Verify the closing } brace for the contract is present.

Help me debug this compilation error

Add the payment and delivery functions

The core trade flow is two actions: the buyer deposits ETH, then confirms delivery to release payment. Each function checks the caller's identity and the current state before doing anything.

  • Add the deposit function inside the contract, below the constructor:
    // Buyer locks ETH into the escrow
    function deposit() external payable {
        require(msg.sender == buyer, "Only buyer");
        require(state == State.AWAITING_PAYMENT, "Already funded");
        require(msg.value > 0, "Must deposit something");
        amount = msg.value;
        state = State.AWAITING_DELIVERY;
    }

What does deposit do?

Three require checks run before anything else. Only the buyer can call it, the escrow must be waiting for payment, and the deposit must be greater than zero. Once those pass, the ETH is locked and the state advances to AWAITING_DELIVERY.

  • Add the confirmDelivery function below deposit:
    // Buyer confirms goods received, releases funds to seller
    function confirmDelivery() external {
        require(msg.sender == buyer, "Only buyer");
        require(state == State.AWAITING_DELIVERY, "Not awaiting delivery");
        state = State.COMPLETE;
        (bool sent, ) = seller.call{value: amount}("");
        require(sent, "Transfer failed");
    }

What does confirmDelivery do?

When the buyer confirms they received the goods, the contract moves to COMPLETE and sends all locked ETH to the seller. The low-level call{value: amount} transfers the funds. If the transfer fails for any reason, the entire transaction reverts.

  • Save contracts/TradeEscrow.sol and compile again to verify:
npx hardhat compile

You should see a successful compilation message again. The payment flow is working.

Add the refund protections and compile

A real trade can go wrong. The arbiter needs to force refunds for disputes, and the buyer needs a safety net if the seller disappears entirely.

  • Add the refund function below confirmDelivery:
    // Arbiter forces a refund in case of disputes
    function refund() external {
        require(msg.sender == arbiter, "Only arbiter");
        require(state == State.AWAITING_DELIVERY, "Not awaiting delivery");
        state = State.REFUNDED;
        (bool sent, ) = buyer.call{value: amount}("");
        require(sent, "Transfer failed");
    }

What does refund do?

Only the arbiter can call this. It sends all locked ETH back to the buyer and moves the state to REFUNDED. This is the dispute resolution path when the arbiter decides the seller failed to deliver.

  • Add the claimRefund and getState functions below refund:
    // Buyer reclaims funds if deadline passes with no delivery
    function claimRefund() external {
        require(msg.sender == buyer, "Only buyer");
        require(state == State.AWAITING_DELIVERY, "Not awaiting delivery");
        require(block.timestamp > deadline, "Deadline not reached");
        state = State.REFUNDED;
        (bool sent, ) = buyer.call{value: amount}("");
        require(sent, "Transfer failed");
    }

    // Helper to check current escrow state
    function getState() external view returns (State) {
        return state;
    }

What do these functions do?

The claimRefund function is the buyer's safety net. If block.timestamp passes the deadline and delivery was never confirmed, the buyer can pull their ETH back without needing the arbiter.

The getState function is a read-only helper. It lets external code (tests, frontends) check which lifecycle stage the escrow is in without paying gas.

Why no events or OpenZeppelin imports?

This contract uses plain require(msg.sender == buyer) checks for access control. It has no events and no reentrancy protection. This is intentional.

You will discover why this is insufficient in the next step when your tests reveal that a compromised buyer account cannot be revoked, and that no external system can track what happened on-chain.

  • Save contracts/TradeEscrow.sol.

Before you compile, what do you expect to see? The contract has valid syntax and a complete state machine, so the compiler should be happy.

  • Run the final compile:
npx hardhat compile

You should see output confirming 1 Solidity file compiled successfully. Your complete trade lifecycle is now encoded in code.

Compilation failing on the functions?

  • Check that every function sits inside the contract TradeEscrow { } braces, not outside.
  • Verify (bool sent, ) = seller.call{value: amount}(""); has the exact spacing and empty string argument shown. Missing quotes or parentheses cause cryptic errors.
  • Ensure each function ends with a closing } and the contract itself has one final } at the bottom.

Help me find the syntax error in my contract

✔️ Awesome, I've got everything!

Great. Double check you saved contracts/TradeEscrow.sol and that your last npx hardhat compile succeeded.

ⓧ I'd like to double check the full code

Scroll up and confirm your contracts/TradeEscrow.sol has all of these sections in order:

  • The SPDX license and pragma solidity ^0.8.24 at the top.
  • The State enum with four values.
  • Six state variables: buyer, seller, arbiter, amount, deadline, state.
  • The constructor accepting four parameters.
  • The deposit function.
  • The confirmDelivery function.
  • The refund function.
  • The claimRefund function.
  • The getState function.

Your escrow contract compiles and implements the entire trade lifecycle. Next up, you will write tests that prove the happy path works and discover exactly where this contract falls short for production use.

Test the Contract and Discover Its Weaknesses

Your contract compiled in the last step. It has a complete trade lifecycle: deposit, confirm delivery, refund, and deadline-based claim.

Compiling is not the same as being production-ready. In this step, you will write automated tests that prove the core logic works. Then you will write tests that expose two critical business problems: the contract is blind (no audit trail) and brittle (no way to recover from a compromised account).

In this step, get ready to:
  • Write happy-path tests proving deposit, delivery confirmation, and payment work.
  • Write a test proving the contract cannot revoke a compromised buyer account.
  • Write a test proving the contract emits no events for external accounting systems.
Write the happy-path tests

Automated tests deploy your contract to a temporary in-memory blockchain and call its functions just like a real user would. Each test proves one behaviour works correctly.

  • Create a new file called test/TradeEscrow.ts inside your project folder by running:
mkdir -p test && touch test/TradeEscrow.ts
  • Confirm the file exists by running:
ls test

You should see TradeEscrow.ts in the output.

  • Open test/TradeEscrow.ts in VS Code and paste the following test code:
// Partial test file demonstrating happy-path tests that pass
// and tests revealing missing events and role revocation
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import hre from "hardhat";
import { parseEther } from "viem";

describe("TradeEscrow - Weakness Discovery", function () {
  async function deployEscrow() {
    const [buyer, seller, arbiter, unauthorized] = await hre.viem.getWalletClients();
    const publicClient = await hre.viem.getPublicClient();
    const futureDeadline = BigInt(Math.floor(Date.now() / 1000) + 86400);
    const escrow = await hre.viem.deployContract("TradeEscrow", [
      buyer.account.address,
      seller.account.address,
      arbiter.account.address,
      futureDeadline,
    ]);
    return { escrow, buyer, seller, arbiter, unauthorized, publicClient };
  }

  it("happy path: deposit and confirm delivery", async function () {
    const { escrow, buyer, seller, publicClient } = await deployEscrow();
    await escrow.write.deposit({ value: parseEther("1"), account: buyer.account });
    const sellerBefore = await publicClient.getBalance({ address: seller.account.address });
    await escrow.write.confirmDelivery({ account: buyer.account });
    const sellerAfter = await publicClient.getBalance({ address: seller.account.address });
    assert.ok(sellerAfter > sellerBefore);
  });
});

What does this code do?

  • deployEscrow() is a helper that deploys a fresh contract instance for each test. It grabs four wallet clients from Hardhat (buyer, seller, arbiter, unauthorized) and sets a deadline 24 hours in the future.
  • The happy-path test deposits 1 ETH as the buyer, confirms delivery, then asserts the seller's balance increased. This proves the core trade flow works end-to-end.
  • The test uses the Node.js built-in test runner (node:test) and node:assert/strict for assertions. This is the default testing stack for Hardhat 3 with the viem toolbox.
  • Save test/TradeEscrow.ts.
  • Run the tests by executing:
npx hardhat test

You should see your happy-path test pass with a check mark next to its name.

Test failing or not running?

  • Make sure test/TradeEscrow.ts is saved and located in the test/ directory at the root of your project.
  • If you see a compilation error, confirm your contracts/TradeEscrow.sol still compiles cleanly by running npx hardhat compile first.
  • Check that your imports match exactly. The test uses node:test and node:assert/strict (built into Node.js), not a third-party library.

help me debug my failing Hardhat test

Write tests that reveal the contract's weaknesses

The happy-path test passed. Your contract's core logic is solid. Now imagine this scenario: a buyer's private key gets stolen. You need to revoke their access immediately and assign a new buyer. Can you do that with this contract?

  • In test/TradeEscrow.ts, find the closing }); of the describe block (the last line of the file).
  • Add these two tests just above that closing line:
  it("FAILS: cannot revoke compromised buyer - no mechanism exists", async function () {
    // This test demonstrates the weakness: no role revocation possible
    const { escrow, buyer, arbiter } = await deployEscrow();
    // There is no revokeRole function - the contract has no such mechanism
    assert.ok(typeof (escrow.write as any).revokeRole === "undefined", "No revokeRole exists");
  });

  it("FAILS: no events emitted on deposit", async function () {
    const { escrow, buyer, publicClient } = await deployEscrow();
    const hash = await escrow.write.deposit({ value: parseEther("1"), account: buyer.account });
    const receipt = await publicClient.waitForTransactionReceipt({ hash });
    assert.equal(receipt.logs.length, 0, "No events emitted");
  });

What do these tests prove?

The first test checks whether a revokeRole function exists on the contract. It does not. If a buyer's key is compromised, you have no way to lock them out. The buyer address is hardcoded in the constructor with no mechanism to change it.

The second test deposits 1 ETH and then inspects the transaction receipt for log entries. It finds zero. Without events, no external system (accounting software, tax tools, audit dashboards) can track what happened on-chain. The contract is blind to the outside world.

  • Save test/TradeEscrow.ts.

Before you run the tests, think about what you expect to see. Will these two new tests pass or fail? Why?

  • Run the full test suite:
npx hardhat test

All three tests pass. The weakness tests pass because they are asserting the absence of features. They prove that revokeRole does not exist and that no events are emitted. This is the designed outcome.

Why do passing tests reveal a problem?

A passing test does not always mean good news. These tests pass by proving your contract is missing critical capabilities. The contract works for the happy path, but it is not production-ready.

Two dealbreakers for a real trade platform: no way to revoke a compromised account (brittle), and no event logs for external accounting systems like DATEV to track payments (blind). The next step fixes both.

Seeing unexpected failures?

  • If the happy-path test passes but the weakness tests fail, check that you placed them inside the describe block (before the final });).
  • Make sure the as any cast is present in (escrow.write as any).revokeRole. Without it, TypeScript may throw a compile error about an unknown property.

help me fix my Hardhat weakness tests

✔️ Awesome, I've got everything!

Great. Double check you have saved test/TradeEscrow.ts and all three tests pass when you run npx hardhat test.

ⓧ I'd like to double check the full code

Your test/TradeEscrow.ts should contain three sections in this order:

  • Imports (node:test, node:assert/strict, hardhat, viem) and the describe block opener.
  • The deployEscrow() helper function that deploys the contract with buyer, seller, arbiter, and a future deadline.
  • Three it() blocks: the happy-path test, the compromised-buyer test, and the no-events test.

Scroll up and confirm each chunk is present in your file. All three tests should pass when you run npx hardhat test.

Your tests just proved the contract works but is not production-ready. It cannot revoke compromised accounts and it emits no events for external systems. Next up, you will fix both problems using OpenZeppelin's battle-tested security libraries.

Secure the Contract with OpenZeppelin

Your tests proved the contract works, but they also exposed two dealbreakers for any real business. The contract is blind (no events for audit trails) and brittle (no way to revoke a compromised account).

OpenZeppelin's battle-tested libraries solve both problems. AccessControl adds revocable roles so you can lock out a stolen wallet. ReentrancyGuard prevents reentrancy attacks on every function that moves ETH. And Solidity events create the on-chain audit trail your accounting system needs.

In this step, get ready to:
  • Refactor the contract to inherit OpenZeppelin's ReentrancyGuard and AccessControl.
  • Add events to every state-changing function for a full audit trail.
  • Update the test suite to verify role revocation, event emission, and access control.
Refactor the contract with OpenZeppelin security

You will replace the entire contents of TradeEscrow.sol with a secured version that inherits from OpenZeppelin and uses role-based access.

  • Open contracts/TradeEscrow.sol in VS Code.
  • Select all the existing code and delete it. You are replacing the entire file.
  • Paste the following as the start of your new contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract TradeEscrow is ReentrancyGuard, AccessControl {
    bytes32 public constant BUYER_ROLE = keccak256("BUYER_ROLE");
    bytes32 public constant SELLER_ROLE = keccak256("SELLER_ROLE");
    bytes32 public constant ARBITER_ROLE = keccak256("ARBITER_ROLE");

    enum State {
        AWAITING_PAYMENT,
        AWAITING_DELIVERY,
        COMPLETE,
        REFUNDED
    }

    address public buyer;
    address public seller;
    uint256 public amount;
    uint256 public deadline;
    State public state;

What does this code do?

The contract now inherits from two OpenZeppelin libraries. ReentrancyGuard provides a nonReentrant modifier that blocks re-entry attacks on functions that send ETH.

AccessControl provides onlyRole() modifiers and functions like revokeRole() that the arbiter can call if a buyer's wallet is compromised. The three role constants define who can do what.

  • Directly below what you just pasted, add the events and constructor:
    event Deposited(address indexed buyer, uint256 amount);
    event DeliveryConfirmed(address indexed buyer);
    event Refunded(address indexed to, uint256 amount);

    constructor(
        address _buyer,
        address _seller,
        address _arbiter,
        uint256 _deadline
    ) {
        buyer = _buyer;
        seller = _seller;
        deadline = _deadline;
        state = State.AWAITING_PAYMENT;

        _grantRole(BUYER_ROLE, _buyer);
        _grantRole(SELLER_ROLE, _seller);
        _grantRole(ARBITER_ROLE, _arbiter);
        _grantRole(DEFAULT_ADMIN_ROLE, _arbiter);
    }

Why does the arbiter get DEFAULT_ADMIN_ROLE?

Events like Deposited and Refunded emit on-chain logs that external systems (accounting, tax, auditing) can subscribe to. This is the audit trail your previous tests proved was missing.

The DEFAULT_ADMIN_ROLE is a special OpenZeppelin role that can grant and revoke any other role. By giving it to the arbiter, you create a recovery path: if the buyer's key is stolen, the arbiter revokes BUYER_ROLE and assigns a new buyer.

  • Below the constructor, add all the functions and the closing brace:
    function deposit() external payable onlyRole(BUYER_ROLE) nonReentrant {
        require(state == State.AWAITING_PAYMENT, "Already funded");
        require(msg.value > 0, "Must deposit something");
        amount = msg.value;
        state = State.AWAITING_DELIVERY;
        emit Deposited(msg.sender, msg.value);
    }

    function confirmDelivery() external onlyRole(BUYER_ROLE) nonReentrant {
        require(state == State.AWAITING_DELIVERY, "Not awaiting delivery");
        state = State.COMPLETE;
        emit DeliveryConfirmed(msg.sender);
        (bool sent, ) = seller.call{value: amount}("");
        require(sent, "Transfer failed");
    }

    function refund() external onlyRole(ARBITER_ROLE) nonReentrant {
        require(state == State.AWAITING_DELIVERY, "Not awaiting delivery");
        state = State.REFUNDED;
        emit Refunded(buyer, amount);
        (bool sent, ) = buyer.call{value: amount}("");
        require(sent, "Transfer failed");
    }

    function claimRefund() external onlyRole(BUYER_ROLE) nonReentrant {
        require(state == State.AWAITING_DELIVERY, "Not awaiting delivery");
        require(block.timestamp > deadline, "Deadline not reached");
        state = State.REFUNDED;
        emit Refunded(buyer, amount);
        (bool sent, ) = buyer.call{value: amount}("");
        require(sent, "Transfer failed");
    }

    function getState() external view returns (State) {
        return state;
    }
}

What changed in these functions?

Every require(msg.sender == buyer) check is replaced with onlyRole(BUYER_ROLE). This makes access revocable. If a wallet is compromised, the arbiter can revoke that role without redeploying the contract.

Every function that transfers ETH now has the nonReentrant modifier. This prevents an attacker from calling back into the contract mid-transfer to drain funds. Each function also emits an event after changing state, creating the audit trail.

  • Save contracts/TradeEscrow.sol.
  • Compile the contract by running:
npx hardhat compile

You should see a success message confirming the Solidity file compiled without errors.

Seeing a compilation error?

  • Check that @openzeppelin/contracts is installed. Run npm install @openzeppelin/contracts if not.
  • Make sure the import paths match exactly: @openzeppelin/contracts/utils/ReentrancyGuard.sol and @openzeppelin/contracts/access/AccessControl.sol.
  • Verify the closing brace } at the end of the file matches the opening contract TradeEscrow declaration.

Still stuck? Help me debug my TradeEscrow compilation error

✔️ Awesome, I've got everything!

Great. Double check you saved contracts/TradeEscrow.sol and it compiled without errors.

ⓧ I'd like to double check the full code

Your contracts/TradeEscrow.sol should contain all three sections you pasted above, in order:

  • Section 1: License, pragma, imports, contract declaration, role constants, enum, and state variables.
  • Section 2: Three event declarations and the constructor with _grantRole calls.
  • Section 3: All four functions (deposit, confirmDelivery, refund, claimRefund) plus getState and the closing brace.

Scroll up and confirm each section is present. The contract should be about 72 lines total.

Write the comprehensive test suite

The previous test file proved the contract's weaknesses. Now you will replace it with a full suite that verifies the secured contract works correctly.

  • Open test/TradeEscrow.ts and delete all existing contents.
  • Paste the following as the start of your new test file:
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import hre from "hardhat";
import { parseEther, getAddress } from "viem";

describe("TradeEscrow", function () {
  async function deployEscrow() {
    const [buyer, seller, arbiter, unauthorized] =
      await hre.viem.getWalletClients();
    const publicClient = await hre.viem.getPublicClient();

    const futureDeadline = BigInt(Math.floor(Date.now() / 1000) + 86400);

    const escrow = await hre.viem.deployContract("TradeEscrow", [
      buyer.account.address,
      seller.account.address,
      arbiter.account.address,
      futureDeadline,
    ]);

    return { escrow, buyer, seller, arbiter, unauthorized, publicClient, futureDeadline };
  }

  describe("Happy Path", function () {
    it("allows buyer to deposit", async function () {
      const { escrow, buyer } = await deployEscrow();
      const hash = await escrow.write.deposit({
        value: parseEther("1"),
        account: buyer.account,
      });
      const state = await escrow.read.getState();
      assert.equal(state, 1); // AWAITING_DELIVERY
    });

What does the helper function do?

The deployEscrow() helper gets four wallet clients from Hardhat's local network, deploys a fresh contract for each test, and returns everything the test needs. Each test starts with a clean slate.

  • Below the deposit test, add the remaining happy path tests and access control tests:
    it("allows buyer to confirm delivery and pay seller", async function () {
      const { escrow, buyer, seller, publicClient } = await deployEscrow();
      await escrow.write.deposit({ value: parseEther("1"), account: buyer.account });
      const sellerBalanceBefore = await publicClient.getBalance({ address: seller.account.address });
      await escrow.write.confirmDelivery({ account: buyer.account });
      const sellerBalanceAfter = await publicClient.getBalance({ address: seller.account.address });
      assert.ok(sellerBalanceAfter > sellerBalanceBefore);
      const state = await escrow.read.getState();
      assert.equal(state, 2); // COMPLETE
    });

    it("allows arbiter to refund buyer", async function () {
      const { escrow, buyer, arbiter, publicClient } = await deployEscrow();
      await escrow.write.deposit({ value: parseEther("1"), account: buyer.account });
      const buyerBalanceBefore = await publicClient.getBalance({ address: buyer.account.address });
      await escrow.write.refund({ account: arbiter.account });
      const buyerBalanceAfter = await publicClient.getBalance({ address: buyer.account.address });
      assert.ok(buyerBalanceAfter > buyerBalanceBefore);
      const state = await escrow.read.getState();
      assert.equal(state, 3); // REFUNDED
    });
  });

  describe("Access Control", function () {
    it("blocks unauthorized deposit", async function () {
      const { escrow, unauthorized } = await deployEscrow();
      await assert.rejects(
        escrow.write.deposit({ value: parseEther("1"), account: unauthorized.account })
      );
    });

What are these tests proving?

The happy path tests verify that the buyer can deposit, confirm delivery (paying the seller), and the arbiter can issue refunds. The access control test verifies that a random wallet cannot call deposit() because it lacks BUYER_ROLE.

  • Finally, add the remaining access control, events, and deadline tests plus the closing braces:
    it("blocks unauthorized delivery confirmation", async function () {
      const { escrow, buyer, unauthorized } = await deployEscrow();
      await escrow.write.deposit({ value: parseEther("1"), account: buyer.account });
      await assert.rejects(
        escrow.write.confirmDelivery({ account: unauthorized.account })
      );
    });

    it("allows arbiter to revoke compromised buyer role", async function () {
      const { escrow, buyer, arbiter } = await deployEscrow();
      const BUYER_ROLE = await escrow.read.BUYER_ROLE();
      await escrow.write.revokeRole([BUYER_ROLE, buyer.account.address], { account: arbiter.account });
      await assert.rejects(
        escrow.write.deposit({ value: parseEther("1"), account: buyer.account })
      );
    });
  });

  describe("Events", function () {
    it("emits Deposited event on deposit", async function () {
      const { escrow, buyer, publicClient } = await deployEscrow();
      const hash = await escrow.write.deposit({ value: parseEther("1"), account: buyer.account });
      const receipt = await publicClient.waitForTransactionReceipt({ hash });
      assert.ok(receipt.logs.length > 0);
    });
  });

  describe("Deadline Refund", function () {
    it("allows buyer to claim refund after deadline", async function () {
      const [buyer, seller, arbiter] = await hre.viem.getWalletClients();
      const publicClient = await hre.viem.getPublicClient();
      const pastDeadline = BigInt(Math.floor(Date.now() / 1000) - 1);
      const escrow = await hre.viem.deployContract("TradeEscrow", [
        buyer.account.address,
        seller.account.address,
        arbiter.account.address,
        pastDeadline,
      ]);
      await escrow.write.deposit({ value: parseEther("1"), account: buyer.account });
      await escrow.write.claimRefund({ account: buyer.account });
      const state = await escrow.read.getState();
      assert.equal(state, 3); // REFUNDED
    });
  });
});

What do the final tests prove?

The role revocation test is the key one. It reads BUYER_ROLE from the contract, calls revokeRole as the arbiter, then proves the buyer can no longer deposit. This is the exact scenario that was impossible in the previous contract.

The events test checks that receipt.logs contains entries after a deposit. The deadline test deploys a contract with an already-expired deadline and verifies the buyer can reclaim funds.

  • Save test/TradeEscrow.ts.

Before you run the tests, predict: do you think the event emission test will pass now? What about the role revocation test that failed in the previous step?

  • Run the full test suite:
npx hardhat test

You should see all tests passing, including the event and role-revocation tests that previously failed.

Tests failing?

  • If you see an import error for getAddress, make sure your import line reads import { parseEther, getAddress } from "viem".
  • If the revocation test fails, check that _grantRole(DEFAULT_ADMIN_ROLE, _arbiter) is in your constructor. Without admin role, the arbiter cannot revoke other roles.
  • If all tests revert, make sure you removed the old address public arbiter state variable. The secured contract only stores buyer and seller as address state variables.

Still stuck? Help me debug my failing TradeEscrow tests after adding OpenZeppelin security

✔️ Awesome, I've got everything!

Great. Double check that you see all tests passing with no failures.

ⓧ I'd like to double check the full code

Your test/TradeEscrow.ts should contain all three sections you pasted above, in order:

  • Section 1: Imports, the deployEscrow helper function, and the deposit test inside a Happy Path describe block.
  • Section 2: The confirm-delivery and arbiter-refund tests (still in Happy Path), then the start of the Access Control describe block with the unauthorized-deposit test.
  • Section 3: The unauthorized-confirmation test, the role-revocation test, the Events describe block, the Deadline Refund describe block, and all closing braces.

Scroll up and confirm each section is present and in order.

Your contract is no longer blind or brittle. It has revocable roles, reentrancy protection, and a full event-based audit trail. Next up, you will deploy this secured contract to a local blockchain and watch real ETH move between wallets.

Deploy and Run a Full Trade Cycle

Your contract passes every test, but tests run in an isolated simulation. Deploying to a real blockchain node and watching ETH move between actual wallets is the difference between "it compiles" and "it works."

In this step, you will start a local Ethereum blockchain, deploy your escrow contract to it, and execute a complete trade cycle. You will see funds move between wallets in MetaMask in real-time.

In this step, get ready to:
  • Start a local Hardhat blockchain node and connect MetaMask to it.
  • Deploy your escrow contract using a deployment script.
  • Run a full trade cycle and watch ETH transfer between buyer and seller accounts.
Start the local Hardhat node and connect MetaMask

Hardhat can run a full Ethereum blockchain on your machine. It comes with 20 pre-funded test accounts, each holding 10,000 ETH.

  • Start the local blockchain by running this command in a new terminal:
npx hardhat node

What does this command do?

This starts a JSON-RPC server on localhost:8545 that acts as a local Ethereum blockchain. Hardhat prints 20 test accounts with their private keys to the console. These accounts are pre-loaded with 10,000 ETH each for testing.

This terminal will stay busy running the node. You will need a second terminal for the remaining commands in this step.

  • Look at the terminal output. You should see a list of accounts labelled Account #0 through Account #19, each with an address and a private key.
  • Copy the private key for Account #0 (this will be the buyer).
  • In your browser, open the MetaMask extension and click the account selector icon at the top.
  • Select Add wallet.
  • Click Import an account.
  • Paste the private key for Account #0 and click Import.
  • Repeat the import process for Account #1 (this will be the seller).

Now connect MetaMask to your local blockchain so it can see these accounts.

  • In MetaMask, click the network selector at the top of the wallet.
  • Select Add network.
  • Click Add a network manually.
  • Enter Hardhat Local in the Network name field.
  • Enter http://localhost:8545 in the New RPC URL field.
  • Enter 31337 in the Chain ID field.
  • Enter ETH in the Currency symbol field.
  • Click Save.
  • Switch to the Hardhat Local network using the network selector at the top of MetaMask.

You should see the imported Account #0 holding 10,000 ETH. Switch between Account #0 and Account #1 to confirm both show large balances.

Seeing 0 ETH in MetaMask?

Make sure you switched to the Hardhat Local network, not Ethereum Mainnet. Check that your npx hardhat node terminal is still running. If you closed it and restarted, the accounts reset and you need to re-import the new private keys.

Help me troubleshoot this

Write and run the deploy script

A deploy script sends your compiled contract bytecode to the blockchain and returns a live contract address. You will use this address to interact with the contract.

  • Open a second terminal in your project directory (keep the first terminal running the node).
  • Create a new file called scripts/deploy.ts and add the following code:
import hre from "hardhat";

async function main() {
  // Get the first three pre-funded accounts from the local node
  const [buyer, seller, arbiter] = await hre.viem.getWalletClients();

  // Set a deadline 7 days from now
  const deadline = BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);

  // Deploy the contract with the three parties and deadline
  const escrow = await hre.viem.deployContract("TradeEscrow", [
    buyer.account.address,
    seller.account.address,
    arbiter.account.address,
    deadline,
  ]);

  console.log("TradeEscrow deployed to:", escrow.address);
  console.log("Buyer:", buyer.account.address);
  console.log("Seller:", seller.account.address);
  console.log("Arbiter:", arbiter.account.address);
  console.log("Deadline:", new Date(Number(deadline) * 1000).toISOString());
}

main().catch(console.error);

What does this script do?

The script grabs the first three accounts from the Hardhat node (buyer, seller, arbiter) and deploys the TradeEscrow contract with a 7-day deadline. It logs the deployed contract address and all party addresses so you can reference them later.

  • Save scripts/deploy.ts.
  • Deploy the contract to your local blockchain by running:
npx hardhat run scripts/deploy.ts --network localhost

You should see output showing the deployed contract address, buyer address, seller address, arbiter address, and deadline.

Seeing a connection error?

Make sure your npx hardhat node terminal is still running. The --network localhost flag tells Hardhat to send the transaction to the running node instead of an in-memory chain.

Help me debug this

Run a full trade cycle and watch funds move

The deploy script proved the contract is live on-chain. Now you will run a script that executes the full escrow lifecycle: deposit, confirm delivery, and verify the seller received payment.

  • Create a new file called scripts/interact.ts and add the first part of the trade script:
import hre from "hardhat";
import { parseEther, formatEther } from "viem";

async function main() {
  // Get accounts and public client for balance queries
  const [buyer, seller, arbiter] = await hre.viem.getWalletClients();
  const publicClient = await hre.viem.getPublicClient();

  // Set deadline and deploy a fresh escrow for this trade
  const deadline = BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);

  console.log("Deploying TradeEscrow...");
  const escrow = await hre.viem.deployContract("TradeEscrow", [
    buyer.account.address,
    seller.account.address,
    arbiter.account.address,
    deadline,
  ]);
  console.log("Deployed to:", escrow.address);

  // Record balances before the trade
  console.log("\n--- BEFORE TRADE ---");
  const buyerBefore = await publicClient.getBalance({ address: buyer.account.address });
  const sellerBefore = await publicClient.getBalance({ address: seller.account.address });
  console.log("Buyer balance:", formatEther(buyerBefore), "ETH");
  console.log("Seller balance:", formatEther(sellerBefore), "ETH");
}

What does this first section do?

This deploys a fresh escrow contract and records the starting balances of both the buyer and seller. The formatEther function converts raw wei values into human-readable ETH amounts.

  • Now add the trade execution and final balance check. Replace the closing } of the main function with the following code, so the function continues:
  // Buyer deposits 1 ETH into escrow
  console.log("\n--- BUYER DEPOSITS 1 ETH ---");
  await escrow.write.deposit({ value: parseEther("1"), account: buyer.account });
  console.log("Deposit successful. Escrow state: AWAITING_DELIVERY");

  // Buyer confirms goods received, triggering payment to seller
  console.log("\n--- BUYER CONFIRMS DELIVERY ---");
  await escrow.write.confirmDelivery({ account: buyer.account });
  console.log("Delivery confirmed. Escrow state: COMPLETE");

  // Check final balances to prove funds moved
  console.log("\n--- AFTER TRADE ---");
  const buyerAfter = await publicClient.getBalance({ address: buyer.account.address });
  const sellerAfter = await publicClient.getBalance({ address: seller.account.address });
  console.log("Buyer balance:", formatEther(buyerAfter), "ETH");
  console.log("Seller balance:", formatEther(sellerAfter), "ETH");
  console.log("Seller gained:", formatEther(sellerAfter - sellerBefore), "ETH");
}

main().catch(console.error);

What does this code do?

The buyer deposits 1 ETH into the escrow, then immediately confirms delivery. The confirmDelivery function releases the locked funds to the seller. The final balance printout proves the seller gained exactly 1 ETH from the trade.

  • Save scripts/interact.ts.

Before you run this, what do you think will happen to the seller's balance? Will they gain exactly 1 ETH, or something different?

  • Run the full trade cycle by executing:
npx hardhat run scripts/interact.ts --network localhost

You should see the BEFORE TRADE balances, the deposit confirmation, the delivery confirmation, and the AFTER TRADE balances showing the seller gained 1 ETH.

  • Switch to MetaMask and check the seller's account (Account #1). The balance should now show approximately 10,001 ETH (the original 10,000 plus the 1 ETH from the trade).

Why MetaMask matters here

The terminal output already proves the math works. MetaMask adds a second proof point: an independent wallet application querying the same local blockchain confirms the state change. In production, this is how your users experience the escrow. They see their balance update the moment the trade completes.

MetaMask balance not updating?

MetaMask caches balances. Click on the account and pull down to refresh, or switch networks and switch back. Also confirm you are still connected to the Hardhat Local network, not Ethereum Mainnet.

Help me fix this

✔️ Awesome, I've got everything!

Great. Make sure both scripts/deploy.ts and scripts/interact.ts are saved.

ⓧ I'd like to double check the full code

Here is the complete scripts/deploy.ts:

import hre from "hardhat";

async function main() {
  const [buyer, seller, arbiter] = await hre.viem.getWalletClients();

  const deadline = BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);

  const escrow = await hre.viem.deployContract("TradeEscrow", [
    buyer.account.address,
    seller.account.address,
    arbiter.account.address,
    deadline,
  ]);

  console.log("TradeEscrow deployed to:", escrow.address);
  console.log("Buyer:", buyer.account.address);
  console.log("Seller:", seller.account.address);
  console.log("Arbiter:", arbiter.account.address);
  console.log("Deadline:", new Date(Number(deadline) * 1000).toISOString());
}

main().catch(console.error);

Here is the complete scripts/interact.ts (part 1):

import hre from "hardhat";
import { parseEther, formatEther } from "viem";

async function main() {
  const [buyer, seller, arbiter] = await hre.viem.getWalletClients();
  const publicClient = await hre.viem.getPublicClient();

  const deadline = BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);

  console.log("Deploying TradeEscrow...");
  const escrow = await hre.viem.deployContract("TradeEscrow", [
    buyer.account.address,
    seller.account.address,
    arbiter.account.address,
    deadline,
  ]);
  console.log("Deployed to:", escrow.address);

  console.log("\n--- BEFORE TRADE ---");
  const buyerBefore = await publicClient.getBalance({
    address: buyer.account.address,
  });
  const sellerBefore = await publicClient.getBalance({
    address: seller.account.address,
  });
  console.log("Buyer balance:", formatEther(buyerBefore), "ETH");
  console.log("Seller balance:", formatEther(sellerBefore), "ETH");

Part 2 of scripts/interact.ts:

  console.log("\n--- BUYER DEPOSITS 1 ETH ---");
  await escrow.write.deposit({
    value: parseEther("1"),
    account: buyer.account,
  });
  console.log("Deposit successful. Escrow state: AWAITING_DELIVERY");

  console.log("\n--- BUYER CONFIRMS DELIVERY ---");
  await escrow.write.confirmDelivery({ account: buyer.account });
  console.log("Delivery confirmed. Escrow state: COMPLETE");

  console.log("\n--- AFTER TRADE ---");
  const buyerAfter = await publicClient.getBalance({
    address: buyer.account.address,
  });
  const sellerAfter = await publicClient.getBalance({
    address: seller.account.address,
  });
  console.log("Buyer balance:", formatEther(buyerAfter), "ETH");
  console.log("Seller balance:", formatEther(sellerAfter), "ETH");
  console.log(
    "Seller gained:",
    formatEther(sellerAfter - sellerBefore),
    "ETH"
  );
}

main().catch(console.error);

You just watched a trustless trade execute on-chain. No bank. No intermediary. No manual approvals. The Secret Mission challenges you to build a Factory contract that lets any user deploy their own custom escrow in a single transaction.

Secret mission

Build the Escrow Factory

Your escrow contract handles one trade between two parties. But a real platform needs to let any user spin up their own escrow with custom terms. In this mission, you'll build a Factory contract that deploys independent escrow instances in a single transaction.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Local Hardhat blockchain node (running process in terminal).
  • Project folder on your Desktop (contracts, tests, scripts, and node_modules).
  • MetaMask browser extension with imported test accounts.
  • Hardhat Solidity VS Code extension.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your contracts.

  • Leave the npx hardhat node terminal open to continue interacting with your deployed contracts.
  • Your MetaMask test accounts and local network configuration remain available for future transactions.
  • Your project folder, contracts, and test suite are ready for further development at any time.

Pause - I'll come back to this later

Shut down the running blockchain node to free up your terminal. Your code remains on disk and you can restart anytime.

  • In the terminal running npx hardhat node, press Ctrl+C to stop the local blockchain.
  • The local chain state is lost (deployed contracts, balances). Your source code in the project folder is unaffected.
  • To resume later, run npx hardhat node again and redeploy your contracts.

Delete - I don't want to use this again

Remove all project resources and start fresh. Everything runs locally, so there is nothing to delete in the cloud.

  • In the terminal running npx hardhat node, press Ctrl+C to stop the local blockchain.
  • Delete your project folder by running this command from the Desktop:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]

On Windows, use this command instead:

cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your-project-folder-name"]]
  • Remove the MetaMask extension from your browser. In Chrome, right-click the MetaMask icon in your toolbar and select Remove from Chrome. In Firefox, right-click the icon and select Remove Extension.
  • Remove the Hardhat Solidity extension in VS Code. Open the Extensions sidebar (Cmd+Shift+X on macOS, Ctrl+Shift+X on Windows), find Hardhat Solidity, and click Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a production-grade escrow smart contract that automates trade payments on a blockchain. No intermediary, no bank fees, no delays.

You've learned how to:

  • Built a Solidity smart contract with a complete state machine that locks funds in escrow and releases payment only when trade conditions are confirmed on-chain.
  • Secured the contract with OpenZeppelin's ReentrancyGuard and AccessControl, adding reentrancy protection, revocable role-based permissions, and a full event system for audit trails.
  • Deployed to a local blockchain and executed a complete trade cycle, watching real ETH move between MetaMask wallets in real-time after confirming delivery.
  • Secret Mission: Built a Factory contract enabling any user to deploy custom escrows in one transaction, the exact pattern needed for a multi-tenant trade platform.

Ready to quiz yourself?