Event Logs and Pool Reserves: Decode Safely, Track Sync and Reorgs
TECH INSIGHT / EXPLAINED
A correctly decoded log is not automatically a correct pool state. First identify the contract and ABI; then decide whether the event describes a trade or a complete reserve update.
Recognize the event before reading bytes
For a non-anonymous event, topics[0] is the Keccak-256 event signature. Indexed address arguments occupy topics; non-indexed arguments use ABI-encoded data. Indexed dynamic values are hashes rather than a directly recoverable string or array.
An ERC-20 Transfer decoder should require the expected emitting contract, matching signature, three topics and 32 data bytes before extracting addresses and amount. These checks reject a truncated receipt instead of panicking or silently accepting the wrong ABI.
Swap is not the complete reserve history
Uniswap V2 Swap describes four traded amounts. Liquidity minting, burning and explicit syncing also affect reserves, so accumulating Swap deltas alone misses state changes. Use the pair’s Sync(uint112,uint112) reserve values, together with a pinned initial snapshot. This rule concerns the V2 pair implementation; another protocol may expose a different model.
def decode_sync(data: bytes):
if len(data) != 64:
raise ValueError('Sync requires two ABI words')
values = (int.from_bytes(data[:32], 'big'),
int.from_bytes(data[32:], 'big'))
if any(v >= 2**112 for v in values):
raise ValueError('not canonical uint112 values')
return values
payload = (100).to_bytes(32, 'big') + (200).to_bytes(32, 'big')
assert decode_sync(payload) == (100, 200)Connect the snapshot to the stream
At a chosen block, obtain getReserves and preserve its block hash. Backfill logs after that block, order by block number, transaction index and log index, and deduplicate using block hash, transaction hash and log index. Buffer live events while catching up so the handoff does not create a gap.
A WebSocket connection delivers new notifications, not a durable queue. Reconnect using a persisted checkpoint and historical log queries. On removed logs or a changed checkpoint hash, roll back affected state or rebuild from a trusted ancestor. Keep provisional and confirmed state distinguishable.
A small decoder, a larger boundary
The example validates the two words of Sync; it deliberately does not subscribe, choose a contract or handle reorgs. Test those boundaries separately. Raw token integers also need token decimals for display; decimals never change the reserve integer itself.
Validate left padding before extracting an address
An indexed address occupies the rightmost 20 bytes of a 32-byte topic; the preceding 12 bytes must be zero. Simply truncating to an H160 can make a word with invalid high bytes appear to be a valid address. Check length and padding against the address encoding in the Solidity ABI first.
def indexed_address(word):
if len(word) != 32 or any(word[:12]):
raise ValueError("noncanonical address topic")
return "0x" + word[12:].hex()
An independent Python experiment accepted a canonical 32-byte word and rejected 31-byte, 33-byte, and nonzero-high-padding inputs. This function receives bytes already decoded from hexadecimal. Validate the JSON string's 0x prefix, even length, and hexadecimal characters separately before calling it. String length is not the same as decoded byte length.
Identical signatures can hide different indexed layouts
ERC-20 and ERC-721 Transfer events both use the signature Transfer(address,address,uint256). In ordinary ERC-20, the amount is in data; the ERC-721 specification makes tokenId indexed, placing it in a topic. Because the signature hash does not encode indexed annotations, topic0 alone cannot distinguish these layouts.
This provides a concrete reason to combine the emitting contract's expected ABI with topic-count and data-length checks. Quarantine a structural mismatch rather than automatically reinterpreting it through another parser. Apply equivalent strictness to Sync: accept the maximum uint112 value but reject 2^112, so a word with noncanonical upper padding cannot become a reserve value.
Handle removed before ordinary deduplication
If removed=true arrives with the same key as an already applied log, dropping it as “already seen” loses the rollback. First classify it as a change in canonical-chain status and update the stored log's application status. Repeated delivery of a normal log and removal of that log have different meanings. The Geth subscription documentation describes removed logs and renewed delivery on the replacement chain.
Include chain ID when multiple chains share a store. Block hash, transaction hash, and log index identify a particular log on a branch; block height alone cannot distinguish old and replacement branches. When removal notifications are missed or delivered out of order, do not choose current state from notification arrival order. Recheck canonical ancestry through the block headers.
Journal blocks even when they contain no matching logs
Suppose synthetic checkpoint block 100 has reserves (100, 200), and applying old block 101a changes them to (110, 190). Replacement block 101b records (95, 210). Roll back to common ancestor 100 and apply 101b. Subtracting the old Sync values from the new values is incorrect because Sync replaces the full reserves rather than describing a delta.
Journal block number, hash, parent hash, and the before/after state. Advance the checkpoint for a new block 102b even if it contains no relevant logs. Saving state and checkpoint atomically reduces the risk of applying logs while retaining an older position after a crash. The independent synthetic replay applied a duplicate only once, rolled back 101a, and restored (95, 210) on the replacement branch. It did not exercise a production subscription or an actual chain reorganization.
Sources and further reading
Solidity ABI events · Uniswap V2 pair source · Geth subscriptions
Content and examples reviewed: 2026-10-01
댓글
댓글 쓰기