<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Am0MuK</title>
    <description>The latest articles on DEV Community by Am0MuK (@am0muk_3e9691aa890035463c).</description>
    <link>https://dev.to/am0muk_3e9691aa890035463c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4127805%2F6be41006-37d0-40db-92cf-78b2d2f78dc5.png</url>
      <title>DEV Community: Am0MuK</title>
      <link>https://dev.to/am0muk_3e9691aa890035463c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/am0muk_3e9691aa890035463c"/>
    <language>en</language>
    <item>
      <title>The First Bugs My Reconciliation Tool Caught Were Its Own</title>
      <dc:creator>Am0MuK</dc:creator>
      <pubDate>Sun, 27 Sep 2026 10:35:32 +0000</pubDate>
      <link>https://dev.to/am0muk_3e9691aa890035463c/the-first-bugs-my-reconciliation-tool-caught-were-its-own-4890</link>
      <guid>https://dev.to/am0muk_3e9691aa890035463c/the-first-bugs-my-reconciliation-tool-caught-were-its-own-4890</guid>
      <description>&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The page size I specified was wrong, and every test agreed with me&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Etherscan's documentation describes a 10,000-row result window, and I had written about that cap myself. So the client requested &lt;code&gt;offset=10000&lt;/code&gt; and treated any page shorter than that as the last one.&lt;/p&gt;

&lt;p&gt;In reality, Etherscan V2 returned 1,000 rows per page, even when 10,000 were requested. The client saw 1,000 &amp;lt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A single block with more than one page of rows&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Rate limits reported as data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;balanceOf&lt;/code&gt; call reverts.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Two bugs found by reading, not running&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Reading the agent's code line by line turned up two more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A response with &lt;code&gt;status: "1"&lt;/code&gt; but a result that was not a list was silently turned into an empty history.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both have the same shape as the bugs above: an ambiguous input turned into a plausible answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. What "complete" looks like on a real wallet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Transfer&lt;/code&gt; events inconsistent with their own &lt;code&gt;balanceOf&lt;/code&gt;, 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 &lt;code&gt;negative_history&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;deposit()&lt;/code&gt; minus &lt;code&gt;withdraw()&lt;/code&gt; calls exactly, because WETH wraps emit no &lt;code&gt;Transfer&lt;/code&gt; event.&lt;/p&gt;

&lt;p&gt;One more silent failure turned up in the output formatting: Python's &lt;code&gt;Decimal&lt;/code&gt; rounds to 28 significant digits by default, so very large spam amounts were printed rounded and tiny ones as &lt;code&gt;2.2E-7&lt;/code&gt;. Amounts are now formatted with integer arithmetic only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I took from it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;"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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The tool is open source (Python, MIT): github.com/Am0MuK/onchain-tieout&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This describes software behavior, not tax or financial advice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>blockchain</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The Uniswap v4 tick that only breaks when it's negative</title>
      <dc:creator>Am0MuK</dc:creator>
      <pubDate>Fri, 18 Sep 2026 16:58:19 +0000</pubDate>
      <link>https://dev.to/am0muk_3e9691aa890035463c/the-uniswap-v4-tick-that-only-breaks-when-its-negative-1a62</link>
      <guid>https://dev.to/am0muk_3e9691aa890035463c/the-uniswap-v4-tick-that-only-breaks-when-its-negative-1a62</guid>
      <description>&lt;p&gt;A wallet's DeFi positions quietly disappeared from a report I generate. Not zeroed — &lt;em&gt;absent&lt;/em&gt;. No error, no stack trace, no failed run. The pipeline said it finished successfully.&lt;/p&gt;

&lt;p&gt;The cause was one line of tick decoding that is wrong only when the number is negative. Everything about how it hid is more interesting than the bug itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to read a tick
&lt;/h2&gt;

&lt;p&gt;The code reads Uniswap v4 positions in two steps.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;PositionManager.getPoolAndPositionInfo(tokenId)&lt;/code&gt; returns the pool key and a &lt;code&gt;PositionInfo&lt;/code&gt; — a single 256-bit word with several fields bit-packed into it. From the least significant bit: 8 bits &lt;code&gt;hasSubscriber&lt;/code&gt;, 24 bits &lt;code&gt;tickLower&lt;/code&gt;, 24 bits &lt;code&gt;tickUpper&lt;/code&gt;, 200 bits of truncated pool id.&lt;/p&gt;

&lt;p&gt;To get a tick out of that word you shift, mask, and sign-extend by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sign_extend_24&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;

&lt;span class="n"&gt;tick_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_sign_extend_24&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;position_info&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFFFFFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tick_upper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_sign_extend_24&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;position_info&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFFFFFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is correct, and it matches what Uniswap's own library does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;_tickLower := signextend(2, shr(TICK_LOWER_OFFSET, info))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, &lt;code&gt;StateView.getSlot0(poolId)&lt;/code&gt; returns the pool's current state. Its signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function getSlot0(PoolId poolId)
    external view
    returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four separate return values. And here I wrote this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;current_tick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_sign_extend_24&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_parse_u256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_word&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same helper, same idea, second word of the return data. It looks consistent with the packed decode three lines above it. It is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's wrong
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getSlot0&lt;/code&gt; is an ordinary external function returning an ordinary tuple. The ABI encoder gives every return value its own 32-byte word, and for a signed type it &lt;strong&gt;sign-extends into the full word&lt;/strong&gt;. By the time the bytes reach you, &lt;code&gt;int24 tick = -100&lt;/code&gt; is already two's complement across all 256 bits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not a 24-bit field inside a larger word. It is a complete 256-bit signed integer, and the only correct way to read it is as one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_parse_i256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hex_word&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_parse_u256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hex_word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed that same word to the 24-bit path instead and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;_parse_u256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# 115792089237316195423570985008687907853269984665640564039457584007913129639836
&lt;/span&gt;&lt;span class="nf"&gt;_sign_extend_24&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;           &lt;span class="c1"&gt;# 115792089237316195423570985008687907853269984665640564039457584007913112862620
&lt;/span&gt;&lt;span class="nf"&gt;_parse_i256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# -100   ← correct
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_sign_extend_24&lt;/code&gt; checks whether bit 23 is set. In a fully sign-extended negative number every high bit is set, so the check passes, it subtracts 2²⁴, and returns a number that is still astronomically large. The guard fires and accomplishes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it stayed hidden
&lt;/h2&gt;

&lt;p&gt;Positive ticks are unaffected. &lt;code&gt;int24 tick = 12345&lt;/code&gt; ABI-encodes to a word with leading zeros — indistinguishable from an unsigned 24-bit field. &lt;code&gt;_sign_extend_24&lt;/code&gt; sees bit 23 clear, returns the value untouched, and everything downstream is correct.&lt;/p&gt;

&lt;p&gt;So the bug is invisible in exactly the situation you test first: a pool whose price sits above the 1:1 raw ratio.&lt;/p&gt;

&lt;p&gt;Negative ticks are not an edge case. A tick is &lt;code&gt;log₁.₀₀₀₁(price)&lt;/code&gt; where price is currency1 per currency0 &lt;strong&gt;in raw base units&lt;/strong&gt;, and decimals are part of that ratio. In any v4 pool paired with native ETH, ETH is currency0 — &lt;code&gt;address(0)&lt;/code&gt; sorts below every token. Pair it with USDC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 ETH ≈ 3000 USDC
raw ratio = 3000 × 10⁶ / 10¹⁸ = 3 × 10⁻⁹
tick = ln(3 × 10⁻⁹) / ln(1.0001) ≈ -196,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every ETH/stablecoin pool in v4 sits at a deeply negative tick, permanently. Not a boundary condition — the common case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing reported it
&lt;/h2&gt;

&lt;p&gt;The corrupted tick goes into a price calculation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sqrt_price_at_tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0001&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a tick around 1.16 × 10⁷⁷ that raises &lt;code&gt;OverflowError&lt;/code&gt;. Which would be a fine, loud failure — except the enrichment calls were wrapped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uniswap_v3_positions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_uniswap_v3_positions&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uniswap_v4_positions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_uniswap_v4_positions&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;morpho_positions&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_morpho_positions&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compound_v3_positions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_compound_v3_positions&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;euler_positions&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_euler_positions&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pendle_positions&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_pendle_positions&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;curve_positions&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_curve_positions&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Position enrichment failed (non-fatal): &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;except&lt;/code&gt; around seven protocols, logged at &lt;code&gt;info&lt;/code&gt;. A single negative tick in one Uniswap pool therefore removed the wallet's Morpho, Compound, Euler, Pendle and Curve positions from the report as well — because they are simply the lines that never ran. The log line that would have explained it sits below the level anyone filters for in production.&lt;/p&gt;

&lt;p&gt;Three failures stacked: a decode that is wrong only for negative numbers, a sign asymmetry that hides it during testing, and an exception handler broad enough to turn it into silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Two decodings, one file, opposite handling — and the difference is not the type, it's the path the value took to reach you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What you receive&lt;/th&gt;
&lt;th&gt;How to decode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A return value of an external function&lt;/td&gt;
&lt;td&gt;Already sign-extended to 32 bytes by the ABI encoder&lt;/td&gt;
&lt;td&gt;Read the whole word as signed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A field inside a manually packed word&lt;/td&gt;
&lt;td&gt;A raw bit range, no sign extension&lt;/td&gt;
&lt;td&gt;Mask, then sign-extend by width&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Uniswap's own library shows both. &lt;code&gt;PositionInfoLibrary&lt;/code&gt; calls &lt;code&gt;signextend(2, ...)&lt;/code&gt; because it is pulling bits out of storage it packed itself. &lt;code&gt;getSlot0&lt;/code&gt; doesn't, because returning &lt;code&gt;int24&lt;/code&gt; through the ABI already did it.&lt;/p&gt;

&lt;p&gt;The fix was one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;current_tick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_parse_i256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_word&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which is what the v3 path in the same file had been doing correctly all along, a few hundred lines up.&lt;/p&gt;

&lt;p&gt;Two changes worth making beyond the fix: raise that &lt;code&gt;logger.info&lt;/code&gt; to &lt;code&gt;warning&lt;/code&gt;, and treat "a whole class of positions vanished from output" as a condition worth asserting on, not something to discover by reading a report and noticing an absence.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written from a real bug in a multi-chain DeFi tax engine I maintain. References: &lt;a href="https://docs.uniswap.org/contracts/v4/reference/periphery/lens/StateView" rel="noopener noreferrer"&gt;StateView&lt;/a&gt;, &lt;a href="https://docs.uniswap.org/contracts/v4/reference/periphery/libraries/PositionInfoLibrary" rel="noopener noreferrer"&gt;PositionInfoLibrary&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ethereum</category>
      <category>python</category>
      <category>debugging</category>
      <category>web3</category>
    </item>
    <item>
      <title>Five Ways DeFi Tax Software Silently Produces Wrong Numbers</title>
      <dc:creator>Am0MuK</dc:creator>
      <pubDate>Wed, 16 Sep 2026 18:36:26 +0000</pubDate>
      <link>https://dev.to/am0muk_3e9691aa890035463c/five-ways-defi-tax-software-silently-produces-wrong-numbers-4pl7</link>
      <guid>https://dev.to/am0muk_3e9691aa890035463c/five-ways-defi-tax-software-silently-produces-wrong-numbers-4pl7</guid>
      <description>&lt;p&gt;Anyone who reconciles DeFi portfolios for tax purposes knows the pattern: a tool pulls the wallet history, applies FIFO, spits out a number. The number looks plausible. It gets filed.&lt;/p&gt;

&lt;p&gt;The problem is rarely the calculation logic. FIFO is FIFO. The problem sits one layer down — in whether the engine actually saw every relevant transaction before it started calculating. And that's exactly where systems fail silently: no error, no crash, just an incomplete number wearing the same confident formatting as a complete one.&lt;/p&gt;

&lt;p&gt;We ran our own engine against real multi-chain wallets for months and found (and fixed) five structural failure classes. Below are five concrete failure modes, with the technical mechanisms behind them — not because they're quirks of our implementation, but because they can affect any engine that relies on the same underlying data sources and assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Provider rate tiers that refuse entire chains — with HTTP 200
&lt;/h2&gt;

&lt;p&gt;Explorer APIs (Etherscan V2 and compatible endpoints on other chains) respond to low-tier requests on certain chains not with an error, but with a success-shaped status message: &lt;code&gt;status: "0"&lt;/code&gt;, message &lt;em&gt;"Free API access is not supported for this chain"&lt;/em&gt;. No HTTP error code, no exception — a formally valid response with no content.&lt;/p&gt;

&lt;p&gt;An engine that doesn't check the status field inside the body logs this as "no transactions on this chain." On a multi-chain portfolio, that means an entire chain silently disappears from the tax report. (Confirmed live, including on Base.)&lt;/p&gt;

&lt;p&gt;A related second case: the same APIs cap result sets (typically 10,000 rows per query). Without active pagination via &lt;code&gt;startblock&lt;/code&gt;, you get the first 10,000 entries and treat them as the full history.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Internal transfers that simply aren't available on most chains
&lt;/h2&gt;

&lt;p&gt;Native internal transfers (contract-to-contract, triggered by a smart contract call rather than an external transaction) aren't an edge case for DeFi users — bridges, liquidations, and wrapper repayments run through them.&lt;/p&gt;

&lt;p&gt;On Alchemy, the &lt;code&gt;internal&lt;/code&gt; category in &lt;code&gt;alchemy_getAssetTransfers&lt;/code&gt; is only queryable on a subset of chains. On the rest, the request doesn't partially degrade — it fails &lt;strong&gt;completely&lt;/strong&gt; with a &lt;code&gt;-32602&lt;/code&gt; RPC error, taking every other requested category down with it. An engine has to know, per chain, whether it's even allowed to ask for that category.&lt;/p&gt;

&lt;p&gt;The honest consequence is uncomfortable: on chains where the provider doesn't support the category, internal transfers via this path are &lt;strong&gt;not collectible&lt;/strong&gt;. That's not a bug you code away — it's a data gap you either close with a second source or declare openly in the report. What you can't do is silently log it as "no internal transfers found."&lt;/p&gt;

&lt;h2&gt;
  
  
  3. WETH unwraps that emit no transfer event
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WETH9.withdraw(uint256)&lt;/code&gt; burns the WETH balance and sends native ETH 1:1 — but only emits a &lt;code&gt;Withdrawal&lt;/code&gt; log, &lt;strong&gt;never&lt;/strong&gt; a standard ERC-20 &lt;code&gt;Transfer&lt;/code&gt; event.&lt;/p&gt;

&lt;p&gt;Every common indexer (Etherscan's &lt;code&gt;tokentx&lt;/code&gt;, Alchemy's &lt;code&gt;erc20&lt;/code&gt; category) is built on transfer logs. The WETH leg of the operation simply doesn't exist for them. What lands in the journal is only the native ETH side flowing back.&lt;/p&gt;

&lt;p&gt;The result is inventory drift in both directions: ETH appears from nowhere, while the corresponding WETH position never gets drawn down. Without an active reconciliation between computed and actual on-chain balance, this doesn't surface — the report stays internally consistent and is still wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Phantom transfers from failed transactions
&lt;/h2&gt;

&lt;p&gt;Explorer APIs' transaction lists include transactions that reverted on-chain — with a non-zero &lt;code&gt;value&lt;/code&gt; field. The transaction was sent, burned gas, but the state change never happened.&lt;/p&gt;

&lt;p&gt;Skip checking &lt;code&gt;isError&lt;/code&gt; / &lt;code&gt;txreceipt_status&lt;/code&gt; separately, and you book an asset movement that never occurred. Depending on where this phantom entry lands in the FIFO stack, it shifts acquisition order, holding periods, and therefore tax liability — in either direction.&lt;/p&gt;

&lt;p&gt;(The gas fee actually spent is real, by the way, and stays correctly recorded — only the transfer itself isn't.)&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The same method selector meaning a different asset on every chain
&lt;/h2&gt;

&lt;p&gt;DeFi protocols get recognized by method selectors (the first 4 bytes of calldata). Aave's &lt;code&gt;WrappedTokenGateway&lt;/code&gt; uses the exact same &lt;code&gt;depositETH&lt;/code&gt; selector for native-gas-token deposits on &lt;strong&gt;every&lt;/strong&gt; chain — including chains where the native token isn't ETH at all.&lt;/p&gt;

&lt;p&gt;On Sonic, that identical selector fires for a native &lt;strong&gt;S&lt;/strong&gt; deposit. An engine that infers the asset from the selector's name books WETH instead of WS: wrong asset, wrong price, wrong cost basis — while correctly recognizing the transaction type.&lt;/p&gt;

&lt;p&gt;The fix requires deriving the actual token that moved from the transaction's balance changes, instead of guessing it from the selector name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these five have in common
&lt;/h2&gt;

&lt;p&gt;None of them throws an error. Each produces a number that looks like a complete calculation — cleanly formatted, decimal places included, ready to file. The difference between a correct and a silently incomplete tax calculation isn't visible from outside without active cross-checking.&lt;/p&gt;

&lt;p&gt;That's why we built our engine to report its own incompleteness instead of hiding it: an automatic tie-out between on-chain balance and computed inventory after every run, a completeness statement listing which chains were covered with which data source and coverage level, and a hard rule that an unresolvable transaction gets flagged, never silently dropped.&lt;/p&gt;

&lt;p&gt;The question worth asking any DeFi tax tool isn't &lt;em&gt;"does it calculate FIFO correctly?"&lt;/em&gt; — most do. It's:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What does it do when it can't fully attribute a transaction — does it show you, or does it hide it?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This describes software behavior, not tax advice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>defi</category>
      <category>ethereum</category>
      <category>python</category>
    </item>
  </channel>
</rss>
