Foundry PoC template

A fork-test scaffold for a bug bounty proof of concept: pinned block, named actors, concrete values, a control run, and a final assertion that reads the object the impact names. One file, one command, pasted output.

Download ImpactPoC.t.sol

Compiled and run with forge 1.7.1, forge-std 1.9.5 · checked 2 Oct 2026

The rule: the final assertion reads the object the impact names

The impact row says what was lost. The last line of the test reads that thing from chain state and compares it with the number in the report. Everything before it is setup.

The object to read, by impact row
Impact rowRead thisNot this
Direct theft of user fundsRead thisWhat the victim can still withdraw, and the attacker’s token balanceNot thisA Transfer event or the return value of the attacking call
Permanent freezing of fundsRead thisWhat a withdrawal returns after the longest wait, on every exit path including admin recoveryNot thisOne reverting call
Protocol insolvencyRead thisAssets held minus liabilities owed, both read from the contractsNot thisA share price or an exchange rate
Theft of unclaimed yieldRead thisThe victim’s claimable amount before and afterNot thisA reward index or an accumulator
Unauthorised mintingRead thistotalSupply, and the balance of the account that received the mintNot thisA counter inside the test
Governance result changedRead thisThe stored outcome of the proposal, or the state its execution wroteNot thisA vote count the attacker inflated

The scaffold

Save it as test/ImpactPoC.t.sol in the project’s repository. It compiles as it stands and fails until the path is filled in. Amber lines are yours to fill. Blue lines are the two assertions.

test/ImpactPoC.t.sol1–124
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// Fork-test proof of concept. One file, one command, pasted output.
// Scaffold from https://bountyoperator.com/templates/foundry-poc
//
// Impact row, quoted from the programme:   "TODO: paste the exact text"
// Asset in scope:                          TODO: name and address
// Source commit the deployed code matches: TODO: 40-character commit
//
// Run on the deployed code:   forge test --match-path test/ImpactPoC.t.sol -vvv
// Run on your patched build:  FIXED=true forge test --match-path test/ImpactPoC.t.sol -vvv
//
// RPC_URL points at an archive node. The test runs on a local fork and sends
// nothing to mainnet or a public testnet.

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

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
}

/// The in-scope contract. Inside the project's repository, import its own
/// interface instead of declaring one here.
interface ITarget {
    function deposit(uint256 assets, address receiver) external returns (uint256 shares);
    function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
    function balanceOf(address account) external view returns (uint256 shares);
}

contract ImpactPoC is Test {
    // ---- 1. Setup: one chain, one block, the scoped addresses ---------------
    uint256 constant FORK_BLOCK = 0; // TODO: the block you reproduced at
    ITarget constant TARGET = ITarget(address(0)); // TODO: address from the programme's asset list
    IERC20 constant ASSET = IERC20(address(0)); // TODO: the token the impact row names

    // ---- 2. Actors: who performs each step ----------------------------------
    // No prank on an owner, admin, keeper or governance address anywhere below.
    address attacker = makeAddr("attacker"); // holds no role
    address victim = makeAddr("victim"); // an ordinary user

    // ---- 3. Concrete values -------------------------------------------------
    uint256 constant VICTIM_DEPOSIT = 100_000e6; // TODO: 100,000 units of a 6-decimal token
    uint256 constant ATTACKER_CAPITAL = 1_000e6; // TODO: what the attacker starts with
    uint256 constant EXPECTED_LOSS = 100_000e6; // TODO: the loss the report claims

    function setUp() public {
        vm.createSelectFork(vm.envString("RPC_URL"), FORK_BLOCK);
        if (vm.envOr("FIXED", false)) _applyFix();

        vm.label(address(TARGET), "Target");
        vm.label(address(ASSET), "Asset");

        // Starting balances. deal() funds an account. Nothing here writes the
        // target's storage or mocks one of its calls.
        deal(address(ASSET), victim, VICTIM_DEPOSIT);
        deal(address(ASSET), attacker, ATTACKER_CAPITAL);

        // The victim uses the protocol the way its documentation describes.
        vm.startPrank(victim);
        ASSET.approve(address(TARGET), VICTIM_DEPOSIT);
        TARGET.deposit(VICTIM_DEPOSIT, victim);
        vm.stopPrank();
    }

    /// Puts your patched build at the deployed address. Existing storage stays,
    /// except the slots the constructor writes: it runs again at that address.
    /// Patch the source in this repository first. Behind a proxy, pass the
    /// implementation address.
    function _applyFix() internal {
        // TODO: artifact and constructor arguments of the patched contract
        deployCodeTo("Target.sol:Target", abi.encode(address(ASSET)), address(TARGET));
    }

    /// The object the impact row names, read from chain state.
    ///   Theft of user funds     -> what the victim can still withdraw
    ///   Protocol insolvency     -> assets held minus liabilities owed
    ///   Unauthorised minting    -> totalSupply, or the attacker's balance
    ///   Permanent freezing      -> what a withdrawal returns after the wait
    function _impactObject() internal view returns (uint256) {
        return ASSET.balanceOf(address(TARGET)); // TODO: the read that matches your impact row
    }

    /// The attack path, step for step as numbered in the report. Public calls only.
    function _path(bool withBugStep) internal {
        vm.startPrank(attacker);
        // TODO step 1: the ordinary calls that set the stage
        if (withBugStep) {
            // TODO step 2: the one call the report is about
        }
        // TODO step 3: the calls that realise the loss
        vm.stopPrank();
    }

    /// Passes on the deployed code. Fails when run with FIXED=true.
    function test_impact() public {
        uint256 objectBefore = _impactObject();
        uint256 attackerBefore = ASSET.balanceOf(attacker);

        _path(true);

        uint256 objectAfter = _impactObject();
        uint256 loss = objectBefore > objectAfter ? objectBefore - objectAfter : 0;
        console2.log("impact object before", objectBefore);
        console2.log("impact object after ", objectAfter);
        console2.log("measured loss       ", loss);
        console2.log("attacker before     ", attackerBefore);
        console2.log("attacker after      ", ASSET.balanceOf(attacker));

        // FINAL ASSERTION. It reads the object the impact row names.
        // Not an event, not a return value, not an intermediate variable.
        assertGe(loss, EXPECTED_LOSS, "impact: the object the impact row names lost value");
    }

    /// Control: the same path without the bug step. The object does not move.
    function test_control() public {
        uint256 objectBefore = _impactObject();

        _path(false);

        assertEq(_impactObject(), objectBefore, "control: no loss without the bug step");
    }
}

Five parts, and the check each one answers

  1. Setup: one chain, one block, the scoped addresses

    vm.createSelectFork with a block number makes the run repeatable. The triager gets the same numbers you did. TARGET is the address on the programme’s asset list. The header records the commit the deployed code was built from.

    Answers Asset and version binding

  2. Actors: who performs each step

    makeAddr gives every party a name in the trace. The attacker holds no role. There is no vm.prank on an owner, an admin, a keeper or governance: a decisive step performed by a trusted role ends the report.

    Answers Actor trace

  3. Concrete values

    Deposit, capital and expected loss are constants with the unit in the comment, and the report quotes the same numbers. deal funds accounts. Nothing writes the target’s storage and nothing mocks its calls, because every state the proof sets up needs a route from live state by public calls.

    Answers Production reachability

  4. The assertion that proves impact

    One assertGe at the end of test_impact, on _impactObject(), against the loss the report claims. The logged numbers above it are the ones you paste into the Impact section.

    Answers Executed end-state proof

  5. The control, and the assertion that fails after the fix

    test_control runs the same path without the bug step and asserts the object did not move. Then patch the source and run with FIXED=true: deployCodeTo puts the patched build at the deployed address, and test_impact fails. The bug step reverts, or the final assertion reports no loss. Paste that line next to the fix.

    Answers Design intent and counterfactual

Two runs, both pasted into the report

One command. The fork needs an archive node in RPC_URL.

export RPC_URL=<archive node for the chain>
forge test --match-path test/ImpactPoC.t.sol -vvv

The output below is real, with the call trace left out. It is this scaffold filled in for a test vault whose redeem has no owner check, deployed on a local anvil chain and forked at block 2.

On the deployed code: both pass

$ forge test --match-path test/ImpactPoC.t.sol -vvv
Ran 2 tests for test/ImpactPoC.t.sol:ImpactPoC
[PASS] test_control() (gas: 64727)
[PASS] test_impact() (gas: 80048)
Logs:
  impact object before 100000000000
  impact object after  0
  measured loss        100000000000
  attacker before      1000000000
  attacker after       101000000000

Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 41.09ms (4.81ms CPU time)

On the patched build: the impact test fails

$ FIXED=true forge test --match-path test/ImpactPoC.t.sol -vvv
Ran 2 tests for test/ImpactPoC.t.sol:ImpactPoC
[PASS] test_control() (gas: 64754)
[FAIL: not owner] test_impact() (gas: 81399)
Backtrace:
  at Target.redeem
  at ImpactPoC.test_impact

Suite result: FAILED. 1 passed; 1 failed; 0 skipped; finished in 11.83ms (1.10ms CPU time)

deployCodeTo runs the constructor again at the target address. Storage stays as deployed except for the slots the constructor writes. Behind a proxy, pass the implementation address.

What gets a PoC rejected

  1. It ran against mainnet or a public testnet

    Grounds for a permanent ban on Immunefi. Cantina and Sherlock’s bounty rules say the same: local forks only. The scaffold never broadcasts.

  2. It is steps or pseudocode

    Immunefi’s guide rules out a list of steps, pseudocode, and the project’s contracts on their own. A PoC is code the triager runs.

  3. It does not compile, or does not show the stated impact

    Cantina’s bar is that the PoC compiles and demonstrates the impact, on the audit branch, with its output. A PoC that rests on unrealistic assumptions gets the finding downgraded or invalidated.

  4. It asserts on the wrong object

    A test that ends on an event, a return value or a variable inside the test proves the call happened. The impact row names a balance, an owner or a stored record. Read that.

  5. It sets up state nobody can reach

    A storage write on the target, a mocked in-scope call, or a prank on a trusted role each replace a step the attacker has to perform. Say what was mocked, or mock nothing.

Have the proof reviewed

Paste the filled test and its output. The workbench opens with Proof review selected and goes through the test step by step: executed, mocked or narrated, and whether the end state is the one the impact row names.

The draft stays in this browser. It is sent only when you run a review in the workbench.

Sources

Platform rules read on 2 Oct 2026.