Build a Secure Delivery Escrow

Build a Solidity delivery escrow with secure payouts and adversarial tests.

Introduction

30 Second Summary

A delivery payment has three people relying on the same promise. The money must stay locked until the agreed delivery stages are complete.

In this project, you will use Claude Code to build a local delivery escrow as an Ethereum smart contract. You will preserve a vulnerable settlement version before securing its state transitions, payouts, and accounting.

What You'll Build

You'll show a reviewer one repository where the vulnerable settlement can be reproduced and the secured version passes every base test with all simulated funds accounted for.

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

  • A visible happy-path order lifecycle where the customer funds an order, the restaurant accepts it, the courier completes delivery, and the intended recipients receive payout credits.
  • Reproducible exploit evidence preserved in Git at the vulnerable-settlement tag, so a reviewer can inspect the unsafe source and replay its failing security tests.
  • A secured seven-test suite in Forge that blocks unauthorized settlement, invalid transitions, repeated payouts, and double withdrawals while proving escrow conservation.
  • Secret Mission: Prove both transaction orderings of the cancellation-versus-courier-acceptance race without duplicating escrow.

Are there any prerequisites?

You'll need existing access to Claude Code plus VS Code, Git, GitHub, and Node.js.

Comfort reading code and tests is enough because the project uses local simulated funds with no wallet or real cryptocurrency. You do not need prior Solidity experience.

Before We Start

Before any code, this step locks in the delivery scenario that every security rule must protect. A customer funds an order, a restaurant accepts it, and a courier delivers it. Correct escrow release prevents simulated funds from reaching the wrong party or being credited twice.

Install and Pin the Local Toolchain

Your delivery scenario now identifies the three participants who depend on correct escrow accounting. The next challenge is making every future security result reproducible.

A pinned Foundry release keeps test behavior stable. A pinned Solidity compiler keeps contract builds consistent. This step also gives Claude Code the security rules it must follow.

In this step, get ready to:
  • Install Foundry 1.8.6 through foundryup.
  • Create a Git repository with pinned compiler settings and forge-std 1.17.0.
  • Add the project instructions and pass the initial Forge smoke test.
Install the pinned Foundry release

Foundry provides the local tools that compile contracts and run adversarial tests. foundryup manages the installed release so this repository can use the verified Intel macOS build of version 1.8.6.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal into Spotlight.
  • Press Enter to open Terminal.
  • Check the installed Forge version by running this command:
forge --version

What does this command do?

The --version option prints the active Forge release. This check separates an exact match from an older installation or a missing command.

✔️ I see version 1.8.6

Your pinned Foundry release is ready. foundryup has already placed Forge in your shell path.

  • Keep this Terminal window open for the Claude Code version check.

ⓧ I see an older version

Your shell can find Forge, but the active release does not match this project. Installing the pinned release replaces the active Foundry toolchain.

Expect the download and hash verification to take a few minutes. The pause means foundryup is checking the release files before installing them.

  • Install Foundry 1.8.6 by running this command:
foundryup --install 1.8.6

What does this command do?

foundryup downloads the requested Foundry release. It checks the downloaded binary hashes against the release attestation before activating the toolchain.

  • Confirm the active release by running the version check again:
forge --version

What should I see?

Forge prints version 1.8.6 in its version information. That output confirms the pinned release is active.

Still seeing a different version?

Reload Zsh with the command shown in the next tab if your current shell still resolves an older installation.

Need help tracing the active installation? Help me find why Forge still reports the wrong version.

ⓧ Command not found

Foundry is not available in your current Zsh path. The installer first adds foundryup before you use it to fetch the pinned release.

  • Install foundryup by running this command:
curl -L https://foundry.paradigm.xyz | bash

What does this command do?

This downloads the official Foundry installer script. The script installs foundryup and updates your shell configuration with its binary location.

  • Reload your Zsh configuration by running this command:
source ~/.zshrc

Why reload Zsh?

Your current Terminal session does not automatically reread changes to ~/.zshrc. Sourcing the file makes the new foundryup path available immediately.

The pinned release download can take a few minutes. foundryup also checks the downloaded binary hashes before activation.

  • Install Foundry 1.8.6 by running this command:
foundryup --install 1.8.6

What gets installed?

foundryup installs Forge with the other Foundry command-line tools. The published release includes an Intel macOS binary for this machine.

  • Verify the active Forge release by running:
forge --version

What should I see?

Forge prints version 1.8.6 in its version information. Your Zsh session can now run the pinned toolchain.

Still seeing command not found?

Close Terminal through its window controls. Reopen it through Spotlight so Zsh starts with the updated configuration.

Need help with the install path? Help me make Foundry available in Zsh.

Forge now covers the contract toolchain. Claude Code also needs to be callable from the same local environment.

  • Confirm Claude Code is available by running:
claude --version

What should I see?

Claude Code prints its installed version number. That response confirms the claude command is available before the repository depends on it.

That is the machine-level setup complete. Your Intel Mac can now run the pinned contract tests and launch Claude Code.

Claude Code command unavailable?

Use the Claude Code setup guide to repair the existing installation. Return here once the version command prints a result.

Need help diagnosing your installation? Help me restore the Claude Code command on macOS.

Create and pin the repository

The repository configuration records the compiler and directory layout used by every test. Git then tracks the vulnerable and secure versions as separate points in history.

  • Click an empty area of your macOS Desktop.
  • Click File in the Finder menu bar.
  • Select New Folder.
  • Type delivery-escrow as the folder name.
  • Press Return to create the folder.
  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Enter to open VS Code.

Keep the repository isolated

The delivery-escrow folder lives on your Desktop outside any existing repository. Its files and Git history remain separate from your other projects.

  • Click File in the VS Code menu bar.
  • Select Open Folder....
  • Select the delivery-escrow folder on your Desktop.
  • Click Open.
  • Select Yes, I trust the authors if Workspace Trust appears.
  • Select the Source Control view in the Activity Bar.
  • Select Initialize Repository.

The Source Control view now treats delivery-escrow as a local repository. No files have been committed or uploaded.

  • Select the Explorer view in the Activity Bar.
  • Select the New File... control beside the delivery-escrow folder.
  • Enter foundry.toml as the file name.
  • Add the pinned project configuration by pasting this code:
[profile.default]
src = "src"
test = "test"
out = "out"
libs = ["lib"]
solc = "0.8.38"

What does this configuration do?

  • The path keys tell Forge where to find contracts and tests.
  • The output keys keep dependencies and generated build files in predictable folders.
  • The solc value pins compilation to Solidity 0.8.38.
  • Save foundry.toml.
  • Confirm the Explorer lists foundry.toml inside delivery-escrow.

Configuration file missing?

Check that the filename ends in .toml. A hidden text extension can prevent Forge from loading the project configuration.

Need help checking the file? Help me verify my Foundry configuration file.

  • Select the New File... control beside the delivery-escrow folder.
  • Enter .gitignore as the file name.
  • Add the generated project directories by pasting this code:
cache/
out/
lib/

Why ignore these folders?

Forge can recreate the build cache and compiled output. The installed test library can also be restored from its pinned release.

Ignoring these directories keeps future commits focused on source code and security evidence.

  • Save .gitignore.
  • Confirm the Explorer lists .gitignore beside foundry.toml.

Ignored files still visible?

A Git ignore rule prevents matching files from being tracked. VS Code can still display those folders in the Explorer.

Need help checking the rules? Help me verify my escrow repository ignore file.

The repository can now resolve a test library without changing the compiler pin. forge-std supplies the assertion helpers used by the smoke test.

  • Click Terminal in the VS Code menu bar.
  • Select New Terminal.

The first dependency download can take a minute while Forge fetches the pinned release. The terminal remains at the delivery-escrow workspace folder.

  • Install forge-std 1.17.0 by running:
forge install --no-git foundry-rs/forge-std@v1.17.0

What does this command do?

Forge installs the tagged forge-std release inside lib. The --no-git flag keeps the dependency out of Git submodule management.

  • Expand the lib folder in the Explorer.
  • Confirm the Explorer shows a forge-std folder inside lib.

Dependency installation failed?

Confirm the terminal prompt is inside the delivery-escrow workspace. Forge needs foundry.toml in that folder.

Need help with the download? Help me install the pinned forge-std release.

Add the project guardrails and smoke test

Project instructions give Claude Code the escrow invariants before feature work begins. The smoke test then proves that the pinned compiler and test library work together.

  • Select the New File... control beside the delivery-escrow folder.
  • Enter CLAUDE.md as the file name.
  • Add the project instructions by pasting this content:
# Delivery Escrow Project Instructions

## Purpose
Build a local educational escrow for a customer, restaurant, and courier. Treat it as security-critical, but never as audited or production-ready.

## Toolchain
- Foundry 1.8.6
- Solidity 0.8.38
- forge-std 1.17.0
- Build with `forge build`.
- Test with `forge test`.
- Debug with `forge test -vvv`.

## Security Invariants
1. Only the restaurant accepts, only the assigned courier records pickup and delivery, and only the customer confirms delivery.
2. Orders follow Funded to RestaurantAccepted to CourierAccepted to PickedUp to Delivered to Settled without skips, repeats, or exits from Settled.
3. In controlled tests, contract balance plus total withdrawn equals total deposited after every successful operation.
4. An address withdraws no more than its credit, and withdrawal clears credit before sending value.

## Rules
- Use custom errors and events.
- Apply checks, then effects, then interactions.
- Use withdrawal-based payouts.
- Never use `tx.origin` for authorization.
- Do not weaken failing security tests.
- Preserve the vulnerable implementation and exploit tests at the annotated `vulnerable-settlement` tag before applying fixes.
- Run the full suite after every fix and document remaining limitations.

What do these instructions control?

The invariants define who can change an order and which state transitions are valid. They also define the accounting and withdrawal properties that later tests must enforce.

Claude Code reads CLAUDE.md as project context. The instructions guide its work without replacing executable tests.

  • Save CLAUDE.md.
  • Confirm the Explorer lists CLAUDE.md at the top level of delivery-escrow.

Instructions not loading later?

Keep CLAUDE.md directly inside delivery-escrow. Claude Code loads the project instructions from that location.

Need help checking placement? Help me verify my Claude Code project instructions.

  • Select the New File... control beside the delivery-escrow folder.
  • Enter README.md as the file name.
  • Add the initial project description by pasting this content:
# Local Delivery Escrow

An educational Ethereum escrow that demonstrates a problem-first workflow: build a happy path, reproduce and preserve settlement exploits, enforce a state machine, and prove accounting properties.

Why start the README now?

The README records the project as an educational Ethereum security exercise from the beginning. Later steps expand it with reproducible exploit evidence and the secure design.

  • Save README.md.
  • Confirm the editor shows the Local Delivery Escrow heading above the project description.

README formatting looks wrong?

Check that the title begins with one hash character. Keep a blank line between the title and description.

Need help comparing the Markdown? Help me correct the initial README.

  • Return to the integrated terminal in VS Code.
  • Start an interactive Claude Code session by running:
claude

What happens when Claude Code starts?

Claude Code starts in the delivery-escrow repository. It loads the project-level instructions from CLAUDE.md.

  • Complete the existing account sign-in flow if Claude Code requests authentication.
  • Ask Claude Code to read the security invariants and summarize the four properties it must preserve.

Claude Code should identify role authorization, ordered state transitions, escrow conservation, and withdrawal limits. That response proves the repository instructions are in context.

  • Select the delivery-escrow folder in the Explorer.
  • Select the New Folder control.
  • Enter src as the folder name.
  • Select the new src folder.
  • Select the New File... control.
  • Enter DeliveryEscrow.sol as the file name.
  • Add the contract placeholder by pasting this code:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

contract DeliveryEscrow {}

What does this placeholder prove?

The pragma requires the pinned Solidity compiler. The empty DeliveryEscrow contract gives Forge a valid source file before the order state machine exists.

  • Save src/DeliveryEscrow.sol.
  • Confirm the Explorer shows DeliveryEscrow.sol inside src.

Contract file showing syntax errors?

Confirm the pragma uses 0.8.38 exactly. Check that the contract has matching braces.

Need help finding the mismatch? Help me compare the contract placeholder.

  • Select the delivery-escrow folder in the Explorer.
  • Select the New Folder control.
  • Enter test as the folder name.
  • Select the new test folder.
  • Select the New File... control.
  • Enter DeliveryEscrow.t.sol as the file name.
  • Add the smoke test by pasting this code:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

import {Test} from "forge-std/Test.sol";

contract DeliveryEscrowTest is Test {
    function test_Smoke() public pure {
        assertTrue(true);
    }
}

What does this test prove?

  • The import proves Forge can resolve the pinned forge-std library.
  • The test_Smoke() naming pattern makes Forge discover the test.
  • The assertion proves the test runner can execute a passing Solidity test.
  • Save test/DeliveryEscrow.t.sol.
  • Confirm the Explorer shows DeliveryEscrow.t.sol inside test.
  • Create another workspace terminal through Terminal in the VS Code menu bar.
  • Select New Terminal.

Before you run the suite, predict whether the compiler pin and test-library pin can produce one passing test.

  • Compile the placeholder contract and run the smoke test with:
forge test

What does this verification prove?

Forge compiles the contract with Solidity 0.8.38. It then discovers the smoke test through forge-std and executes it locally.

You should see one passing test with no failures. The repository now has a reproducible local test loop.

Smoke test not passing?

If Forge cannot find the import, confirm lib/forge-std exists. If compilation fails, compare the pragma in both Solidity files with the compiler pin in foundry.toml.

Need help reading the test failure? Help me debug my initial Forge smoke test.

You have the first concrete success of the project. The pinned compiler and test library now execute Solidity tests inside a Git-tracked repository.

✔️ Awesome, I've got everything!

Great. Save every open file and keep the Claude Code session available for the next step.

ⓧ I'd like to double check the full code

Check foundry.toml
[profile.default]
src = "src"
test = "test"
out = "out"
libs = ["lib"]
solc = "0.8.38"

What to compare

Confirm the paths and Solidity pin match exactly. This file controls how Forge compiles and locates the project.

Check .gitignore
cache/
out/
lib/

What to compare

Confirm each generated directory has its own line. These rules keep reproducible build artifacts out of Git history.

Check CLAUDE.md
# Delivery Escrow Project Instructions

## Purpose
Build a local educational escrow for a customer, restaurant, and courier. Treat it as security-critical, but never as audited or production-ready.

## Toolchain
- Foundry 1.8.6
- Solidity 0.8.38
- forge-std 1.17.0
- Build with `forge build`.
- Test with `forge test`.
- Debug with `forge test -vvv`.

## Security Invariants
1. Only the restaurant accepts, only the assigned courier records pickup and delivery, and only the customer confirms delivery.
2. Orders follow Funded to RestaurantAccepted to CourierAccepted to PickedUp to Delivered to Settled without skips, repeats, or exits from Settled.
3. In controlled tests, contract balance plus total withdrawn equals total deposited after every successful operation.
4. An address withdraws no more than its credit, and withdrawal clears credit before sending value.

## Rules
- Use custom errors and events.
- Apply checks, then effects, then interactions.
- Use withdrawal-based payouts.
- Never use `tx.origin` for authorization.
- Do not weaken failing security tests.
- Preserve the vulnerable implementation and exploit tests at the annotated `vulnerable-settlement` tag before applying fixes.
- Run the full suite after every fix and document remaining limitations.

What to compare

Confirm all four invariants and every project rule are present. Claude Code uses this file as repository context.

Check README.md
# Local Delivery Escrow

An educational Ethereum escrow that demonstrates a problem-first workflow: build a happy path, reproduce and preserve settlement exploits, enforce a state machine, and prove accounting properties.

What to compare

Confirm the repository title and initial problem-first description match exactly. Later steps extend this document with security evidence.

Check the contract placeholder
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

contract DeliveryEscrow {}

What to compare

Confirm the compiler pragma and empty contract declaration match exactly. The state machine arrives in the next step.

Check the smoke test
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

import {Test} from "forge-std/Test.sol";

contract DeliveryEscrowTest is Test {
    function test_Smoke() public pure {
        assertTrue(true);
    }
}

What to compare

Confirm the import and smoke-test function match exactly. This is the test that produces the first passing result.

Your local security lab is ready. Next, you will replace the placeholder with a working delivery order state machine and watch its happy path settle simulated funds.

Build the Happy-Path Order State Machine

Your pinned Foundry project already passes its smoke test. You now have a reproducible place to model the delivery flow.

A working order flow creates the baseline that later security tests can challenge. This step turns the placeholder into a Solidity escrow with distinct participant roles plus observable payout credits.

In this step, get ready to:
  • Define the escrow state model.
  • Implement funding plus each forward lifecycle transition.
  • Prove the happy path with a passing Forge test.
Define the escrow data model

A finite-state machine gives every order one current stage. Stored role addresses identify who participates in that order.

The contract also needs payment allocations plus accounting totals. Events provide a record of important lifecycle changes.

  • Select src/DeliveryEscrow.sol in the VS Code Explorer sidebar.
  • Replace the placeholder contract with the code below:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

contract DeliveryEscrow {
    enum OrderState { None, Funded, RestaurantAccepted, CourierAccepted, PickedUp, Delivered, Settled }
    struct Order { address customer; address restaurant; address courier; uint256 restaurantPayment; uint256 courierPayment; OrderState state; }

    error InvalidAddress(); error InvalidAmount(); error RoleConflict(); error Unauthorized(); error InvalidState(OrderState expected, OrderState actual);
    event OrderCreated(uint256 indexed orderId, address indexed customer, address indexed restaurant, uint256 restaurantPayment, uint256 courierPayment);
    event OrderStateChanged(uint256 indexed orderId, OrderState previousState, OrderState newState, address indexed actor);
    event PayoutCredited(address indexed recipient, uint256 amount);

    mapping(uint256 => Order) private orders;
    mapping(address => uint256) public withdrawable;
    uint256 public nextOrderId = 1;
    uint256 public totalDeposited;
    uint256 public totalWithdrawn;

    function getOrder(uint256 orderId) external view returns (Order memory) { return orders[orderId]; }
    function accountingInvariantHolds() external view returns (bool) { return address(this).balance + totalWithdrawn == totalDeposited; }
}

What does this code establish?

  • OrderState identifies the seven stages available to an order.
  • Order stores the three participant addresses plus the two payment allocations.
  • orders stores each order under its numeric identifier.
  • withdrawable records payout credit for each recipient.
  • Custom errors describe rejected operations without long error strings.
  • Events expose order creation plus state changes plus payout credits.
  • accountingInvariantHolds() compares the contract balance plus withdrawals against all deposits.
  • Save src/DeliveryEscrow.sol.
  • Verify the data model by running this command in the terminal from earlier:
forge test

What does this check prove?

The command recompiles the expanded contract before running the existing smoke test. A passing result proves the data model is valid inside the pinned project.

Good progress. The smoke test still passes with the escrow data model in place.

Does the contract fail to compile?

Confirm that OrderState appears before Order because the struct uses that enum type.

Check that the contract has one final closing brace.

Still blocked? Help me compare my escrow data model with the required Solidity structure.

Implement the naive lifecycle

The forward lifecycle records restaurant acceptance through delivery. For this baseline, confirmDelivery() credits both recipients without checking the caller.

It also skips the delivery-state guard. It leaves the order open to repeated credits.

  • Find the getOrder() function in src/DeliveryEscrow.sol.
  • Insert the order-creation function immediately above getOrder() using the code below:
    function createOrder(address restaurant, uint256 restaurantPayment, uint256 courierPayment) external payable returns (uint256 orderId) {
        if (restaurant == address(0)) revert InvalidAddress();
        if (restaurant == msg.sender) revert RoleConflict();
        if (restaurantPayment == 0 || courierPayment == 0 || msg.value != restaurantPayment + courierPayment) revert InvalidAmount();
        orderId = nextOrderId++;
        orders[orderId] = Order(msg.sender, restaurant, address(0), restaurantPayment, courierPayment, OrderState.Funded);
        totalDeposited += msg.value;
        emit OrderCreated(orderId, msg.sender, restaurant, restaurantPayment, courierPayment);
        emit OrderStateChanged(orderId, OrderState.None, OrderState.Funded, msg.sender);
    }

How does order creation work?

  • The address checks reject a zero restaurant address plus a customer acting as the restaurant.
  • The amount check requires two positive allocations plus an exact deposit.
  • nextOrderId supplies a unique identifier for the new order.
  • The new order begins in OrderState.Funded with no courier assigned.
  • totalDeposited records the value entering the escrow.
  • Save src/DeliveryEscrow.sol.
  • Verify order creation compiles by running:
forge test

What does this check prove?

The command compiles the payable creation path with the existing storage model. The smoke test should remain green.

The passing test confirms the contract can now accept a correctly allocated escrow deposit.

Does order creation fail to compile?

Make sure createOrder() sits inside the DeliveryEscrow contract.

Check that the function appears above getOrder().

Need another set of eyes? Help me debug the createOrder function in my delivery escrow.

A funded order now needs guarded forward transitions for the restaurant plus the courier. Shared helpers keep the state updates consistent.

  • Find the line that starts with function getOrder.
  • Replace that line through the contract's final } with the code below:
    function getOrder(uint256 orderId) external view returns (Order memory) { return orders[orderId]; }
    function restaurantAccept(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.restaurant) revert Unauthorized(); _requireState(order, OrderState.Funded); _transition(orderId, order, OrderState.RestaurantAccepted); }
    function courierAccept(uint256 orderId) external { Order storage order = orders[orderId]; _requireState(order, OrderState.RestaurantAccepted); if (msg.sender == order.customer || msg.sender == order.restaurant) revert RoleConflict(); order.courier = msg.sender; _transition(orderId, order, OrderState.CourierAccepted); }
    function markPickedUp(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.CourierAccepted); _transition(orderId, order, OrderState.PickedUp); }
    function markDelivered(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.PickedUp); _transition(orderId, order, OrderState.Delivered); }
    function confirmDelivery(uint256 orderId) external { Order storage order = orders[orderId]; withdrawable[order.restaurant] += order.restaurantPayment; withdrawable[order.courier] += order.courierPayment; emit PayoutCredited(order.restaurant, order.restaurantPayment); emit PayoutCredited(order.courier, order.courierPayment); }
    function accountingInvariantHolds() external view returns (bool) { return address(this).balance + totalWithdrawn == totalDeposited; }
    function _requireState(Order storage order, OrderState expected) internal view { if (order.state != expected) revert InvalidState(expected, order.state); }
    function _transition(uint256 orderId, Order storage order, OrderState nextState) internal { OrderState previousState = order.state; order.state = nextState; emit OrderStateChanged(orderId, previousState, nextState, msg.sender); }
}

How does the lifecycle move forward?

  • restaurantAccept() restricts the first acceptance to the stored restaurant.
  • courierAccept() assigns an eligible caller as the courier.
  • markPickedUp() plus markDelivered() restrict delivery updates to the assigned courier.
  • confirmDelivery() credits the two payout balances without changing the order state.
  • _requireState() rejects a transition from the wrong stage.
  • _transition() changes the state before emitting its event.
  • Save src/DeliveryEscrow.sol.
  • Verify the full lifecycle compiles by running:
forge test

What does this check prove?

The command compiles every lifecycle function plus both transition helpers. The existing smoke test confirms the project remains runnable.

That is the contract baseline complete. Every forward lifecycle function now compiles with the deliberately naive settlement path.

Does a lifecycle function fail to compile?

Confirm that _requireState() plus _transition() appear before the contract's final closing brace.

Check that each function uses Order storage order with the same lowercase variable name.

Still seeing a compiler failure? Help me trace the mismatch in my escrow lifecycle functions.

✔️ Awesome, I've got everything!

Your completed src/DeliveryEscrow.sol file is saved.

ⓧ I'd like to double check the full code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

contract DeliveryEscrow {
    enum OrderState { None, Funded, RestaurantAccepted, CourierAccepted, PickedUp, Delivered, Settled }
    struct Order { address customer; address restaurant; address courier; uint256 restaurantPayment; uint256 courierPayment; OrderState state; }

    error InvalidAddress(); error InvalidAmount(); error RoleConflict(); error Unauthorized(); error InvalidState(OrderState expected, OrderState actual);
    event OrderCreated(uint256 indexed orderId, address indexed customer, address indexed restaurant, uint256 restaurantPayment, uint256 courierPayment);
    event OrderStateChanged(uint256 indexed orderId, OrderState previousState, OrderState newState, address indexed actor);
    event PayoutCredited(address indexed recipient, uint256 amount);

    mapping(uint256 => Order) private orders;
    mapping(address => uint256) public withdrawable;
    uint256 public nextOrderId = 1;
    uint256 public totalDeposited;
    uint256 public totalWithdrawn;

    function createOrder(address restaurant, uint256 restaurantPayment, uint256 courierPayment) external payable returns (uint256 orderId) {
        if (restaurant == address(0)) revert InvalidAddress();
        if (restaurant == msg.sender) revert RoleConflict();
        if (restaurantPayment == 0 || courierPayment == 0 || msg.value != restaurantPayment + courierPayment) revert InvalidAmount();
        orderId = nextOrderId++;
        orders[orderId] = Order(msg.sender, restaurant, address(0), restaurantPayment, courierPayment, OrderState.Funded);
        totalDeposited += msg.value;
        emit OrderCreated(orderId, msg.sender, restaurant, restaurantPayment, courierPayment);
        emit OrderStateChanged(orderId, OrderState.None, OrderState.Funded, msg.sender);
    }
    function getOrder(uint256 orderId) external view returns (Order memory) { return orders[orderId]; }
    function restaurantAccept(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.restaurant) revert Unauthorized(); _requireState(order, OrderState.Funded); _transition(orderId, order, OrderState.RestaurantAccepted); }
    function courierAccept(uint256 orderId) external { Order storage order = orders[orderId]; _requireState(order, OrderState.RestaurantAccepted); if (msg.sender == order.customer || msg.sender == order.restaurant) revert RoleConflict(); order.courier = msg.sender; _transition(orderId, order, OrderState.CourierAccepted); }
    function markPickedUp(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.CourierAccepted); _transition(orderId, order, OrderState.PickedUp); }
    function markDelivered(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.PickedUp); _transition(orderId, order, OrderState.Delivered); }
    function confirmDelivery(uint256 orderId) external { Order storage order = orders[orderId]; withdrawable[order.restaurant] += order.restaurantPayment; withdrawable[order.courier] += order.courierPayment; emit PayoutCredited(order.restaurant, order.restaurantPayment); emit PayoutCredited(order.courier, order.courierPayment); }
    function accountingInvariantHolds() external view returns (bool) { return address(this).balance + totalWithdrawn == totalDeposited; }
    function _requireState(Order storage order, OrderState expected) internal view { if (order.state != expected) revert InvalidState(expected, order.state); }
    function _transition(uint256 orderId, Order storage order, OrderState nextState) internal { OrderState previousState = order.state; order.state = nextState; emit OrderStateChanged(orderId, previousState, nextState, msg.sender); }
}
Test the complete happy path

A happy-path test assigns separate addresses to the customer plus restaurant plus courier. It drives one funded order through each expected transition.

The final assertions prove that settlement credits the intended amounts. The accounting assertion confirms the deposit remains conserved inside the local simulation.

  • Select test/DeliveryEscrow.t.sol in the VS Code Explorer sidebar.
  • Replace the smoke test with the complete happy-path test below:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;
import {Test} from "forge-std/Test.sol";
import {DeliveryEscrow} from "../src/DeliveryEscrow.sol";

contract DeliveryEscrowTest is Test {
    DeliveryEscrow internal escrow;
    address internal constant CUSTOMER = address(0xC0FFEE);
    address internal constant RESTAURANT = address(0xBEEF);
    address internal constant COURIER = address(0xCAFE);
    uint256 internal constant RESTAURANT_PAYMENT = 1 ether;
    uint256 internal constant COURIER_PAYMENT = 0.25 ether;
    uint256 internal constant TOTAL_PAYMENT = RESTAURANT_PAYMENT + COURIER_PAYMENT;

    function setUp() public { escrow = new DeliveryEscrow(); vm.deal(CUSTOMER, 10 ether); }
    function test_HappyPathCreditsPayoutsAndConservesEscrow() public {
        vm.prank(CUSTOMER); uint256 id = escrow.createOrder{value: TOTAL_PAYMENT}(RESTAURANT, RESTAURANT_PAYMENT, COURIER_PAYMENT);
        vm.prank(RESTAURANT); escrow.restaurantAccept(id);
        vm.prank(COURIER); escrow.courierAccept(id);
        vm.prank(COURIER); escrow.markPickedUp(id);
        vm.prank(COURIER); escrow.markDelivered(id);
        escrow.confirmDelivery(id);
        assertEq(escrow.withdrawable(RESTAURANT), RESTAURANT_PAYMENT);
        assertEq(escrow.withdrawable(COURIER), COURIER_PAYMENT);
        assertTrue(escrow.accountingInvariantHolds());
    }
}

What does this test prove?

  • setUp() deploys a fresh escrow before the test.
  • vm.deal() gives the customer simulated funds.
  • vm.prank() makes the next contract call come from a selected participant.
  • The lifecycle calls move the order from funding through recorded delivery.
  • The payout assertions compare each credit against its allocated amount.
  • The final invariant assertion checks deposit conservation.
  • Save test/DeliveryEscrow.t.sol.

Before you run the suite, do you expect the role-specific lifecycle to reach payout credit without breaking the accounting invariant?

  • Run the complete happy-path test with this command:
forge test

What does the final test check?

The command compiles both project files before executing the happy-path test. A passing result means every assertion completed without a revert.

You should see one passing test with no failures. The result proves that the restaurant receives its 1 ether credit.

The courier receives its 0.25 ether credit. The escrow accounting equation also remains true.

Does the happy-path test fail?

Check that every vm.prank() call appears immediately before the lifecycle call it controls.

Confirm that TOTAL_PAYMENT equals the two payout constants.

Need help reading the failure? Help me trace my happy-path escrow test from the first failing call.

✔️ Awesome, I've got everything!

Your happy-path test file matches the working baseline.

ⓧ I'd like to double check the full code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;
import {Test} from "forge-std/Test.sol";
import {DeliveryEscrow} from "../src/DeliveryEscrow.sol";

contract DeliveryEscrowTest is Test {
    DeliveryEscrow internal escrow;
    address internal constant CUSTOMER = address(0xC0FFEE);
    address internal constant RESTAURANT = address(0xBEEF);
    address internal constant COURIER = address(0xCAFE);
    uint256 internal constant RESTAURANT_PAYMENT = 1 ether;
    uint256 internal constant COURIER_PAYMENT = 0.25 ether;
    uint256 internal constant TOTAL_PAYMENT = RESTAURANT_PAYMENT + COURIER_PAYMENT;

    function setUp() public { escrow = new DeliveryEscrow(); vm.deal(CUSTOMER, 10 ether); }
    function test_HappyPathCreditsPayoutsAndConservesEscrow() public {
        vm.prank(CUSTOMER); uint256 id = escrow.createOrder{value: TOTAL_PAYMENT}(RESTAURANT, RESTAURANT_PAYMENT, COURIER_PAYMENT);
        vm.prank(RESTAURANT); escrow.restaurantAccept(id);
        vm.prank(COURIER); escrow.courierAccept(id);
        vm.prank(COURIER); escrow.markPickedUp(id);
        vm.prank(COURIER); escrow.markDelivered(id);
        escrow.confirmDelivery(id);
        assertEq(escrow.withdrawable(RESTAURANT), RESTAURANT_PAYMENT);
        assertEq(escrow.withdrawable(COURIER), COURIER_PAYMENT);
        assertTrue(escrow.accountingInvariantHolds());
    }
}

You now have a reproducible happy-path baseline with participant roles plus conserved escrow accounting. Next, you will write adversarial tests that expose the naive settlement gaps.

Reproduce and Preserve the Settlement Exploit

Your happy-path test proves the intended delivery flow can reach payout credit. The naive confirmDelivery function still leaves the escrow state machine exposed.

A security fix is more credible when reviewers can reproduce the original failure from immutable repository history. You will encode three adversarial expectations with Forge before preserving the vulnerable snapshot with Git.

In this step, get ready to:
  • Add adversarial expectations for unauthorized settlement, premature confirmation, and repeatable payout credit.
  • Record the three root causes in README.md.
  • Commit the vulnerable evidence under the annotated vulnerable-settlement tag.
Add adversarial settlement tests

Adversarial tests express security requirements as executable expectations. Each new test asks the naive settlement function to reject a dangerous action.

  • In test/DeliveryEscrow.t.sol, find the COURIER constant.
  • Add the attacker identity immediately below it by copying this line:
    address internal constant ATTACKER = address(0xBAD);

What does this line add?

The ATTACKER address represents a caller with no role in the order. The unauthorized-settlement test uses it to challenge the missing caller check.

  • Save test/DeliveryEscrow.t.sol.
  • Confirm the original happy path still passes by running this command:
forge test

What should you see?

You should see the existing happy-path test pass. Good progress. The attacker identity is now available without changing the contract's current behavior.

Seeing a compilation error?

Check that the ATTACKER line sits inside DeliveryEscrowTest. Confirm that the address literal matches the snippet exactly.

Still blocked? Help me fix the attacker constant in my Forge test.

Reusable setup helpers keep every exploit test focused on one security property. They also ensure each test constructs the same order lifecycle.

  • Find the final closing brace in test/DeliveryEscrow.t.sol.
  • Insert the reusable setup helpers before that brace by copying this code:
    function _createOrder() internal returns (uint256 id) { vm.prank(CUSTOMER); id = escrow.createOrder{value: TOTAL_PAYMENT}(RESTAURANT, RESTAURANT_PAYMENT, COURIER_PAYMENT); }
    function _createDeliveredOrder() internal returns (uint256 id) {
        id = _createOrder(); vm.prank(RESTAURANT); escrow.restaurantAccept(id); vm.prank(COURIER); escrow.courierAccept(id);
        vm.prank(COURIER); escrow.markPickedUp(id); vm.prank(COURIER); escrow.markDelivered(id);
    }
    function _settle(uint256 id) internal { vm.prank(CUSTOMER); escrow.confirmDelivery(id); }

What do these helpers prepare?

  • The _createOrder() helper funds a new order as CUSTOMER.
  • The _createDeliveredOrder() helper advances an order through delivery.
  • The _settle() helper calls confirmDelivery as the customer.
  • Save test/DeliveryEscrow.t.sol.
  • Verify the helpers compile by running the test suite again:
forge test

What does this check prove?

You should still see the happy-path test pass. The helpers now compile against the naive contract.

Do the helpers fail to compile?

Confirm that all three helpers remain inside DeliveryEscrowTest. Check that they appear before the contract's final closing brace.

Need another pair of eyes? Help me debug the setup helpers in my Solidity test.

The next three tests encode the settlement protections that the contract should enforce. They cover caller authorization, lifecycle ordering, and one-time payout credit.

  • Find the _createOrder() helper near the bottom of test/DeliveryEscrow.t.sol.
  • Insert the three adversarial tests immediately above that helper by copying this code:
    function test_RevertWhenUnauthorizedCallerSettles() public { uint256 id = _createDeliveredOrder(); vm.prank(ATTACKER); vm.expectRevert(DeliveryEscrow.Unauthorized.selector); escrow.confirmDelivery(id); }
    function test_RevertWhenCustomerConfirmsBeforeDelivery() public { uint256 id = _createOrder(); vm.prank(CUSTOMER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.confirmDelivery(id); }
    function test_RevertWhenOrderSettlesTwice() public { uint256 id = _createDeliveredOrder(); _settle(id); vm.prank(CUSTOMER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.confirmDelivery(id); }

What do these tests challenge?

  • The unauthorized-caller test expects confirmDelivery to reject ATTACKER.
  • The premature-confirmation test expects settlement to reject an order that has only reached Funded.
  • The double-settlement test expects a completed settlement to reject another payout credit.
  • Save test/DeliveryEscrow.t.sol.

Before you run the suite, do you think the naive confirmDelivery function can satisfy these three expectations?

  • Expose the settlement gaps with detailed traces by running this command:
forge test -vvv

The failures are the evidence

You should see the happy-path test pass. You should also see all three adversarial tests fail.

The unauthorized call succeeds because confirmDelivery does not check msg.sender. The premature call succeeds because the function does not require Delivered.

The repeated call succeeds because settlement never changes the order to Settled. Each successful call credits the payouts again.

Do you see a different result?

Confirm that the contract still contains the naive confirmDelivery implementation. A caller guard or state transition added early changes the designed result.

Check that the three test names begin with test_ so Forge discovers them.

Need help reading the traces? Help me interpret my failing settlement tests.

Your cumulative test file now preserves the happy path beside three executable exploit expectations.

✔️ Awesome, I've got everything!

Great. Save test/DeliveryEscrow.t.sol with the three failing adversarial tests intact.

ⓧ I'd like to double check the full code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;
import {Test} from "forge-std/Test.sol";
import {DeliveryEscrow} from "../src/DeliveryEscrow.sol";

contract DeliveryEscrowTest is Test {
    DeliveryEscrow internal escrow;
    address internal constant CUSTOMER = address(0xC0FFEE);
    address internal constant RESTAURANT = address(0xBEEF);
    address internal constant COURIER = address(0xCAFE);
    address internal constant ATTACKER = address(0xBAD);
    uint256 internal constant RESTAURANT_PAYMENT = 1 ether;
    uint256 internal constant COURIER_PAYMENT = 0.25 ether;
    uint256 internal constant TOTAL_PAYMENT = RESTAURANT_PAYMENT + COURIER_PAYMENT;

    function setUp() public { escrow = new DeliveryEscrow(); vm.deal(CUSTOMER, 10 ether); }
    function test_HappyPathCreditsPayoutsAndConservesEscrow() public {
        vm.prank(CUSTOMER); uint256 id = escrow.createOrder{value: TOTAL_PAYMENT}(RESTAURANT, RESTAURANT_PAYMENT, COURIER_PAYMENT);
        vm.prank(RESTAURANT); escrow.restaurantAccept(id);
        vm.prank(COURIER); escrow.courierAccept(id);
        vm.prank(COURIER); escrow.markPickedUp(id);
        vm.prank(COURIER); escrow.markDelivered(id);
        escrow.confirmDelivery(id);
        assertEq(escrow.withdrawable(RESTAURANT), RESTAURANT_PAYMENT);
        assertEq(escrow.withdrawable(COURIER), COURIER_PAYMENT);
        assertTrue(escrow.accountingInvariantHolds());
    }
    function test_RevertWhenUnauthorizedCallerSettles() public { uint256 id = _createDeliveredOrder(); vm.prank(ATTACKER); vm.expectRevert(DeliveryEscrow.Unauthorized.selector); escrow.confirmDelivery(id); }
    function test_RevertWhenCustomerConfirmsBeforeDelivery() public { uint256 id = _createOrder(); vm.prank(CUSTOMER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.confirmDelivery(id); }
    function test_RevertWhenOrderSettlesTwice() public { uint256 id = _createDeliveredOrder(); _settle(id); vm.prank(CUSTOMER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.confirmDelivery(id); }
    function _createOrder() internal returns (uint256 id) { vm.prank(CUSTOMER); id = escrow.createOrder{value: TOTAL_PAYMENT}(RESTAURANT, RESTAURANT_PAYMENT, COURIER_PAYMENT); }
    function _createDeliveredOrder() internal returns (uint256 id) {
        id = _createOrder(); vm.prank(RESTAURANT); escrow.restaurantAccept(id); vm.prank(COURIER); escrow.courierAccept(id);
        vm.prank(COURIER); escrow.markPickedUp(id); vm.prank(COURIER); escrow.markDelivered(id);
    }
    function _settle(uint256 id) internal { vm.prank(CUSTOMER); escrow.confirmDelivery(id); }
}

How to use this reference

Compare this file with your saved test file. Keep the vulnerable expectations unchanged because the annotated tag must preserve the exact evidence.

Document the root causes

Failing traces show what happened during execution. The repository also needs a concise explanation that connects each failure to its missing security control.

  • In README.md, find the opening project description.
  • Add the vulnerable-evidence section immediately below that description by copying this block:
## Vulnerable Evidence
The annotated tag `vulnerable-settlement` preserves the naive settlement implementation and deliberately failing adversarial expectations. Its root causes are missing caller authorization, missing Delivered-state enforcement, and repeatable payout credit.

Why record the root causes?

The new section connects each failing expectation to a missing guard. A reviewer can now understand the exploit before inspecting the contract diff.

  • Save README.md.
  • Confirm the file shows the Vulnerable Evidence heading below the opening description.

The README now names the authorization gap, the state-enforcement gap, and the repeatable-credit gap.

✔️ Awesome, I've got everything!

Your README now explains why the three adversarial expectations fail.

ⓧ I'd like to double check the full code

# Local Delivery Escrow

An educational Ethereum escrow that demonstrates a problem-first workflow: build a happy path, reproduce and preserve settlement exploits, enforce a state machine, and prove accounting properties.

## Vulnerable Evidence
The annotated tag `vulnerable-settlement` preserves the naive settlement implementation and deliberately failing adversarial expectations. Its root causes are missing caller authorization, missing Delivered-state enforcement, and repeatable payout credit.

How to use this reference

Compare the complete file with your saved README. The vulnerable-evidence paragraph should appear directly after the project description.

Commit and tag the vulnerable evidence

An annotated tag gives the vulnerable snapshot a durable name plus a tag message. It points reviewers to the exact contract, tests, and explanation that existed before the security fix.

  • Stage the vulnerable contract and its evidence by running this command:
git add .

What does staging preserve?

This stages the unchanged naive contract beside the failing tests and README evidence. The next commit records those files as one reproducible snapshot.

  • Create the vulnerable-evidence commit by running this command:
git commit -m "Reproduce vulnerable settlement"

What should you see?

You should see a new commit summary naming the vulnerable-settlement reproduction. That commit now holds the exact evidence you tested.

Did the commit stop?

If Git asks for author information, follow the configuration instructions printed in your terminal. Run the commit command again after Git accepts your identity.

If Git reports no changes to commit, confirm that test/DeliveryEscrow.t.sol and README.md are saved.

Still stuck? Help me complete the vulnerable-evidence commit.

  • Attach the annotated evidence tag to the current commit by running this command:
git tag -a vulnerable-settlement -m "Vulnerable settlement with reproducible exploit tests"

What does the tag preserve?

The vulnerable-settlement tag points to the commit you just created. Its annotation records a message that identifies the snapshot as reproducible exploit evidence.

Before you run the final checks, do you expect the preserved suite to pass or expose the three settlement gaps?

  • Reproduce the vulnerable test result from the tagged repository state by running this command:
forge test -vvv

What does the final test run prove?

You should see the happy path pass. The unauthorized-settlement, premature-confirmation, and double-settlement expectations should fail.

That result proves the vulnerable evidence remains reproducible at the moment you created the tag.

Before you inspect the tag, do you expect it to resolve to the vulnerable-evidence commit you just created?

  • Inspect the annotated tag and its referenced commit by running this command:
git show vulnerable-settlement

What should the tag show?

You should see the annotated tag information plus the vulnerable-evidence commit. The displayed changes should include the adversarial tests and README root-cause section.

That is the hard part captured. Reviewers can now reproduce the original settlement exploit from immutable repository history.

Does the tag fail to resolve?

Confirm that the tag name is spelled vulnerable-settlement everywhere. A different spelling creates a separate tag reference.

If Git says the tag already exists, avoid replacing it until you confirm what commit it references. Preserving the exact vulnerable snapshot is the goal.

Need help checking the tag? Help me verify my annotated vulnerable-settlement tag.

Your repository now preserves the vulnerable settlement path as reproducible evidence. Next, you will turn the failed authorization and transition expectations into passing security policy.

Enforce Roles and Valid State Transitions

Your exploit tests now preserve the naive settlement failure in Git history at vulnerable-settlement.

The working contract can still credit payouts from the wrong caller. It can also settle from the wrong state.

A guarded state machine makes each invariant executable. Solidity checks the caller plus the current state before allowing a transition.

In this step, get ready to:
  • Enforce role authorization on every order transition.
  • Prove exact lifecycle states through invalid-transition tests.
  • Compare the secure contract with the preserved vulnerable tag.
Add role and state guards

A guarded transition verifies the caller before changing an order. It also requires the order to be at one exact lifecycle stage.

  • Switch back to src/DeliveryEscrow.sol in VS Code.
  • Replace the file contents with the secured state-machine implementation below:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

contract DeliveryEscrow {
    enum OrderState { None, Funded, RestaurantAccepted, CourierAccepted, PickedUp, Delivered, Settled }
    struct Order { address customer; address restaurant; address courier; uint256 restaurantPayment; uint256 courierPayment; OrderState state; }
    error InvalidAddress(); error InvalidAmount(); error RoleConflict(); error Unauthorized(); error InvalidState(OrderState expected, OrderState actual);
    event OrderCreated(uint256 indexed orderId, address indexed customer, address indexed restaurant, uint256 restaurantPayment, uint256 courierPayment);
    event OrderStateChanged(uint256 indexed orderId, OrderState previousState, OrderState newState, address indexed actor);
    event PayoutCredited(address indexed recipient, uint256 amount);
    mapping(uint256 => Order) private orders; mapping(address => uint256) public withdrawable;
    uint256 public nextOrderId = 1; uint256 public totalDeposited; uint256 public totalWithdrawn;
    function createOrder(address restaurant, uint256 restaurantPayment, uint256 courierPayment) external payable returns (uint256 orderId) { if (restaurant == address(0)) revert InvalidAddress(); if (restaurant == msg.sender) revert RoleConflict(); if (restaurantPayment == 0 || courierPayment == 0 || msg.value != restaurantPayment + courierPayment) revert InvalidAmount(); orderId = nextOrderId++; orders[orderId] = Order(msg.sender, restaurant, address(0), restaurantPayment, courierPayment, OrderState.Funded); totalDeposited += msg.value; emit OrderCreated(orderId, msg.sender, restaurant, restaurantPayment, courierPayment); emit OrderStateChanged(orderId, OrderState.None, OrderState.Funded, msg.sender); }
    function getOrder(uint256 orderId) external view returns (Order memory) { return orders[orderId]; }
    function restaurantAccept(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.restaurant) revert Unauthorized(); _requireState(order, OrderState.Funded); _transition(orderId, order, OrderState.RestaurantAccepted); }
    function courierAccept(uint256 orderId) external { Order storage order = orders[orderId]; _requireState(order, OrderState.RestaurantAccepted); if (msg.sender == order.customer || msg.sender == order.restaurant) revert RoleConflict(); order.courier = msg.sender; _transition(orderId, order, OrderState.CourierAccepted); }
    function markPickedUp(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.CourierAccepted); _transition(orderId, order, OrderState.PickedUp); }
    function markDelivered(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.PickedUp); _transition(orderId, order, OrderState.Delivered); }
    function confirmDelivery(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.customer) revert Unauthorized(); _requireState(order, OrderState.Delivered); _transition(orderId, order, OrderState.Settled); withdrawable[order.restaurant] += order.restaurantPayment; withdrawable[order.courier] += order.courierPayment; emit PayoutCredited(order.restaurant, order.restaurantPayment); emit PayoutCredited(order.courier, order.courierPayment); }
    function accountingInvariantHolds() external view returns (bool) { return address(this).balance + totalWithdrawn == totalDeposited; }
    function _requireState(Order storage order, OrderState expected) internal view { if (order.state != expected) revert InvalidState(expected, order.state); }
    function _transition(uint256 orderId, Order storage order, OrderState nextState) internal { OrderState previousState = order.state; order.state = nextState; emit OrderStateChanged(orderId, previousState, nextState, msg.sender); }
}

What makes this state machine secure?

  • The restaurantAccept() function accepts only restaurant calls from Funded.
  • The courierAccept() function starts only from RestaurantAccepted. It rejects the customer as courier.
  • The same courier-role check rejects the restaurant as courier.
  • The markPickedUp() function accepts only the assigned courier from CourierAccepted.
  • The markDelivered() function accepts only the assigned courier from PickedUp.
  • The confirmDelivery() function accepts only the customer from Delivered. The transition to Settled happens before payout credit.
  • Save src/DeliveryEscrow.sol.

Before you run the suite, consider whether the three exploit tests now pass.

  • Test the secured contract by running:
forge test -vvv

What does this test run prove?

The command runs the full test suite with verbose traces. The existing adversarial expectations now exercise the new authorization guard plus the exact state guard.

Your Foundry suite should report four passing tests. The happy path still works.

That closes the original exploit paths. Unauthorized settlement no longer reaches payout credit.

Still seeing a failing exploit test?

Check that confirmDelivery() calls _transition() before updating withdrawable.

Confirm that the function checks msg.sender against order.customer.

Need another pair of eyes? Help me diagnose my failing settlement security tests.

✔️ Awesome, I've got everything!

Your secured contract is saved. Its original vulnerable form remains available through the annotated tag.

ⓧ I'd like to double check the full code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

contract DeliveryEscrow {
    enum OrderState { None, Funded, RestaurantAccepted, CourierAccepted, PickedUp, Delivered, Settled }
    struct Order { address customer; address restaurant; address courier; uint256 restaurantPayment; uint256 courierPayment; OrderState state; }
    error InvalidAddress(); error InvalidAmount(); error RoleConflict(); error Unauthorized(); error InvalidState(OrderState expected, OrderState actual);
    event OrderCreated(uint256 indexed orderId, address indexed customer, address indexed restaurant, uint256 restaurantPayment, uint256 courierPayment);
    event OrderStateChanged(uint256 indexed orderId, OrderState previousState, OrderState newState, address indexed actor);
    event PayoutCredited(address indexed recipient, uint256 amount);
    mapping(uint256 => Order) private orders; mapping(address => uint256) public withdrawable;
    uint256 public nextOrderId = 1; uint256 public totalDeposited; uint256 public totalWithdrawn;
    function createOrder(address restaurant, uint256 restaurantPayment, uint256 courierPayment) external payable returns (uint256 orderId) { if (restaurant == address(0)) revert InvalidAddress(); if (restaurant == msg.sender) revert RoleConflict(); if (restaurantPayment == 0 || courierPayment == 0 || msg.value != restaurantPayment + courierPayment) revert InvalidAmount(); orderId = nextOrderId++; orders[orderId] = Order(msg.sender, restaurant, address(0), restaurantPayment, courierPayment, OrderState.Funded); totalDeposited += msg.value; emit OrderCreated(orderId, msg.sender, restaurant, restaurantPayment, courierPayment); emit OrderStateChanged(orderId, OrderState.None, OrderState.Funded, msg.sender); }
    function getOrder(uint256 orderId) external view returns (Order memory) { return orders[orderId]; }
    function restaurantAccept(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.restaurant) revert Unauthorized(); _requireState(order, OrderState.Funded); _transition(orderId, order, OrderState.RestaurantAccepted); }
    function courierAccept(uint256 orderId) external { Order storage order = orders[orderId]; _requireState(order, OrderState.RestaurantAccepted); if (msg.sender == order.customer || msg.sender == order.restaurant) revert RoleConflict(); order.courier = msg.sender; _transition(orderId, order, OrderState.CourierAccepted); }
    function markPickedUp(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.CourierAccepted); _transition(orderId, order, OrderState.PickedUp); }
    function markDelivered(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.PickedUp); _transition(orderId, order, OrderState.Delivered); }
    function confirmDelivery(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.customer) revert Unauthorized(); _requireState(order, OrderState.Delivered); _transition(orderId, order, OrderState.Settled); withdrawable[order.restaurant] += order.restaurantPayment; withdrawable[order.courier] += order.courierPayment; emit PayoutCredited(order.restaurant, order.restaurantPayment); emit PayoutCredited(order.courier, order.courierPayment); }
    function accountingInvariantHolds() external view returns (bool) { return address(this).balance + totalWithdrawn == totalDeposited; }
    function _requireState(Order storage order, OrderState expected) internal view { if (order.state != expected) revert InvalidState(expected, order.state); }
    function _transition(uint256 orderId, Order storage order, OrderState nextState) internal { OrderState previousState = order.state; order.state = nextState; emit OrderStateChanged(orderId, previousState, nextState, msg.sender); }
}

This reference shows the complete secured contract for this step.

Add invalid-transition coverage

Exploit tests cover the known settlement failures. An invalid-transition test checks that earlier lifecycle functions also reject skipped stages plus repeated stages.

  • Switch back to test/DeliveryEscrow.t.sol in VS Code.
  • Find the test_RevertWhenOrderSettlesTwice() function.
  • Add the following test immediately below that function:
    function test_RevertOnInvalidStateTransitions() public {
        uint256 id = _createOrder();
        vm.prank(COURIER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.courierAccept(id);
        vm.prank(RESTAURANT); escrow.restaurantAccept(id);
        vm.prank(RESTAURANT); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.restaurantAccept(id);
    }

What does this test challenge?

  • The first expectation proves that a courier cannot accept an order while it remains Funded.
  • The restaurant moves the order into RestaurantAccepted through the valid transition.
  • The final expectation proves that the restaurant cannot repeat its acceptance.
  • Both rejected calls must use the InvalidState custom error.
  • Save test/DeliveryEscrow.t.sol.

Before the next run, consider whether the added test can skip or repeat any lifecycle state.

  • Run the expanded security suite with:
forge test -vvv

What does the expanded suite prove?

This run adds invalid-transition coverage to the earlier exploit tests. A passing suite shows that role checks preserve the happy path while state checks reject forbidden ordering.

You should see five passing tests. The invalid-transition test confirms that skipped acceptance plus repeated acceptance both revert.

Invalid-transition test failing?

Check that courierAccept() requires RestaurantAccepted before assigning the courier.

Check that restaurantAccept() requires Funded before calling _transition().

Still stuck? Help me trace my invalid order transition.

✔️ Awesome, I've got everything!

Your suite now covers the happy path plus four security behaviors.

ⓧ I'd like to double check the full code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;
import {Test} from "forge-std/Test.sol";
import {DeliveryEscrow} from "../src/DeliveryEscrow.sol";

contract DeliveryEscrowTest is Test {
    DeliveryEscrow internal escrow;
    address internal constant CUSTOMER = address(0xC0FFEE);
    address internal constant RESTAURANT = address(0xBEEF);
    address internal constant COURIER = address(0xCAFE);
    address internal constant ATTACKER = address(0xBAD);
    uint256 internal constant RESTAURANT_PAYMENT = 1 ether;
    uint256 internal constant COURIER_PAYMENT = 0.25 ether;
    uint256 internal constant TOTAL_PAYMENT = RESTAURANT_PAYMENT + COURIER_PAYMENT;

    function setUp() public { escrow = new DeliveryEscrow(); vm.deal(CUSTOMER, 10 ether); }
    function test_HappyPathCreditsPayoutsAndConservesEscrow() public {
        uint256 id = _createDeliveredOrder(); _settle(id);
        DeliveryEscrow.Order memory order = escrow.getOrder(id);
        assertEq(uint256(order.state), uint256(DeliveryEscrow.OrderState.Settled));
        assertEq(escrow.withdrawable(RESTAURANT), RESTAURANT_PAYMENT);
        assertEq(escrow.withdrawable(COURIER), COURIER_PAYMENT);
        assertTrue(escrow.accountingInvariantHolds());
    }
    function test_RevertWhenUnauthorizedCallerSettles() public { uint256 id = _createDeliveredOrder(); vm.prank(ATTACKER); vm.expectRevert(DeliveryEscrow.Unauthorized.selector); escrow.confirmDelivery(id); }
    function test_RevertWhenCustomerConfirmsBeforeDelivery() public { uint256 id = _createOrder(); vm.prank(CUSTOMER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.confirmDelivery(id); }
    function test_RevertWhenOrderSettlesTwice() public { uint256 id = _createDeliveredOrder(); _settle(id); vm.prank(CUSTOMER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.confirmDelivery(id); }
    function test_RevertOnInvalidStateTransitions() public {
        uint256 id = _createOrder();
        vm.prank(COURIER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.courierAccept(id);
        vm.prank(RESTAURANT); escrow.restaurantAccept(id);
        vm.prank(RESTAURANT); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.restaurantAccept(id);
    }
    function _createOrder() internal returns (uint256 id) { vm.prank(CUSTOMER); id = escrow.createOrder{value: TOTAL_PAYMENT}(RESTAURANT, RESTAURANT_PAYMENT, COURIER_PAYMENT); }
    function _createDeliveredOrder() internal returns (uint256 id) {
        id = _createOrder(); vm.prank(RESTAURANT); escrow.restaurantAccept(id); vm.prank(COURIER); escrow.courierAccept(id);
        vm.prank(COURIER); escrow.markPickedUp(id); vm.prank(COURIER); escrow.markDelivered(id);
    }
    function _settle(uint256 id) internal { vm.prank(CUSTOMER); escrow.confirmDelivery(id); }
}

This reference contains the complete five-test suite for this step.

Review the security diff

The annotated tag gives the diff a fixed security baseline. Reviewing that comparison turns the code change into evidence a reviewer can inspect.

  • Compare the current contract with its vulnerable tagged version by running:
git diff vulnerable-settlement -- src/DeliveryEscrow.sol

What does this comparison show?

The command compares the current working file with the version stored at vulnerable-settlement. Its output isolates the contract changes from the preserved evidence.

You should see caller checks around each role-specific transition. You should also see exact state checks plus the transition to Settled before payout credit.

Diff output missing security changes?

Confirm that you saved src/DeliveryEscrow.sol before running the comparison.

Confirm that the command runs from the repository containing the vulnerable-settlement tag.

Need help reading the comparison? Help me interpret my secure contract diff.

Before the final run, consider whether any adversarial expectation still reaches an unsafe settlement.

  • Verify the final five-test suite by running:
forge test -vvv

What does the final run establish?

The final run checks the secure working tree after the historical comparison. It proves that authorization plus lifecycle guards satisfy the current security expectations.

You should see all five tests pass. Unauthorized settlement is blocked.

Premature confirmation is blocked. Repeated settlement is blocked.

You have converted the documented role plus transition invariants into executable policy. The vulnerable implementation remains reproducible through its tag.

Final suite still failing?

Use the verbose trace to identify the first unexpected call. Match that function against its required caller plus expected state.

Confirm that confirmDelivery() transitions to Settled before either withdrawable balance changes.

Need help with the trace? Help me debug my final role and transition test run.

Your order lifecycle now rejects unauthorized actors plus invalid state changes. Next, you will secure payout execution through withdrawal limits plus escrow conservation checks.

Secure Withdrawals and Prove Escrow Conservation

Your guarded order flow now reaches Settled only through the authorized lifecycle. The vulnerable settlement path remains preserved in the annotated vulnerable-settlement tag.

Settlement currently credits recipients. The funds remain locked without a secure withdrawal path.

A pull-based withdrawal lets each recipient claim only its recorded credit. An accounting invariant proves that every simulated deposit remains represented throughout the lifecycle.

In this step, get ready to:
  • Add recipient-initiated withdrawals that clear credit before transferring value.
  • Test double-withdrawal rejection plus escrow conservation across the lifecycle.
  • Document the state machine plus the before-and-after security evidence.
Add pull-based withdrawals

Recipient-initiated payouts separate settlement from the external value transfer. This keeps one recipient's transfer behavior from controlling settlement for everyone else.

Why clear credit first?

The Checks-Effects-Interactions pattern performs validation first. It updates contract state before making an external call.

Clearing the recipient's credit before sending value closes the withdrawal window before control leaves the contract. A repeated call then finds no remaining credit.

  • Switch back to src/DeliveryEscrow.sol in VS Code.
  • Find the declaration for InvalidState.
  • Add the withdrawal errors directly below that declaration by pasting this code:
    error NothingToWithdraw();
    error TransferFailed();

What do these errors protect?

  • NothingToWithdraw() rejects an address whose recorded credit is zero.
  • TransferFailed() reports that the external value transfer did not complete.
  • Save src/DeliveryEscrow.sol.
  • Find the PayoutCredited event declaration.
  • Add the withdrawal event directly below it by pasting this code:
    event Withdrawal(address indexed recipient, uint256 amount);

What does this event record?

Withdrawal records the recipient plus the transferred amount. An external indexer can use this evidence without reconstructing transfers from raw balance changes.

  • Save src/DeliveryEscrow.sol.
  • Verify that the new declarations compile by running the existing test suite:
forge test -vvv

What does this check prove?

The test command recompiles the contract with both new errors plus the event. It also confirms that the five existing security tests still pass.

You should see the five existing tests pass. This proves the withdrawal declarations preserve the secured state transitions.

Does the contract fail to compile?

Check that both errors remain inside the DeliveryEscrow contract. Confirm that the event ends with a semicolon in the source file.

Need help reading the compiler output? Help me diagnose my withdrawal declaration error.

The withdrawal function follows the same accounting boundary. It reads the caller's credit before clearing that credit plus recording the withdrawn total.

  • Find the closing brace for confirmDelivery() in src/DeliveryEscrow.sol.
  • Add the withdrawal function directly below confirmDelivery() by pasting this code:
    function withdraw() external { uint256 amount = withdrawable[msg.sender]; if (amount == 0) revert NothingToWithdraw(); withdrawable[msg.sender] = 0; totalWithdrawn += amount; (bool success, ) = payable(msg.sender).call{value: amount}(""); if (!success) revert TransferFailed(); emit Withdrawal(msg.sender, amount); }

What does this function do?

  • amount captures the caller's available credit before any state changes.
  • withdrawable[msg.sender] becomes zero before the contract transfers value.
  • totalWithdrawn preserves the cumulative amount that has left the contract.
  • TransferFailed() reverts the transaction if the value call fails.
  • Save src/DeliveryEscrow.sol.

Before you run the suite, do you expect the existing transition tests to behave differently? Make your prediction now.

  • Compile the withdrawal path against the existing tests by running:
forge test -vvv

What does this run establish?

The existing tests still exercise settlement plus transition guards. Their success confirms that adding a separate withdrawal path did not change those behaviors.

You should see the five existing tests pass again. The withdrawal path now compiles without weakening the state machine.

Does the withdrawal function fail to compile?

Check that withdraw() sits inside the contract. Confirm that NothingToWithdraw plus TransferFailed match their declarations exactly.

Still stuck? Help me fix my withdrawal function.

✔️ Awesome, I've got everything!

Your contract now has a pull-based withdrawal path. Save src/DeliveryEscrow.sol before continuing.

ⓧ I'd like to double check the full code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

contract DeliveryEscrow {
    enum OrderState { None, Funded, RestaurantAccepted, CourierAccepted, PickedUp, Delivered, Settled }
    struct Order { address customer; address restaurant; address courier; uint256 restaurantPayment; uint256 courierPayment; OrderState state; }

    error InvalidAddress();
    error InvalidAmount();
    error RoleConflict();
    error Unauthorized();
    error InvalidState(OrderState expected, OrderState actual);
    error NothingToWithdraw();
    error TransferFailed();

    event OrderCreated(uint256 indexed orderId, address indexed customer, address indexed restaurant, uint256 restaurantPayment, uint256 courierPayment);
    event OrderStateChanged(uint256 indexed orderId, OrderState previousState, OrderState newState, address indexed actor);
    event PayoutCredited(address indexed recipient, uint256 amount);
    event Withdrawal(address indexed recipient, uint256 amount);

    mapping(uint256 => Order) private orders;
    mapping(address => uint256) public withdrawable;
    uint256 public nextOrderId = 1;
    uint256 public totalDeposited;
    uint256 public totalWithdrawn;

    function createOrder(address restaurant, uint256 restaurantPayment, uint256 courierPayment) external payable returns (uint256 orderId) {
        if (restaurant == address(0)) revert InvalidAddress();
        if (restaurant == msg.sender) revert RoleConflict();
        if (restaurantPayment == 0 || courierPayment == 0) revert InvalidAmount();
        if (msg.value != restaurantPayment + courierPayment) revert InvalidAmount();
        orderId = nextOrderId++;
        orders[orderId] = Order(msg.sender, restaurant, address(0), restaurantPayment, courierPayment, OrderState.Funded);
        totalDeposited += msg.value;
        emit OrderCreated(orderId, msg.sender, restaurant, restaurantPayment, courierPayment);
        emit OrderStateChanged(orderId, OrderState.None, OrderState.Funded, msg.sender);
    }

    function getOrder(uint256 orderId) external view returns (Order memory) { return orders[orderId]; }

    function restaurantAccept(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.restaurant) revert Unauthorized(); _requireState(order, OrderState.Funded); _transition(orderId, order, OrderState.RestaurantAccepted); }
    function courierAccept(uint256 orderId) external { Order storage order = orders[orderId]; _requireState(order, OrderState.RestaurantAccepted); if (msg.sender == order.customer || msg.sender == order.restaurant) revert RoleConflict(); order.courier = msg.sender; _transition(orderId, order, OrderState.CourierAccepted); }
    function markPickedUp(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.CourierAccepted); _transition(orderId, order, OrderState.PickedUp); }
    function markDelivered(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.courier) revert Unauthorized(); _requireState(order, OrderState.PickedUp); _transition(orderId, order, OrderState.Delivered); }
    function confirmDelivery(uint256 orderId) external { Order storage order = orders[orderId]; if (msg.sender != order.customer) revert Unauthorized(); _requireState(order, OrderState.Delivered); _transition(orderId, order, OrderState.Settled); withdrawable[order.restaurant] += order.restaurantPayment; withdrawable[order.courier] += order.courierPayment; emit PayoutCredited(order.restaurant, order.restaurantPayment); emit PayoutCredited(order.courier, order.courierPayment); }

    function withdraw() external { uint256 amount = withdrawable[msg.sender]; if (amount == 0) revert NothingToWithdraw(); withdrawable[msg.sender] = 0; totalWithdrawn += amount; (bool success, ) = payable(msg.sender).call{value: amount}(""); if (!success) revert TransferFailed(); emit Withdrawal(msg.sender, amount); }
    function accountingInvariantHolds() external view returns (bool) { return address(this).balance + totalWithdrawn == totalDeposited; }
    function _requireState(Order storage order, OrderState expected) internal view { if (order.state != expected) revert InvalidState(expected, order.state); }
    function _transition(uint256 orderId, Order storage order, OrderState nextState) internal { OrderState previousState = order.state; order.state = nextState; emit OrderStateChanged(orderId, previousState, nextState, msg.sender); }
}

Use this full-file reference to align the declaration spacing plus function placement with the final secure contract.

Test withdrawal limits and conservation

A secure withdrawal function needs adversarial evidence. The first test proves that a recipient receives exactly one credited payment.

The second test follows value from deposit through both withdrawals. At each checkpoint, the contract balance plus totalWithdrawn must still equal totalDeposited.

  • Switch back to test/DeliveryEscrow.t.sol from earlier.
  • Find the first helper function near the bottom of the test contract.
  • Add the double-withdrawal test directly above the helper functions by pasting this code:
    function test_WithdrawalLimitPreventsDoubleWithdrawal() public {
        uint256 id = _createDeliveredOrder(); _settle(id);
        uint256 beforeBalance = RESTAURANT.balance;
        vm.prank(RESTAURANT); escrow.withdraw();
        assertEq(RESTAURANT.balance - beforeBalance, RESTAURANT_PAYMENT);
        assertEq(escrow.withdrawable(RESTAURANT), 0);
        vm.prank(RESTAURANT); vm.expectRevert(DeliveryEscrow.NothingToWithdraw.selector); escrow.withdraw();
    }

What does this test prove?

  • beforeBalance provides a baseline for the restaurant's received value.
  • The first withdrawal must increase that balance by exactly RESTAURANT_PAYMENT.
  • The remaining credit must become zero after the successful call.
  • The repeated withdrawal must revert with NothingToWithdraw.
  • Save test/DeliveryEscrow.t.sol.

Before you run the suite, do you think the restaurant can withdraw its credit twice? Make your prediction now.

  • Exercise the new withdrawal-limit test by running the full suite:
forge test -vvv

What should this run establish?

The new test attempts the same withdrawal twice. A passing result proves the first call clears the restaurant's credit before a repeated call can reuse it.

You should see six tests pass. The withdrawal limit now has executable evidence.

Does the new withdrawal test fail?

Confirm that withdrawable[msg.sender] becomes zero before the value call. Check that the test settles the order before the restaurant withdraws.

Need help with the failed assertion? Help me trace the withdrawal test.

A withdrawal limit protects each recipient's claim. Conservation protects the entire escrow pool.

  • Add the lifecycle accounting test directly below test_WithdrawalLimitPreventsDoubleWithdrawal() by pasting this code:
    function test_AccountingInvariantAcrossLifecycle() public {
        uint256 id = _createDeliveredOrder(); assertTrue(escrow.accountingInvariantHolds()); _settle(id);
        vm.prank(RESTAURANT); escrow.withdraw(); assertTrue(escrow.accountingInvariantHolds());
        vm.prank(COURIER); escrow.withdraw(); assertTrue(escrow.accountingInvariantHolds());
        assertEq(address(escrow).balance, 0); assertEq(escrow.totalDeposited(), TOTAL_PAYMENT); assertEq(escrow.totalWithdrawn(), TOTAL_PAYMENT);
    }

How does this prove conservation?

  • The first invariant check covers funded escrow before settlement.
  • The second check covers the state after the restaurant withdraws.
  • The third check covers the state after the courier withdraws.
  • The final assertions prove that the empty contract balance is explained by TOTAL_PAYMENT moving into totalWithdrawn.
  • Save test/DeliveryEscrow.t.sol.

Before you run the complete suite, do you expect conservation to hold after each recipient withdraws? Make your prediction now.

  • Test the lifecycle accounting evidence by running:
forge test -vvv

What does the seventh test prove?

The accounting test observes the same total value before settlement plus after each withdrawal. It proves that successful lifecycle operations neither duplicate nor lose simulated funds.

You should see all seven base tests pass. The suite now covers settlement guards plus withdrawal limits plus lifecycle accounting.

Does the conservation test fail?

Confirm that createOrder() increments totalDeposited by the received value. Confirm that withdraw() increments totalWithdrawn before transferring value.

Still seeing a failed invariant? Help me trace the escrow accounting.

✔️ Awesome, I've got everything!

Your suite now contains seven passing security plus accounting tests. Save test/DeliveryEscrow.t.sol before continuing.

ⓧ I'd like to double check the full code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;
import {Test} from "forge-std/Test.sol";
import {DeliveryEscrow} from "../src/DeliveryEscrow.sol";

contract DeliveryEscrowTest is Test {
    DeliveryEscrow internal escrow;
    address internal constant CUSTOMER = address(0xC0FFEE);
    address internal constant RESTAURANT = address(0xBEEF);
    address internal constant COURIER = address(0xCAFE);
    address internal constant ATTACKER = address(0xBAD);
    uint256 internal constant RESTAURANT_PAYMENT = 1 ether;
    uint256 internal constant COURIER_PAYMENT = 0.25 ether;
    uint256 internal constant TOTAL_PAYMENT = RESTAURANT_PAYMENT + COURIER_PAYMENT;

    function setUp() public { escrow = new DeliveryEscrow(); vm.deal(CUSTOMER, 10 ether); }
    function test_HappyPathCreditsPayoutsAndConservesEscrow() public {
        uint256 id = _createDeliveredOrder(); _settle(id);
        DeliveryEscrow.Order memory order = escrow.getOrder(id);
        assertEq(uint256(order.state), uint256(DeliveryEscrow.OrderState.Settled));
        assertEq(escrow.withdrawable(RESTAURANT), RESTAURANT_PAYMENT);
        assertEq(escrow.withdrawable(COURIER), COURIER_PAYMENT);
        assertTrue(escrow.accountingInvariantHolds());
    }
    function test_RevertWhenUnauthorizedCallerSettles() public { uint256 id = _createDeliveredOrder(); vm.prank(ATTACKER); vm.expectRevert(DeliveryEscrow.Unauthorized.selector); escrow.confirmDelivery(id); }
    function test_RevertWhenCustomerConfirmsBeforeDelivery() public { uint256 id = _createOrder(); vm.prank(CUSTOMER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.confirmDelivery(id); }
    function test_RevertWhenOrderSettlesTwice() public { uint256 id = _createDeliveredOrder(); _settle(id); vm.prank(CUSTOMER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.confirmDelivery(id); }
    function test_RevertOnInvalidStateTransitions() public {
        uint256 id = _createOrder();
        vm.prank(COURIER); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.courierAccept(id);
        vm.prank(RESTAURANT); escrow.restaurantAccept(id);
        vm.prank(RESTAURANT); vm.expectRevert(DeliveryEscrow.InvalidState.selector); escrow.restaurantAccept(id);
    }
    function test_WithdrawalLimitPreventsDoubleWithdrawal() public {
        uint256 id = _createDeliveredOrder(); _settle(id);
        uint256 beforeBalance = RESTAURANT.balance;
        vm.prank(RESTAURANT); escrow.withdraw();
        assertEq(RESTAURANT.balance - beforeBalance, RESTAURANT_PAYMENT);
        assertEq(escrow.withdrawable(RESTAURANT), 0);
        vm.prank(RESTAURANT); vm.expectRevert(DeliveryEscrow.NothingToWithdraw.selector); escrow.withdraw();
    }
    function test_AccountingInvariantAcrossLifecycle() public {
        uint256 id = _createDeliveredOrder(); assertTrue(escrow.accountingInvariantHolds()); _settle(id);
        vm.prank(RESTAURANT); escrow.withdraw(); assertTrue(escrow.accountingInvariantHolds());
        vm.prank(COURIER); escrow.withdraw(); assertTrue(escrow.accountingInvariantHolds());
        assertEq(address(escrow).balance, 0); assertEq(escrow.totalDeposited(), TOTAL_PAYMENT); assertEq(escrow.totalWithdrawn(), TOTAL_PAYMENT);
    }
    function _createOrder() internal returns (uint256 id) { vm.prank(CUSTOMER); id = escrow.createOrder{value: TOTAL_PAYMENT}(RESTAURANT, RESTAURANT_PAYMENT, COURIER_PAYMENT); }
    function _createDeliveredOrder() internal returns (uint256 id) {
        id = _createOrder(); vm.prank(RESTAURANT); escrow.restaurantAccept(id); vm.prank(COURIER); escrow.courierAccept(id);
        vm.prank(COURIER); escrow.markPickedUp(id); vm.prank(COURIER); escrow.markDelivered(id);
    }
    function _settle(uint256 id) internal { vm.prank(CUSTOMER); escrow.confirmDelivery(id); }
}

Use this reference to confirm the seven test functions plus the three shared helper functions match the final base suite.

Document the security evidence

Passing tests provide reproducible evidence. The README gives a reviewer the model needed to interpret that evidence.

The final documentation connects the state diagram to the vulnerable tag plus the secure implementation. It also defines the trust boundary for any future web client.

  • Switch back to README.md in VS Code.
  • Replace its current contents with the project overview plus state model below:
# Local Delivery Escrow

An educational Ethereum escrow that demonstrates a problem-first workflow: build a happy path, reproduce and preserve settlement exploits, enforce a state machine, and prove accounting properties.

## Order State Diagram
```mermaid
stateDiagram-v2
    [*] --> Funded: createOrder
    Funded --> RestaurantAccepted: restaurantAccept
    RestaurantAccepted --> CourierAccepted: courierAccept
    CourierAccepted --> PickedUp: markPickedUp
    PickedUp --> Delivered: markDelivered
    Delivered --> Settled: confirmDelivery
    Settled --> [*]: recipients withdraw
```

## Invariants
1. Role authorization limits each transition to its participant.
2. Calls cannot skip, repeat, or reverse states, and Settled is terminal.
3. In controlled tests, `contract balance + totalWithdrawn == totalDeposited`.
4. Each recipient withdraws only its cleared-before-call credit.

What does this section communicate?

The Mermaid diagram shows the only valid forward path through the order lifecycle. The invariant list turns the diagram into specific security claims that the tests can exercise.

  • Save README.md.
  • Confirm that the editor shows the path from Funded through Settled.
  • Confirm that four numbered invariants appear below the diagram.

Is the diagram fence incomplete?

Check that the Mermaid section has an opening fence plus a closing fence. Keep the invariants below the closing fence so they render as regular Markdown.

Need help checking the structure? Help me repair my Mermaid section.

  • Append the evidence plus limitations sections below the fourth invariant by pasting this code:


## Reproduce the Before-and-After Evidence
The annotated tag `vulnerable-settlement` contains the naive source and exploit expectations before fixes. Inspect it with `git show vulnerable-settlement`. Check out the tag in a disposable clone or worktree, reinstall forge-std, and run `forge test -vvv` to reproduce the failing security expectations. Return to the secure branch and rerun the same suite to see the tests pass.

## Exploit and Fixes
The naive implementation omitted caller and Delivered-state checks, allowing attacker settlement, premature confirmation, and repeated credit. The secure version adds authorization, exact state guards, terminal settlement, pull withdrawals, Checks-Effects-Interactions, custom errors, events, and accounting tests.

## Run
```bash
forge install --no-git foundry-rs/forge-std@v1.17.0
forge test -vvv
```

## Trust Assumptions and Limitations
The customer truthfully confirms physical delivery. Addresses do not prove identity. There is no oracle, delivery proof, dispute process, timeout, general refund, fee, privacy, formal verification, or audit. Forced Ether can make `accountingInvariantHolds()` false without changing internal accounting.

## Future TypeScript/Next.js Boundary
A wallet client can read `getOrder` and `withdrawable`, submit role-specific transitions, decode custom errors, and index events. Menus, locations, search, and private customer data remain off-chain.

This repository is educational, unaudited, and must not hold real funds.

What does the finished README establish?

The evidence section gives reviewers a reproducible path to the preserved vulnerable implementation. The comparison section explains which controls close each exploit.

The limitations section scopes the accounting claim to controlled tests. Forced Ether can change the contract balance without changing internal deposits.

The frontend boundary keeps private application data off-chain. It reserves the contract interface for order state plus role-specific transactions.

  • Save README.md.
  • Confirm that the file contains the vulnerable-tag reproduction section.
  • Confirm that the file ends with the educational-use warning.

Is a README section missing?

Check that the appended content begins after the fourth invariant. Confirm that the shell command fence closes before the trust assumptions heading.

Still missing a section? Help me compare my final README.

✔️ Awesome, I've got everything!

Your README now explains the lifecycle plus the security evidence plus the project boundary. Save README.md before the final check.

ⓧ I'd like to double check the full code

# Local Delivery Escrow

An educational Ethereum escrow that demonstrates a problem-first workflow: build a happy path, reproduce and preserve settlement exploits, enforce a state machine, and prove accounting properties.

## Order State Diagram
```mermaid
stateDiagram-v2
    [*] --> Funded: createOrder
    Funded --> RestaurantAccepted: restaurantAccept
    RestaurantAccepted --> CourierAccepted: courierAccept
    CourierAccepted --> PickedUp: markPickedUp
    PickedUp --> Delivered: markDelivered
    Delivered --> Settled: confirmDelivery
    Settled --> [*]: recipients withdraw
```

## Invariants
1. Role authorization limits each transition to its participant.
2. Calls cannot skip, repeat, or reverse states, and Settled is terminal.
3. In controlled tests, `contract balance + totalWithdrawn == totalDeposited`.
4. Each recipient withdraws only its cleared-before-call credit.

## Reproduce the Before-and-After Evidence
The annotated tag `vulnerable-settlement` contains the naive source and exploit expectations before fixes. Inspect it with `git show vulnerable-settlement`. Check out the tag in a disposable clone or worktree, reinstall forge-std, and run `forge test -vvv` to reproduce the failing security expectations. Return to the secure branch and rerun the same suite to see the tests pass.

## Exploit and Fixes
The naive implementation omitted caller and Delivered-state checks, allowing attacker settlement, premature confirmation, and repeated credit. The secure version adds authorization, exact state guards, terminal settlement, pull withdrawals, Checks-Effects-Interactions, custom errors, events, and accounting tests.

## Run
```bash
forge install --no-git foundry-rs/forge-std@v1.17.0
forge test -vvv
```

## Trust Assumptions and Limitations
The customer truthfully confirms physical delivery. Addresses do not prove identity. There is no oracle, delivery proof, dispute process, timeout, general refund, fee, privacy, formal verification, or audit. Forced Ether can make `accountingInvariantHolds()` false without changing internal accounting.

## Future TypeScript/Next.js Boundary
A wallet client can read `getOrder` and `withdrawable`, submit role-specific transitions, decode custom errors, and index events. Menus, locations, search, and private customer data remain off-chain.

This repository is educational, unaudited, and must not hold real funds.

Use this reference to confirm every heading plus code fence plus final warning matches the completed project documentation.

Before the final run, do you expect documentation changes to affect the contract suite? Make your prediction now.

  • Verify the completed secure escrow by running the full suite one final time:
forge test -vvv

What does the final run prove?

The final run recompiles the secure contract plus all seven base tests. It verifies authorization plus transitions plus withdrawal limits plus conservation from one reproducible command.

You should see all seven tests pass. The final README now explains how that evidence relates to the preserved vulnerable tag.

Does the final suite show a failure?

Start with the first failing test. Compare the referenced contract function with the matching full-file tab before changing any expectation.

Do not weaken a security assertion to make the suite pass. Help me diagnose the final Forge failure.

That's the security evidence complete: all seven base tests pass against the documented secure implementation.

Secret mission

Resolve the Cancellation Race

Add a customer cancellation path that competes with courier acceptance for the same order state. Prove both transaction orderings leave one winner while preserving every escrowed unit.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs locally with simulated funds, so your files create no ongoing cost. Choose whether to keep the security evidence, pause your work, or remove the local resources.

Resources you used:

  • Local delivery escrow repository containing the secure branch.
  • Annotated vulnerable-settlement tag in the repository's Git history.
  • Pinned forge-std dependency in lib/.
  • Forge build output in out/.
  • Forge cache data in cache/.
  • Foundry 1.8.6 binaries under ~/.foundry.

Keep everything running

No action is required. Choose this option if you want to keep developing the contract or preserve the local evidence for review.

  • Keep the local repository to retain the secure branch.
  • Keep its Git history to retain the vulnerable-settlement tag.
  • Keep ~/.foundry if you plan to use Foundry again.
  • Push the secure branch to GitHub if you want a remote copy.
  • Push the vulnerable-settlement tag to preserve the vulnerable evidence remotely.

Pause - I'll come back to this later

Pausing keeps every file while ending your editing session. Forge tests do not leave a local node running.

  • Close Claude Code.
  • Close VS Code.
  • Leave the local repository unchanged.
  • Leave ~/.foundry installed for your next Foundry project.

Delete - I don't want to use this again

Deleting the repository is final. The secure branch disappears with the tagged vulnerable evidence. A remote copy protects anything you may want later.

  • Push the secure branch to GitHub if you want to preserve it.
  • Push the vulnerable-settlement tag if you want to preserve the exploit evidence.

macOS

Use Finder to remove the repository after confirming that any evidence you want is backed up.

  • Close Claude Code.
  • Close VS Code.
  • In Finder, locate the repository folder containing foundry.toml.
  • Delete that repository folder with Finder.
  • Confirm the repository folder no longer appears in Finder.

Removing the repository also removes lib/, cache/, and out/. Its Git history contains the vulnerable-settlement tag.

The Foundry installation can support future Solidity projects. Remove it only if you want to uninstall Foundry.

  • Open your home folder in Finder.
  • Reveal the hidden files in your home folder.
  • Locate .foundry at ~/.foundry.
  • Delete .foundry with Finder.
  • Confirm .foundry no longer appears in your home folder.

Windows

The project setup targeted Intel macOS. A native Windows Foundry installation was not created by this project.

  • Close Claude Code.
  • Close VS Code.
  • In File Explorer, locate any repository copy containing foundry.toml.
  • Delete that repository folder with File Explorer.
  • Confirm the repository folder no longer appears in File Explorer.

Deleting the repository copy also removes its dependency folder, build output, cache data, and local Git history.

Nice Work!

Nice Work!

Outstanding work! You've built a local Ethereum delivery escrow backed by reproducible security evidence.

You've learned how to:

  • Built a role-guarded state machine that permits only authorized lifecycle transitions.
  • Preserved reproducible exploit evidence in Git at the vulnerable-settlement tag for before-and-after review.
  • Proved escrow conservation across settlement withdrawals through seven passing Foundry tests.
  • Secret Mission: Proved both cancellation-race orderings with two additional tests that preserve single-winner accounting.

Ready to quiz yourself?