智能体
dev.to ·
@Am0MuK
我的对账工具最先抓到的 bug 是它自己的
作者开发开源工具 onchain-tieout,用于验证从钱包交易历史重建的余额是否与链上同一区块报告一致,起因是数据管道会静默失败(如 API 返回 HTTP 200 但 body 内含错误、结果分页被静默截断、已回滚交易仍显示非零值)。首版由 AI coding agent 按规格生成,离线测试全部通过,但在真实主网钱包上首先暴露的是自身缺陷:按 Etherscan 文档假设 10000 行窗口并据此判断末页,而 Etherscan V2 实际返回行为不同。
AI推荐理由 作者用 AI coding agent 按规格生成开源对账工具 onchain-tieout,用于校验从钱包交易历史重建的余额是否与链上同一区块报告一致。文章给出具体失败案例:Etherscan 文档称结果窗口为 10000 行,但 V2 实际返回行为不同,导致分页判断错误;离线测试全绿却在真实主网钱包上暴露自身 bug。对 AI 生成代码的验证局限有实证价值,但属个人项目复盘,影响范围有限。
63
正文
I wrote a small open-source tool, onchain-tieout, to answer one question: does the balance you reconstruct from a wallet's transaction history match the balance the chain actually reports, at the same block?
That check exists because data pipelines fail silently. An explorer API returns HTTP 200 with an error inside the body, a page of results is capped without notice, a transaction that reverted still shows a non-zero value. Everything "succeeds", and the number is wrong.
The first implementation came from an AI coding agent working from my specification. The offline test suite was green. Then I pointed the tool at a real mainnet wallet, and the first things it found were bugs in itself.
1. The page size I specified was wrong, and every test agreed with me
Etherscan's documentation describes a 10,000-row result window, and I had written about that cap myself. So the client requested offset=10000 and treated any page shorter than that as the last one.
In reality, Etherscan V2 returned 1,000 rows per page, even when 10,000 were requested. The client saw 1,000 < 10,000, concluded it had reached the end of the history, and stopped. That is silent truncation, the failure class the tool exists to catch.
The tie-out itself caught it: the reconstructed ETH balance was far off the on-chain balance at the same block. The tool did its job on its own code.
The tests missed it because every offline test used a mocked API, and the mocks were written from the same wrong assumption as the code. A mock can only confirm what you already believe about the system behind it.
2. A single block with more than one page of rows
The standard way to paginate an explorer is by block: take the last block of a full page, drop its rows, and request again from that block, so the boundary block is fetched whole.
The real wallet had more than 1,000 token transfers in one block (a spam airdrop). A block-based cursor cannot move past a block that alone fills a whole page. The fix reads such a block on its own with page numbers, up to the API's 10,000-row window, and fails loudly beyond that instead of guessing. Before the fix, that wallet's balances came out wrong.
3. Rate limits reported as data
The next run marked about 9,700 tokens as "balance could not be read". That label is meant for spam or non-standard token contracts whose balanceOf call reverts.
Sampling the failures showed most of them were HTTP 429 responses from the RPC provider. The tool was turning "the provider is busy" into "this token is unreadable", a network problem reported as a fact about the data.
The fix separates the two. Transport problems (429, 5xx, timeouts) are retried with back-off and, if they persist, abort the run. Only a real revert or an empty result is reported as an unreadable balance.
4. Two bugs found by reading, not running
Reading the agent's code line by line turned up two more:
A response with status: "1" but a result that was not a list was silently turned into an empty history.
De-duplicating token transfers across page boundaries by a key without the log index could merge two genuinely identical transfers in the same transaction into one.
Both have the same shape as the bugs above: an ambiguous input turned into a plausible answer.
5. What "complete" looks like on a real wallet
With the fixes in place, the full run on that wallet (vitalik.eth) produced 10,476 balance rows. 8,281 tied out exactly. Almost all of the rest were airdropped spam: contracts that emit Transfer events inconsistent with their own balanceOf, many reusing real symbols such as USDC or WETH on other addresses. 175 of them had a history in which the wallet sent more than it ever received, impossible for a standard ERC-20, now labelled negative_history.
On the assets that matter, the result is clean. ETH, DAI, USDC and USDT tie out to the last unit. stETH differs because it rebases without transfers. The WETH difference equals the wallet's own deposit() minus withdraw() calls exactly, because WETH wraps emit no Transfer event.
One more silent failure turned up in the output formatting: Python's Decimal rounds to 28 significant digits by default, so very large spam amounts were printed rounded and tiny ones as 2.2E-7. Amounts are now formatted with integer arithmetic only.
What I took from it
Offline tests prove the code matches your assumptions. Only a run against the real system tests the assumptions. A reconciliation check is also worth running on the reconciliation tool itself: the first live tie-out failed for the right reason, and that failure was the most useful test result of the project.
"Could not read" and "does not exist" are different answers, and a tool that merges them will eventually report an outage as a fact about your data. When the input is ambiguous, fail loudly. Every bug here was an ambiguous case resolved into a plausible number.
Getting from the first live run to a clean tie-out took days. I would let an AI agent write the first version of a tool like this again.
The tool is open source (Python, MIT): github.com/Am0MuK/onchain-tieout
This describes software behavior, not tax or financial advice.