Deep EVM #18: EVM 바이트코드 디버깅 — 트레이스, 스택 덤프, cast run
Engineering Team
저수준 EVM 코드의 디버깅 과제
Solidity 트랜잭션이 되돌려지면, 일반적으로 ERC20: transfer amount exceeds balance와 같은 설명적 에러 메시지를 받습니다. Huff나 Yul 트랜잭션이 되돌려지면, 0x — 컨텍스트가 전혀 없는 빈 되돌림 페이로드만 받습니다. 컨트랙트가 단순히 REVERT 옵코드에 도달한 것이며, 이유를 파악하는 것은 여러분의 몫입니다.
바이트코드 수준의 디버깅은 다른 도구와 사고 모델을 필요로 합니다. 스택 머신 관점에서 생각하고, 옵코드별로 메모리와 스토리지 변경을 추적하며, EVM이 JUMP과 JUMPI 명령을 통해 제어 흐름을 어떻게 실행하는지 이해해야 합니다.
이 글에서는 필수 디버깅 툴킷을 다룹니다: 과거 트랜잭션 리플레이를 위한 cast run, 대화형 단계별 디버깅을 위한 forge debug, 그리고 EVM 내부에서 정확히 무슨 일이 일어났는지 이해하기 위한 수동 트레이스 분석.
cast run: 트랜잭션 리플레이
cast run은 실패한 트랜잭션을 디버깅하는 가장 빠른 방법입니다. 과거 상태에 대해 트랜잭션을 리플레이하고 정확히 무슨 일이 일어났는지 보여줍니다:
cast run 0xYOUR_TX_HASH --rpc-url https://eth-mainnet.g.alchemy.com/v2/KEY
출력은 호출 깊이, 가스 사용량, 반환 데이터가 포함된 구조화된 트레이스를 보여줍니다:
Traces:
[328439] 0xContractAddr::transfer(0xRecipient, 1000000000000000000)
+- [2604] 0xContractAddr::balanceOf(0xSender) [staticcall]
| +- <- 500000000000000000
+- <- revert: EvmError: Revert
이것은 발신자가 0.5 ETH를 가지고 있지만 1.0 ETH를 전송하려 했기 때문에 전송이 실패했다는 것을 즉시 알려줍니다. Huff 컨트랙트의 경우, 함수 이름이 디코딩되지 않지만(원시 셀렉터로 표시) 호출 구조와 되돌림 지점은 여전히 볼 수 있습니다.
원시 셀렉터 디코딩
Huff 컨트랙트 작업 시 cast run은 원시 함수 셀렉터를 보여줍니다. 수동으로 디코딩합니다:
# balanceOf(address)의 셀렉터 계산
cast sig "balanceOf(address)"
# 출력: 0x70a08231
# 또는 콜데이터 디코딩
cast 4byte-decode 0x70a0823100000000000000000000000042069abcdef
# 출력: balanceOf(address)(0x42069abcdef)
디버깅 시 컨트랙트의 셀렉터 참조 테이블을 유지합니다:
0x70a08231 -> balanceOf(address)
0xa9059cbb -> transfer(address,uint256)
0x23b872dd -> transferFrom(address,address,uint256)
0x095ea7b3 -> approve(address,uint256)
forge debug: 대화형 단계별 실행
forge debug는 옵코드별로 EVM 실행을 단계별로 진행하는 TUI(터미널 사용자 인터페이스)를 제공합니다:
forge debug --debug test/SimpleToken.t.sol \
--sig "test_transfer()" -vvvv
인터페이스는 네 개의 패널을 보여줍니다:
- 옵코드 — 커서가 있는 현재 명령, 실행 중인 바이트코드 표시
- 스택 — 모든 32바이트 워드가 포함된 현재 스택 상태
- 메모리 — 16진수 원시 메모리 내용
- 스토리지 — 실행 중 스토리지 슬롯 변경
탐색 키:
j/k— 앞으로/뒤로 단계g/G— 시작/끝으로 점프c— 다음 호출 경계까지 계속C— 다음 테스트까지 계속q— 종료
디버깅 중 스택 읽기
EVM 스택은 최대 깊이 1024의 후입선출(LIFO)입니다. Huff를 디버깅할 때는 각 옵코드가 무엇을 소비하고 생성하는지 이해하기 위해 스택을 정신적으로 추적해야 합니다.
이 Huff 스니펫을 봅시다:
0x04 calldataload // 스택: [address]
BALANCES_SLOT // 스택: [slot, address]
calldataload 후 스택에는 주소 매개변수가 있습니다. 스토리지 포인터를 푸시한 후에는 [slot, address]가 있습니다. 스택 위치 0에서 잘못된 값을 보면, 스토리지 슬롯이 어떻게 계산되는지에 버그가 있다는 것을 알 수 있습니다.
옵코드 트레이스 이해하기
프로덕션 디버깅(로컬에서 문제를 재현할 수 없을 때)에서는 아카이브 노드의 원시 옵코드 트레이스가 주요 도구입니다. Tenderly, Etherscan, Alchemy와 같은 서비스가 트레이스 API를 제공합니다:
# cast를 통해 트레이스 가져오기
cast run TX_HASH --rpc-url $RPC -vvvvv 2>&1 | head -200
상세 트레이스 형식은 가스 비용과 스택 상태가 포함된 각 옵코드를 보여줍니다:
[0] PUSH1 0x00 gas: 29234 stack: []
[2] CALLDATALOAD gas: 29231 stack: [0x00]
[3] PUSH1 0xe0 gas: 29228 stack: [0xa9059cbb...]
[5] SHR gas: 29225 stack: [0xa9059cbb..., 0xe0]
[6] DUP1 gas: 29222 stack: [0xa9059cbb]
[7] PUSH4 0x70a08231 gas: 29219 stack: [0xa9059cbb, 0xa9059cbb]
[12] EQ gas: 29216 stack: [0xa9059cbb, 0xa9059cbb, 0x70a08231]
[13] PUSH2 0x0040 gas: 29213 stack: [0xa9059cbb, 0x00]
[16] JUMPI gas: 29210 stack: [0xa9059cbb, 0x00, 0x0040]
이 트레이스는 함수 디스패처가 셀렉터가 balanceOf(address)와 일치하는지 확인하는 것을 보여줍니다. 실제 셀렉터가 0xa9059cbb(transfer)이므로 EQ는 0x00(거짓)을 생성하고 JUMPI는 점프하지 않습니다.
Huff와 Yul을 위한 일반적인 디버깅 패턴
패턴 1: 스택 언더플로
저렴해 보이는 옵코드에서 가스 부족 에러로 실행이 되돌려지면, 스택 언더플로일 가능성이 높습니다. EVM에는 전용 “스택 언더플로” 에러가 없으며 — 모든 가스를 소비할 뿐입니다.
// 버그: 스택이 비어 있을 때 pop
#define macro BROKEN() = takes (0) returns (0) {
pop // 스택 언더플로! 팝할 항목이 없음
}
감지: forge debug에서 스택 패널을 관찰합니다. 소비 옵코드 전에 0개 항목이 표시되면 그것이 버그입니다.
패턴 2: 잘못된 JUMP 목적지
Huff는 점프 목적지에 레이블을 사용합니다. 레이블이 JUMPDEST가 아닌 옵코드로 해결되면 트랜잭션이 되돌려집니다:
#define macro MAIN() = takes (0) returns (0) {
0x01 success jumpi
0x00 0x00 revert
success: // JUMPDEST여야 함
0x00 0x00 return
}
감지: 트레이스에서 JUMP 또는 JUMPI 다음에 즉시 가스가 소진되는 것을 찾습니다. 대상 PC는 점프 전 스택 상단에 있습니다.
패턴 3: 잘못된 ABI 인코딩
Huff는 반환 값을 자동 인코딩하지 않습니다. 적절한 ABI 인코딩 없이 원시 바이트를 반환하면, 호출하는 컨트랙트의 디코더가 되돌려집니다:
// 잘못됨: 오프셋 없이 원시 uint256 반환
0x00 mstore
0x20 0x00 return
// 동적 타입에 올바른 방법: 오프셋 포함
0x20 0x00 mstore // 오프셋
0x05 0x20 mstore // 길이
// ... 0x40의 데이터
감지: 호출하는 컨트랙트의 abi.decode가 되돌려집니다. 트레이스에서 컨트랙트의 성공적인 반환 후 부모 컨텍스트에서 되돌림이 나타납니다.
패턴 4: 스토리지 충돌
Huff는 FREE_STORAGE_POINTER()를 사용하여 스토리지 슬롯을 할당합니다. 두 매크로가 실수로 같은 슬롯을 사용하면 서로를 덮어씁니다:
#define constant BALANCES_SLOT = FREE_STORAGE_POINTER() // 슬롯 0
#define constant ALLOWANCES_SLOT = FREE_STORAGE_POINTER() // 슬롯 1
#define constant TOTAL_SUPPLY_SLOT = FREE_STORAGE_POINTER() // 슬롯 2
감지: forge debug에서 스토리지 패널을 관찰합니다. 하나의 매핑에 쓰기가 다른 변수를 변경하면 충돌이 있는 것입니다.
디버깅 워크플로 구축
Huff 컨트랙트를 디버깅하는 체계적 접근법:
- 재현 — 버그를 트리거하는 실패 테스트를 Foundry에서 작성
- 트레이스 —
-vvvvv로 실행하여 전체 옵코드 트레이스 획득 - 범위 축소 — 동작이 기대에서 벗어나는 정확한 옵코드 식별
- 비교 — Solidity 참조 구현에서 같은 시나리오 실행
- 수정 — Huff 매크로를 수정하고 차등 테스트가 통과하는지 확인
- 회귀 — 실패 케이스를 영구 테스트 스위트에 추가
# 1단계: 실패 테스트 실행
forge test --match-test test_brokenTransfer -vvvvv
# 2단계: 대화형 디버깅
forge debug --debug test/Token.t.sol --sig "test_brokenTransfer()"
# 3단계: 수정 후 검증
forge test --match-contract DifferentialTest
forge snapshot --check
Tenderly를 사용한 프로덕션 디버깅
이미 배포된 컨트랙트의 경우, Tenderly는 디코딩된 함수 호출, 상태 변경, 가스 사용량이 포함된 실행 트레이스를 보여주는 시각적 디버거를 제공합니다:
# 분석을 위한 트랜잭션 내보내기
cast run TX_HASH --rpc-url $RPC --json > trace.json
Tenderly의 시각적 디버거는 각 옵코드에 스택에 대한 효과를 주석으로 달아주므로, 수동으로 스택 상태를 추적하지 않고도 에러를 발견할 수 있어 Huff에 특히 유용합니다.
결론
EVM 바이트코드 디버깅은 취미 Huff 개발자와 프로덕션급 개발자를 구분하는 기술입니다. 빠른 트랜잭션 리플레이를 위한 cast run, 대화형 분석을 위한 forge debug, 프로덕션 인시던트를 위한 수동 트레이스 읽기를 마스터하세요. 체계적 워크플로를 구축하세요: 재현, 트레이스, 범위 축소, 비교, 수정, 회귀. EVM 스택에서 더 낮은 수준으로 갈수록 디버깅 프로세스는 더욱 체계적이어야 합니다.