<?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 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>
