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.
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.
| Impact row | Read this | Not this |
|---|---|---|
| Direct theft of user funds | Read thisWhat the victim can still withdraw, and the attacker’s token balance | Not thisA Transfer event or the return value of the attacking call |
| Permanent freezing of funds | Read thisWhat a withdrawal returns after the longest wait, on every exit path including admin recovery | Not thisOne reverting call |
| Protocol insolvency | Read thisAssets held minus liabilities owed, both read from the contracts | Not thisA share price or an exchange rate |
| Theft of unclaimed yield | Read thisThe victim’s claimable amount before and after | Not thisA reward index or an accumulator |
| Unauthorised minting | Read thistotalSupply, and the balance of the account that received the mint | Not thisA counter inside the test |
| Governance result changed | Read thisThe stored outcome of the proposal, or the state its execution wrote | Not 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.
// 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
-
Setup: one chain, one block, the scoped addresses
vm.createSelectForkwith a block number makes the run repeatable. The triager gets the same numbers you did.TARGETis the address on the programme’s asset list. The header records the commit the deployed code was built from.Asset and version binding
-
Actors: who performs each step
makeAddrgives every party a name in the trace. The attacker holds no role. There is novm.prankon an owner, an admin, a keeper or governance: a decisive step performed by a trusted role ends the report.Actor trace
-
Concrete values
Deposit, capital and expected loss are constants with the unit in the comment, and the report quotes the same numbers.
dealfunds 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.Production reachability
-
The assertion that proves impact
One
assertGeat the end oftest_impact, on_impactObject(), against the loss the report claims. The logged numbers above it are the ones you paste into the Impact section.Executed end-state proof
-
The control, and the assertion that fails after the fix
test_controlruns the same path without the bug step and asserts the object did not move. Then patch the source and run withFIXED=true:deployCodeToputs the patched build at the deployed address, andtest_impactfails. The bug step reverts, or the final assertion reports no loss. Paste that line next to the fix.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
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.
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.
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.
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.
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.
-
Testing on mainnet or a public testnet, and incomplete PoCs.
-
How to Submit Bug Reports That Get Paid
What a PoC is and is not on Immunefi.
-
PoC validity criteria and what invalidates an attack path.
-
Cantina Bug Bounty Participation
Local forks in place of public chains.
-
Sherlock Criteria for Issue Validity
The cases where a coded PoC is recommended.
-
Sherlock bug bounty Platform Rules
Testing on local forks only.
-
deal,makeAddranddeployCodeTo, as used in the scaffold.