스마트컨트랙트 이벤트 로그 파싱 — topic과 data로 풀 상태 추적하기
온체인 봇이 "지금 이 풀에 얼마가 있지?"를 알아내는 방법은 두 가지입니다. 하나는 매번 컨트랙트에 예비금을 물어보는 것(느리고 RPC를 많이 씀), 다른 하나는 이벤트 로그를 구독해 변화를 따라가는 것입니다. 온체인의 거의 모든 행위 — 전송, 스왑, 유동성 추가 — 는 이벤트 로그로 남습니다. 이걸 읽으면 폴링 없이 상태를 실시간으로 추적할 수 있죠.
로그의 구조 — topics와 data
이벤트 로그 하나는 두 부분으로 나뉩니다. topics(인덱싱된 배열)와 data(나머지 값들). 규칙은 이렇습니다.
- topics[0] — 이벤트 시그니처의 해시. "이게 무슨 이벤트인가"를 알려주는 지문입니다.
- topics[1..] —
indexed로 선언된 인자들(주소 등). 이 필드로 필터링할 수 있습니다. - data — 인덱싱되지 않은 나머지 인자들이 순서대로 붙어 있는 바이트 뭉치.
예를 들어 ERC-20의 Transfer(address indexed from, address indexed to, uint256 value)는 from과 to가 topics로, value가 data로 들어갑니다.
// 이벤트 시그니처 해시(topics[0])를 미리 계산해 둔다
var (
transferSig = crypto.Keccak256Hash(
[]byte("Transfer(address,address,uint256)"))
// UniswapV2 Swap 이벤트
swapSig = crypto.Keccak256Hash(
[]byte("Swap(address,uint256,uint256,uint256,uint256,address)"))
)
Transfer 로그 디코딩
topics는 32바이트로 패딩돼 있습니다. 주소는 20바이트라, 앞의 12바이트를 잘라내야 실제 주소가 나옵니다. data의 값은 big.Int로 읽습니다.
func parseTransfer(vLog types.Log) (from, to common.Address, value *big.Int) {
// topics[0]=시그니처, topics[1]=from, topics[2]=to
from = common.BytesToAddress(vLog.Topics[1].Bytes()) // 뒤 20바이트만 취함
to = common.BytesToAddress(vLog.Topics[2].Bytes())
// data에는 32바이트 value 하나
value = new(big.Int).SetBytes(vLog.Data)
return
}
common.BytesToAddress가 32바이트에서 뒤쪽 20바이트만 취해 패딩을 알아서 벗겨 줍니다. 이런 헬퍼 덕에 손으로 바이트를 자를 일은 줄지만, topic은 왼쪽 패딩이라는 규칙은 기억해 둬야 합니다.
Swap 이벤트로 풀 흐름 읽기
UniswapV2 Swap 이벤트는 data에 네 개의 값을 담습니다 — amount0In, amount1In, amount0Out, amount1Out. 어느 방향으로 얼마가 오갔는지가 여기 다 있습니다. 32바이트씩 잘라 순서대로 읽습니다.
func parseSwap(vLog types.Log) (a0In, a1In, a0Out, a1Out *big.Int) {
d := vLog.Data // 32바이트 × 4 = 128바이트
a0In = new(big.Int).SetBytes(d[0:32])
a1In = new(big.Int).SetBytes(d[32:64])
a0Out = new(big.Int).SetBytes(d[64:96])
a1Out = new(big.Int).SetBytes(d[96:128])
return
}
이 네 값이면 예비금 변화를 그대로 계산할 수 있습니다. 토큰0이 들어온 만큼 예비금0은 늘고, 토큰0이 나간 만큼 줄죠. 매번 getReserves()를 호출하지 않고도, Swap 이벤트만 누적하면 로컬에서 풀 상태를 최신으로 유지할 수 있습니다.
구독해서 실시간으로 받기
관심 있는 풀 주소와 이벤트 시그니처로 필터를 만들어 구독하면, 새 로그가 생길 때마다 채널로 밀려옵니다.
query := ethereum.FilterQuery{
Addresses: poolAddrs, // 추적할 풀들
Topics: [][]common.Hash{{swapSig}}, // Swap 이벤트만
}
logs := make(chan types.Log)
sub, err := client.SubscribeFilterLogs(ctx, query, logs)
if err != nil {
return fmt.Errorf("로그 구독 실패: %w", err)
}
for {
select {
case err := <-sub.Err():
return err // 끊기면 재구독 (웹소켓 재연결과 동일한 원리)
case vLog := <-logs:
a0In, a1In, a0Out, a1Out := parseSwap(vLog)
pool.ApplySwap(vLog.Address, a0In, a1In, a0Out, a1Out)
}
}
실전에서 조심할 것
- 재구성(reorg)을 감안하라. 방금 받은 로그가 재구성으로 무효가 될 수 있습니다.
vLog.Removed가 true면 그 변화를 되돌려야 합니다. - data 길이를 검증하라. 컨트랙트마다 이벤트 정의가 미묘하게 다를 수 있으니, 자르기 전에
len(vLog.Data)를 확인해 패닉을 막습니다. - 초기 상태는 한 번 조회로. 이벤트는 '변화'만 알려줍니다. 시작 시점의 예비금은
getReserves()로 한 번 읽어 기준을 잡고, 이후 이벤트로 갱신하세요. - 토큰0/토큰1 순서를 확인하라. 풀마다 어느 토큰이 0번인지 다릅니다. 주소 크기 순으로 정렬되니, 방향 계산 전에 매핑을 확실히 해 두세요.
이벤트 로그는 온체인이 남기는 공식 영수증입니다. topic과 data의 구조만 이해하면, 봇은 무거운 폴링 없이도 수백 개 풀의 상태를 실시간으로 손안에 쥘 수 있습니다. 빠른 봇과 느린 봇의 차이가 바로 여기서 갈립니다.
이 글은 교육·정보 목적이며 투자 권유가 아닙니다. 온체인 자동매매에는 원금 손실 위험이 있고, 결과에 대한 책임은 본인에게 있습니다.
댓글
댓글 쓰기