Chasm is an interactive, gdb-style Solidity/EVM debugger on REVM, built for red-team dynamic analysis.
- Full control: Stepping through Solidity is only half of what this debugger does. Here you are
rooton the EVM, free to rewrite state, the stack, memory, storage, and gas while the transaction is still live. - Go low or high level: Whether you are checking invariants with the source in hand, or dropping into raw EVM to build a jump-oriented programming (JOP) chain from a contract's bytecode, Chasm handles both.
- See everything: No source, no problem. Chasm maps every public function and the flow through it, wired into the decompiled output, so you can see what storage and memory changed at each step.
- Foundry compatible: Use Foundry cheatcodes while debugging.
- Frontend-independent: Drive the Rust engine through the bundled JSON-RPC process from an editor, web service, terminal UI, or any other client.
Table of Contents
- Install
- Usage
- Debug a Foundry test
- Debug a web3.py script
- Connect another frontend
- Re-run with new calldata
- Set breakpoints
- Inspect the frame
- Evaluate Solidity
- Read local variables
- Watch a storage variable
- Change the running transaction
- Force an out-of-gas
- Profile gas
- Debug a failing assertion
- Use Foundry cheatcodes
- Run Yul at the prompt
- Work without source
- Find a gadget
- Debug from an AI client (MCP)
- Compile a project
- Check the environment
- Reference
- Development
Clone the repository and install chasm as a global command:
git clone https://github.com/ChasmHQ/debugger && cd debugger
# if uv is not installed
pipx install uv
uv tool install .Building from this checkout needs Python 3.10+, Rust 1.91+, and git. chasm downloads solc
itself into ~/.solcx, picking the build for this machine. x86-64 and arm64 Linux, Intel
and Apple silicon macOS, and Windows are covered, and a solc already installed by Foundry
(~/.svm) or sitting on PATH is used instead. Anywhere else, on musl systems like
Alpine, NixOS, or an architecture Solidity does not publish for, chasm falls back to
solc's WebAssembly build, which needs node on PATH (or named by CHASM_NODE). Failing
that, point chasm at a compiler you have:
chasm compile --solc-binary /usr/bin/solc examples/bank/srcCHASM_SOLC=/usr/bin/solc does the same for every command.
Chasm has the following subcommands:
run: debug a Foundry test, or a web3.py driver script.compile: compile the contracts and report what chasm sees.doctor: report the compiler, runtimes and caches chasm found on this machine.
For detailed information on each command and its options, run:
chasm -h
chasm <COMMAND> -hBy default chasm run opens the fullscreen interface in the screenshot above. The examples
below use --console, the plain-text frontend, but the commands are identical in both.
Point chasm run at a .sol test and it runs without an existing Foundry project, even
when the file imports forge-std or other libraries. chasm resolves the imports, clones what
is missing, writes the remappings, and breaks on the first line of the test body:
$ chasm run --console -y examples/standalone/Vault.t.sol
standalone test at /tmp/debugger2/examples/standalone; compiling ...
installing forge-std from https://github.com/foundry-rs/forge-std @ v1.17.0
installing @openzeppelin/contracts from https://github.com/OpenZeppelin/openzeppelin-contracts @ v5.7.0
wrote 2 remapping(s) to remappings.txt
wrote 25 artifact(s) to out/chasm
debugging 1 test(s): VaultTest.testDeal
Chasm - Solidity/EVM debugger. gdb commands; `help` for the list.
Breakpoint 1, VaultTest.testDeal() at Vault.t.sol:11
11 vm.deal(alice, 3 ether);
pc 0x0c3d PUSH20 sp 3 gas 29,978,755 step 126
(chasm)Each test gets a fresh deploy, a setUp() and the test call, as forge isolates them. With
no filter, chasm debugs every test it discovers and continue runs to the next test body.
-m/--match narrows to test functions matching a substring, --match-contract to a
contract:
chasm run --console -m testDeposit examples/bank/test/Bank.t.solInside a project with a foundry.toml, nothing is fetched. The project's remappings.txt,
lib/, solc pin and evm_version are used as they are.
chasm run also takes a plain web3.py script. A script that constructs
EthereumTesterProvider runs unmodified: while the driver is active, chasm supplies its
REVM-backed provider, runs the script on a worker thread, and stops the first time
execution enters code it recognises:
$ chasm run --console --contracts examples/bank/src examples/debug_bank.py
compiling /tmp/debugger/examples/bank/src ...
wrote 2 artifact(s) to out/chasm
2 contract(s): Bank, Callee
bank : 0x092cAc6DB1DE08aE9590C7528a5E050FEA367552
alice: 0x72C7F68b160Fd5bF38A207097B3e8cFBC4C9E084
Chasm - Solidity/EVM debugger. gdb commands; `help` for the list.
Bank.deposit() at src/Bank.sol:51
51 function deposit() public payable {
pc 0x0379 JUMPDEST sp 1 gas 278,754 step 570
(chasm)Recognition is by bytecode, not by instrumentation, which is why an unmodified script works. Startup commands run before the prompt opens, so a session can land where you want it:
chasm run -x 'b _credit' -x c --contracts examples/bank/src examples/debug_bank.pychasm-engine exposes the same Rust debugger core as JSON-RPC 2.0 over newline-delimited
standard input and output. A frontend can own presentation and process lifecycle without
embedding Python or depending on the console or TUI:
chasm-engine < examples/headless-session.jsonlThe protocol supports persistent chains, transactions, stepping, breakpoints, snapshots, stack/memory/storage writes, expression execution, host calls, and Foundry prank state. See docs/headless.md for the versioned protocol and a complete request stream.
reset re-runs the target script from scratch (a fresh chain, with every breakpoint,
watchpoint and display still armed), and run [ARGS] re-runs it with new arguments.
For a script that takes its calldata as an argument, that is the whole iterate loop in
one session, without relaunching anything:
(chasm) b CALLDATACOPY
(chasm) c
Breakpoint 1, ...
pc 0x015c CALLDATACOPY sp 1->7 gas 29,976,201 step 101
(chasm) run 0x000040c3...your next attempt...
(chasm) c # breakpoint survived the restartEvery stop ends with a one-line machine echo, giving pc, opcode, gas, step, and the stack
height as old->new whenever it changed (a POP reads sp 13->12), so opcode-level
stepping always shows what the last instruction did to the machine.
Payload hex runs to thousands of characters, past what a Windows console or command line
will accept as one line, so an argument of the form @path is read from that file
instead, with whitespace stripped. It works at launch, at the prompt, and standalone:
chasm run ... script.py @C:/path/to/payload.hex # at launch
(chasm) run @C:/path/to/payload.hex # at the prompt
python script.py @payload.hex # without chasm at allBreak on a function, a source line, a raw program counter, or on every occurrence of an opcode in any contract:
(chasm) b _credit # a function, by name or Contract.name
(chasm) b Bank.sol:46 # a source line, snapped to the next line with code
(chasm) b *0x108 # a raw program counter
(chasm) b SSTORE # every SSTORE, anywhere
(chasm) b Bank.sol:46 if amount > 1 etherAn opcode breakpoint is the way into code you have no line numbers for:
(chasm) b SSTORE
Breakpoint 1 on every SSTORE
(chasm) c
Breakpoint 1, Bank._credit(address, uint256) at src/Bank.sol:46
46 balances[who] += amount - fee;chasm also stops on reverts by default, at the failing instruction, and decodes the reason
as reverted: "...", panic 0x11, or a custom error with its arguments.
info registers is the machine at a glance:
(chasm) info registers
pc 0x09b5
opcode PUSH0 (0x5f)
gas 278,719 remaining / 217 used of 278,936
depth 0
sp 5 items
address 0xf2e246bb76df876cef8b38ae84130f4f55de395b
code 0xf2e246bb76df876cef8b38ae84130f4f55de395b
msg.sender 0x2da044db7416b5210c46b2dcdd521dbff696f3aa
msg.value 2000000000000000000 (2 ether)
tx.origin 0x2da044db7416b5210c46b2dcdd521dbff696f3aa
static no
step 582info storage decodes the whole layout, packed slots and all, without you working out a
single slot number:
(chasm) info storage
Bank at 0xf2e246bb76df876cef8b38ae84130f4f55de395b
slot 0+0 owner address = 0x7e5f4552091a69125d5dfcb7b8c2659029395bdf (cold)
slot 0+20 feeBps uint96 = 25 (cold)
slot 1+0 totalDeposits uint256 = 1000000000000000000 (cold)
slot 2+0 balances mapping(address => uint256) = <mapping: query a key> (cold)
slot 3+0 accounts mapping(address => struct Bank.Account) = <mapping: query a key> (cold)
slot 4+0 history uint256[] = [0 items] [] (cold)
slot 5+0 name string = "chasm-bank" (cold)bt walks the call stack. Internal Solidity calls get their own frames even though the EVM
never changed depth:
(chasm) bt
-> #0 Bank._credit(address, uint256) at src/Bank.sol:45
#1 Bank.deposit() at src/Bank.sol:52
#2 Bank at pc 0x380l lists source around the program counter, disas disassembles around it with the source
line each instruction belongs to, and x/NFU examines memory in gdb syntax:
(chasm) l
src/Bank.sol
. 40 function _fee(uint256 amount) internal view returns (uint256) {
. 41 return (amount * feeBps) / 10000;
42 }
43
. 44 function _credit(address who, uint256 amount) internal {
-> 45 uint256 fee = _fee(amount);
. 46 balances[who] += amount - fee;
. 47 totalDeposits += amount - fee;
. 48 history.push(amount);
49 }
50
(chasm) disas
09b3 JUMP L51
09b4 JUMPDEST L44
=> 09b5 PUSH0 L45
09b6 PUSH2 0x09be L45
09b9 DUP3 L45
(chasm) x/32xb 0x80
0x0080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0090: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
(memory is 96 bytes; the rest reads as zero)p compiles the expression as real Solidity against the paused contract, runs it on a
state snapshot, and throws the snapshot away, so it cannot disturb the run:
(chasm) p balances[who] + 100 ether
$1 = 100000000000000000000 (100 ether) (uint256)
(chasm) p keccak256(abi.encode(who, amount))
$2 = 0x7a26c77763128d91079351eafa8c68f983a19e3262ef0fcc7ac5a257c2253c3a (bytes32)
(chasm) p type(uint256).max
$3 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 (uint256)Because it is Solidity, internal and private functions are callable too:
(chasm) p _fee(1 ether)
$4 = 2500000000000000 (0.002500 ether) (uint256)p discards its side effects. call keeps them:
(chasm) call setNickname("hodler")
done (gas 27599)
(chasm) p accounts[msg.sender].nickname
$5 = "hodler" (string memory)
(chasm) call history.push(7)
done (gas 44484)
(chasm) p history.length
$6 = 1 (uint256)display re-evaluates an expression at every stop, and ptype reports a type:
(chasm) display fee
1: fee = <EvalError: local `fee` cannot be used in an expression: this instruction allocates it; step once to
see it>
(chasm) n
Bank._credit(address, uint256) at src/Bank.sol:46
46 balances[who] += amount - fee;
1: fee = 5000000000000000 (0.005000 ether)
(chasm) ptype balances[who]
type = uint256solc emits no location for locals, so chasm reconstructs them from the AST and the run's
stack heights. info locals names, types and decodes them:
(chasm) info locals
who address = 0x2da044db7416b5210c46b2dcdd521dbff696f3aa (param)
amount uint256 = 2000000000000000000 (2 ether) (param)
fee uint256 = <unavailable> this instruction allocates it; step once to see it
(chasm) n
Bank._credit(address, uint256) at src/Bank.sol:46
46 balances[who] += amount - fee;
(chasm) p amount - fee
$1 = 1995000000000000000 (1.995000 ether) (uint256)A local that cannot be read says why rather than printing a wrong number.
A watchpoint breaks when a value changes, and reports the old and new values:
(chasm) watch totalDeposits
Watchpoint 2: totalDeposits (slot 0x1 of 0xf2e2..395b)
(chasm) c
Watchpoint 2 totalDeposits: 0xde0b6b3a7640000 -> 0x299060a1be4b8000
Bank._credit(address, uint256) at src/Bank.sol:47
47 totalDeposits += amount - fee;Watchpoints work on state variables, on mapping elements (watch balances[msg.sender]) and
on memory (watch *0x80). rwatch breaks on reads and awatch on either.
set var writes through Solidity, so mappings, packed slots and structs all encode
correctly. The write lands in the transaction that is still running, and it changes the
outcome:
(chasm) set var fee = 1 ether
fee = 1000000000000000000 (1 ether)
(chasm) set var balances[who] = 5 ether
balances[who] = 5000000000000000000 (5 ether)
(chasm) c
total deposits: 2000000000000000000
alice balance : 6000000000000000000Without those two writes the same script prints 2995000000000000000 and
1995000000000000000.
The lower-level writes are there too:
(chasm) set $stack[0] = 0xc0ffee # rewrite an operand before its opcode consumes it
(chasm) set $mem[0x80] = 1
(chasm) set $storage[0] = 0xdead
(chasm) jump 0x108 # move the program counter, JUMPDESTs onlySet the gas counter to whatever you need and let the transaction run into the wall:
(chasm) set $gas = 100
$gas = 100
(chasm) c
Stopped on error: OutOfGas: out of gas; `set $gas = N` then `c` retries this instruction
Bank._fee(uint256) at src/Bank.sol:41
41 return (amount * feeBps) / 10000;
pc 0x0a75 SLOAD sp 1->12 gas 32 step 592The same stop is also the way out of an out-of-gas you did not plan: refill the meter and continue, and the failed instruction is retried on its original stack. The transaction then runs as if it had been sent with the larger limit:
(chasm) set $gas = 5000000
(chasm) cThe failure is real, not simulated. The script's own output shows the deposit never happened:
total deposits: 1000000000000000000
alice balance : 0info gas breaks the spend down by source line and by opcode:
(chasm) info gas
limit 278,936
used 28,864
remaining 250,072
refund 0
this op SSTORE base cost 0
gas by source line (highest first)
20,262 L46 balances[who] += amount - fee;
5,072 L47 totalDeposits += amount - fee;
2,247 L41 return (amount * feeBps) / 10000;
182 L7 contract Bank {
25 L45 uint256 fee = _fee(amount);
gas by opcode
107,200 SSTORE
8,600 SLOAD
1,046 CODECOPY
624 JUMP
339 PUSH2A failed forge-std assertion stops the debugger where it broke, with the comparison
decoded, and up walks back to your test:
$ chasm run --console -y -m testBalanceIgnoresFee examples/bank/test/FailingAssertion.t.sol
debugging 1 test(s): FailingAssertionTest.testBalanceIgnoresFee
Breakpoint 1, FailingAssertionTest.testBalanceIgnoresFee() at test/FailingAssertion.t.sol:18
18 vm.prank(alice);
(chasm) c
Stopped on error: reverted: "assertion failed: 997500000000000000 != 1000000000000000000"
StdAssertions.assertEq(uint256, uint256) at lib/forge-std/src/StdAssertions.sol:121
121 vm.assertEq(left, right);
(chasm) up
#1 FailingAssertionTest.testBalanceIgnoresFee() at test/FailingAssertion.t.sol:20This works because chasm implements the vm.assert* family as real comparisons, all 116
overloads that forge-std's own assertEq and friends call into.
Cheatcodes run against live REVM state, both from inside the test and typed at the prompt against the frame you are stopped in:
(chasm) vm.warp(12345)
vm.warp -> ok
(chasm) p block.timestamp
$1 = 12345 (uint256)
(chasm) vm.deal(alice, 5 ether)
vm.deal -> ok
(chasm) p alice.balance
$2 = 5000000000000000000 (5 ether) (uint256)
(chasm) vm.prank(address(0xcafe))
vm.prank -> okAn argument is either a literal (12345, 5 ether, 0xcafe, true, "a string") or a
Solidity expression, so a local, a getter and a cast all work. A short hex value pads to an
address the way Solidity's own address(0xcafe) does. When nothing fits, the error names
the argument and keeps solc's reason:
(chasm) vm.prank(alcie)
error: vm.prank: argument 1 (alcie) is not a valid address (Undeclared identifier. Did you mean "alice"? (in
`alcie`))Implemented: warp roll fee chainId coinbase deal etch store load prank startPrank stopPrank addr sign assume label, plus the vm.assert* family. console.log prints as you
step.
Not yet supported: expectRevert, expectEmit, expectCall, mockCall, ffi, forking,
and fuzz or invariant argument generation.
Type a Yul builtin and it runs on the paused frame through REVM's opcode
implementations. This is the low-level twin of set var:
(chasm) mload(0x40)
mload(0x40) -> $1 = 0x80 (128) (gas 12)
(chasm) sstore(3, add(sload(3), 1))
sstore(3, add(sload(3), 1)) -> ok (gas 22,103)
(chasm) asm mstore(0x80, 1); mstore8(0xa0, 0x61); mload(0x80)
mstore(0x80, 1) -> ok (gas 18)
mstore8(0xa0, 0x61) -> ok (gas 21)
mload(0x80) -> $2 = 0x1 (1) (gas 18)Gas is metered, reported, and then handed back, so poking at the machine cannot turn a transaction that succeeds into one that runs out of gas. Control-flow builtins and the frame terminators are refused, with the reason:
(chasm) jump(0x10)
error: `jump`: Yul has no `jump`; use the debugger's `jump 0xPC` or `set $pc = 0xPC`Convenience variables bypass solc, so they work on contracts you have no source for, and they still mix into a Solidity expression:
(chasm) p $pc
$1 = 2497 (uint256)
(chasm) p $storage[1] + 1 ether
$2 = 2000000000000000000 (2 ether) (uint256)
(chasm) p $stack[0]
$3 = 5000000000000000 (0.005000 ether) (uint256)
(chasm) x/32xb $mem[0x40]
0x0080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0090: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00The full set is $pc $gas $gasused $depth $sp $step $stack[N] $mem[0x40]
$storage[1], plus the value history $1 $2 and so on.
Selectors come out of the bytecode rather than the ABI, so a 4-byte value from a trace can be traced to the pc it routes to even when nothing here declares it:
(chasm) sig transfer(address,uint256)
transfer(address,uint256) 0xa9059cbb (hashed here; not in this ABI)
(chasm) info address 0xa9059cbb
selector 0xa9059cbb
the dispatcher never tests this selector: a call carrying it lands in the fallbackWhen the dispatcher does test it, info address reports the wrapper it jumps to and the
implementation's JUMPDEST behind it, which is the pc every caller converges on. See
docs/commands.md.
find HEX reports every offset in the running code where the byte pattern occurs. Each
hit includes instruction alignment and the nearest preceding JUMPDEST:
(chasm) find 5bchasm mcp serves the debugger over the Model Context Protocol (stdio). It provides
windowed reads with explicit truncation, a stop report after every navigation, and a
diff of what the last step changed. Operand provenance (why N) traces stack values
through recorded opcodes. The server also checks compiled runtime bytecode against a
deployment and exports structLog traces. See docs/mcp.md for the tool list
and client configuration.
chasm compile runs the same build the debugger does and reports what it produced, which is
the fastest way to check whether a contract will be debuggable at all:
$ chasm compile examples/bank/src
cache hit (1 sources)
solc 0.8.36, optimizer off
src/Bank.sol id=0 lines=99
src/Bank.sol:Bank runtime=5302B source-map=yes state-vars=7 fns=15
src/Bank.sol:Callee runtime=468B source-map=yes state-vars=1 fns=2source-map=NO means stepping will not work for that contract. Builds are cached per
compilation unit, so an edit recompiles the file and its importers rather than the project:
$ cd examples/bank && time chasm compile .
wrote 27 artifact(s) to out/chasm
real 1.95
$ time chasm compile .
cache hit (22 sources)
real 0.53
$ time chasm compile . # after editing one test
recompiled 1 of 22 sources
real 1.02Artifacts land in out/chasm/ in forge's layout, and the cache in cache/chasm/. Both are
cleared by forge clean. A directory with no foundry.toml gets nothing written into it.
chasm doctor says which compiler this machine will use, where it came from, and what is
missing. It downloads nothing: a compiler that is not installed yet is reported as the
URL it would be fetched from:
$ chasm doctor
chasm 0.1.0
python 3.12.14 (CPython) at /home/fev/Projects/debugger/.venv/bin/python3
platform linux-arm64 (Linux aarch64, glibc 2.43)
git git version 2.55.0
solc 0.8.28 at /home/fev/.solcx/solc-v0.8.28
installed 0.8.36, 0.8.28
available 68 releases, 0.5.0 .. 0.8.36
wasm solc /home/fev/.local/share/mise/installs/node/latest/bin/node v26.8.1
binaries /home/fev/.solcx
cache /home/fev/.cache/chasmAnything that would stop chasm working is marked ! and makes the command exit non-zero,
so it doubles as a CI check. platform is the build chasm downloads for, and available
is what that platform publishes. Where nothing is published, wasm solc is what will run
instead, and an override from CHASM_SOLC, CHASM_NODE or SOLCX_BINARY_PATH is listed on
an overrides line so a stale environment variable cannot hide.
| Guide | What is in it |
|---|---|
| docs/commands.md | every command, breakpoints and watchpoints, convenience variables |
| docs/expressions.md | evaluating Solidity, reading and writing local variables |
| docs/assembly.md | Yul builtins at the prompt, what is refused and why |
| docs/foundry.md | projects, library install, the build cache, cheatcodes |
| docs/mcp.md | the MCP server for AI clients: tools, data shapes, configuration |
| docs/headless.md | REVM engine protocol for external frontends |
Set up an editable environment:
uv sync
uv run chasm --helpFormat, lint, type-check, and build:
uv run ruff format src tests examples
uv run ruff check src tests examples
uv run mypy src
uv buildThe suite runs under pytest and lives in tests/:
uv run pytest -q
CHASM_NETWORK_TESTS=1 uv run pytest -q -m networkMIT. See LICENSE.
