<?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: Andrew Maury</title>
    <description>The latest articles on DEV Community by Andrew Maury (@andrewmaury).</description>
    <link>https://dev.to/andrewmaury</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%2F3993629%2Fa72c3384-bc53-4d9a-9c4c-29fd4b073e94.jpg</url>
      <title>DEV Community: Andrew Maury</title>
      <link>https://dev.to/andrewmaury</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andrewmaury"/>
    <language>en</language>
    <item>
      <title>Where Dollar Stablecoins Sit Against $1</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Thu, 29 Oct 2026 16:43:54 +0000</pubDate>
      <link>https://dev.to/andrewmaury/where-dollar-stablecoins-sit-against-1-2blp</link>
      <guid>https://dev.to/andrewmaury/where-dollar-stablecoins-sit-against-1-2blp</guid>
      <description>&lt;p&gt;On 21 October 2026 the OCC &lt;a href="https://www.occ.gov/news-issuances/news-releases/2026/nr-occ-2026-92a.pdf" rel="noopener noreferrer"&gt;finalized its rule&lt;/a&gt; under the GENIUS Act, and its &lt;a href="https://occ.gov/topics/charters-and-licensing/genius-act/community-bank-fact-sheet.pdf" rel="noopener noreferrer"&gt;community-bank fact sheet&lt;/a&gt; says the agency is "encouraging the development of payment stablecoin capabilities within the community banking sector." The rule takes effect on 18 January 2027. As of 29 October the OCC is not yet taking applications, and it &lt;a href="https://www.occ.gov/news-issuances/news-releases/2026/nr-occ-2026-92.html" rel="noopener noreferrer"&gt;says&lt;/a&gt; it will announce separately when it begins.&lt;/p&gt;

&lt;p&gt;A bank deciding whether to issue a public coin can already look at the coins that exist. So I measured where today's fiat-backed dollar stablecoins sat against $1 over the last 30 days, one number per coin. The page describes where they sat and makes no claim about why.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured
&lt;/h2&gt;

&lt;p&gt;DefiLlama publishes the two things this needs: a list of stablecoins with each one's supply and how it is backed, and a daily price for each. I took both on 27 October 2026. From the list I kept every coin pegged to the US dollar, labelled fiat-backed, with more than $1 million outstanding, which is 73 coins. For each one I took the distance of every daily price from $1, in basis points, over the 30 days from 28 September to 27 October, and used the median.&lt;/p&gt;

&lt;p&gt;Two rules decide which coins count. A coin needs a usable price on at least 20 of the 30 days. And a daily price of exactly 1.0 is treated as a fixed value and dropped. That second rule removes three products that report a near-constant 1.0: BlackRock's BUIDL fund (28 of its 30 readings are exactly 1.0), Ondo's U.S. Dollar Token (27) and GAIB AID (23). None of the 45 coins that end up counted had a reading of exactly 1.0, so the rule removed no data from them. The other 25 dropped coins simply lack enough prices. In all 28 fail, 27 of them under $1 billion, and BUIDL is the one above. The 45 that pass have between 28 and 30 days each.&lt;/p&gt;

&lt;p&gt;The fiat-backed label is DefiLlama's, and it is broader than the payment stablecoins the rule covers. It includes some yield-bearing tokens and at least one &lt;a href="https://www.marketsmedia.com/figure-markets-launches-first-sec-registered-yield-bearing-stablecoin/" rel="noopener noreferrer"&gt;SEC-registered security&lt;/a&gt;, Figure's YLDS. I left them in, and the table on the original page lists every counted coin, so anyone can drop the ones they would not count.&lt;/p&gt;

&lt;p&gt;The interactive chart (each coin's median distance from $1 against its supply, with a per-coin readout) and the table of all 45 coins, including each one's daily correlation with USDT, are on the &lt;a href="https://rantum.xyz/case-studies/stablecoin-peg-distance.html" rel="noopener noreferrer"&gt;original page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The large coins sat close.&lt;/strong&gt; Seven coins have more than $1 billion outstanding. Five sat within 2.2 bps of $1: USDG and RLUSD at 1.0, USDGO at 1.3, PYUSD at 2.0 and USDC at 2.2. USD1 sat at 7.0 and USDT at 7.6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The smaller coins spread out.&lt;/strong&gt; The other 38, between $1 million and $1 billion, sat a median 10.3 bps from $1. Three of them were under 5 bps, and four were more than 40 bps away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size went with distance across the whole set, much less below $1 billion.&lt;/strong&gt; Across all 45, the rank correlation between supply and distance is −0.42: larger coins tended to sit closer. Among the 38 coins under $1 billion it is −0.16, too weak to call a relation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One issuer's coins can sit far apart.&lt;/strong&gt; Paxos Trust Company issues both &lt;a href="https://docs.paxos.com/stablecoin/pyusd" rel="noopener noreferrer"&gt;PYUSD&lt;/a&gt;, with $2.9 billion outstanding, which sat at 2.0 bps, and &lt;a href="https://docs.paxos.com/stablecoin/usdp" rel="noopener noreferrer"&gt;USDP&lt;/a&gt;, with $22.5 million, which sat at 21.7. The four coins in the set that Anchorage Digital Bank issues (USDGO, USDPT, USDtb and USAT) sat at 1.3, 3.1, 7.2 and 10.4. Sources for each issuer are on &lt;a href="https://www.anchorage.com/platform/transparency-stablecoin-reserves" rel="noopener noreferrer"&gt;Anchorage's reserves page&lt;/a&gt; and, for USDPT, in &lt;a href="https://www.theblock.co/post/399890/western-union-launches-usdpt-stablecoin-anchorage-solana" rel="noopener noreferrer"&gt;The Block&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The insured bank's coin sat in the middle.&lt;/strong&gt; &lt;a href="https://www.nasdaq.com/press-release/sofiusd-becomes-first-stablecoin-issued-us-national-bank-launch-banking-platform-2026" rel="noopener noreferrer"&gt;SoFiUSD&lt;/a&gt;, issued by SoFi Bank, an FDIC-insured national bank, sat at 6.8 bps, with a worst single day of 19.6.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which coins moved with USDT
&lt;/h2&gt;

&lt;p&gt;USDT moved partway through the window. Its signed median (the median of price minus $1, so negative means below par) was −2.0 bps over the ten days from 28 September to 7 October and −8.3 bps from 8 October on. That is a step of 6.3 bps, and roughly four times the discount. Most of the field moved the same way: 33 of the 45 coins had a lower signed median after 8 October than before, and 35 of the 45 sat farther from $1.&lt;/p&gt;

&lt;p&gt;Coin by coin, the smaller coins split. Correlating each one's daily price with USDT's over the window, 10 of the 38 under $1 billion score above 0.8, among them USAT, USDtb and First Digital's FDUSD. Eighteen score 0.2 or less, among them SoFiUSD, USDPT and USDP. The median is 0.24. Over 30 days a correlation like this mostly asks whether a coin stepped down on 8 October too, so it identifies which coins moved with USDT and says nothing about why. One possibility is that some prices are derived partly from trading against USDT; DefiLlama does not document how each price is built, so I cannot check that.&lt;/p&gt;

&lt;p&gt;Measured against USDT's price on the same day, the smaller coins' median distance is 8.0 bps, against 10.3 from $1. That gap is no evidence of a link on its own. 35 of the 38 sit below $1, and so does USDT, so measuring against USDT shrinks the distance of any coin below par whether or not it tracks USDT. The correlation is the better test, and the original page's table gives it for every coin under $1 billion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over 90 days
&lt;/h2&gt;

&lt;p&gt;The same method over 90 days gives smaller numbers. Taking the coins with enough prices for that window, again seven above $1 billion and 38 below though not quite the same 38, the large coins' median is 1.6 bps and the smaller coins' 7.5, or 5.8 against USDT. Twelve of the smaller coins sit under 5 bps, against three over 30 days. The 30-day window includes the October move in USDT and reads farther from $1. The chart on the original page shows it because it is the current one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rule says that bears on this
&lt;/h2&gt;

&lt;p&gt;Three points from the final rule and the statute behind it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An insured bank issues through a subsidiary.&lt;/strong&gt; Under the GENIUS Act, an insured bank must issue a payment stablecoin through a subsidiary (12 U.S.C. 5901(23)). SoFiUSD is issued by SoFi Bank itself today. The requirement applies from 18 January 2027, and &lt;a href="https://www.coindesk.com/policy/2026/10/26/occ-calls-on-community-banks-to-enter-the-stablecoin-payments-market" rel="noopener noreferrer"&gt;existing issuers that apply by then may request a temporary waiver&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tokenized deposit is a different instrument.&lt;/strong&gt; In the words of the OCC's preamble, the Act's definition of a payment stablecoin "explicitly excludes deposits, regardless of whether the deposit is recorded using distributed ledger technology." A deposit can pay interest. A payment stablecoin's issuer cannot pay holders interest or yield "solely in connection with the holding, use, or retention" of the coin (12 U.S.C. 5903(a)(11)).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secondary trading and supervision.&lt;/strong&gt; The OCC says the rule's redemption requirements "do not apply to secondary market trading," and also that it "intends to review" secondary-market information "for supervisory purposes."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trades.&lt;/strong&gt; These are price-feed values, one a day. They say nothing about how much could be bought or sold at that price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redemption.&lt;/strong&gt; What a coin trades at and what its issuer pays on redemption are separate questions. Nothing here measures the second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Causes.&lt;/strong&gt; Size, issuer, chain, trading venue and how the price feed is built all vary at once across 45 coins, and I have not separated them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More than one window.&lt;/strong&gt; The 30-day picture includes a move in USDT that changes it, as the 90-day figures show.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would track from launch
&lt;/h2&gt;

&lt;p&gt;For a bank that issues a public coin, the inexpensive version of this is a few figures recorded every day from the first one: the coin's worst daily distance from par, the same against USDT, and which trading pairs its price feed is built from, since that decides how much of a USDT move lands in the coin's own number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data
&lt;/h2&gt;

&lt;p&gt;DefiLlama's public &lt;code&gt;stablecoins&lt;/code&gt; and &lt;code&gt;stablecoinprices&lt;/code&gt; endpoints, both taken on 27 October 2026. Every figure on this page is printed by one script run against that snapshot, which also draws the chart. The snapshot and the script are available on request.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/stablecoin-peg-distance.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Andrew Maury is the founder of &lt;a href="https://rantum.xyz" rel="noopener noreferrer"&gt;Rantum&lt;/a&gt;, a senior data science and ML studio that turns messy, fragmented and adversarial data into models, APIs and products that ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>blockchain</category>
      <category>fintech</category>
      <category>defi</category>
    </item>
    <item>
      <title>The Metric That Moved 82 Percent and the One That Didn't</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 28 Oct 2026 20:15:32 +0000</pubDate>
      <link>https://dev.to/andrewmaury/the-metric-that-moved-82-percent-and-the-one-that-didnt-4n7</link>
      <guid>https://dev.to/andrewmaury/the-metric-that-moved-82-percent-and-the-one-that-didnt-4n7</guid>
      <description>&lt;p&gt;ClearTrace publishes a median execution slippage for each venue on its leaderboard. On 21 October the cell for ParaSwap on Ethereum read 6.40 basis points, over 17,913 transactions in the trailing seven days. On 29 July the same cell read 36.83. That is a fall of 82.6 percent, and measured in basis points it is the largest move over the quarter of any venue cell ClearTrace publishes.&lt;/p&gt;

&lt;p&gt;Two things inflate that figure: the dates it is measured between, and a change in the transactions underneath it. A second ClearTrace instrument holds the comparison steady, pricing the same pairs at the same sizes against the same baseline at the same moment, and across the same quarter it shows ParaSwap's execution barely moving at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the number is
&lt;/h2&gt;

&lt;p&gt;The cell is a median. For every fill routed through the venue on Ethereum in a trailing seven-day window, ClearTrace values both legs at Dune's price for that minute, takes the size of the gap between them, and reports the median in basis points. The fills come from Dune's &lt;code&gt;dex_aggregator.trades&lt;/code&gt;, where the venue is labelled &lt;code&gt;velora&lt;/code&gt;, the name ParaSwap now trades under. The count published beside the median is distinct transactions, while the median itself runs over fills; on 21 October that was 17,913 transactions and 17,955 fills.&lt;/p&gt;

&lt;p&gt;Three details of the query matter later.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A transaction is credited to one project only, through a fixed priority order, so a change in how Dune attributes shared transactions moves this count.&lt;/li&gt;
&lt;li&gt;A fill is dropped unless Dune has a price for both tokens in that minute, so a change in price coverage moves it too.&lt;/li&gt;
&lt;li&gt;The query itself changed during the quarter: its file was committed eight times between 10 July and 2 October, some of those to comments, with the measurement changes on 20 August, 9 September and 23 September.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 52 snapshots between 1 July and 21 October are readings from an instrument that changed while it was being read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window opened on the series high
&lt;/h2&gt;

&lt;p&gt;36.83 is the highest reading in the whole series, and it is the reading the 90-day window opens on. 6.40 was the lowest reading up to that date, and it is the reading the window closes on. A change measured between those two dates is the full range of the series to that point, by the accident of where the calendar landed.&lt;/p&gt;

&lt;p&gt;The opening value is also older than its date. It was first recorded on 24 July and appears again, identical, on 27 July and on 29 July, and so does every one of the other 39 venue cells. When a Dune run fails, ClearTrace's sync serves the last cached result instead, which is consistent with three identical snapshots across all 40 cells. The window opens on a measurement taken five days before the window began. The same series carries two more repeated pairs, on 13 and 15 July and on 26 and 29 August; the second is the late-August peak.&lt;/p&gt;

&lt;p&gt;Between the endpoints the series does not trend. It rose to 29.43 in late August, fell to 16.62 by mid-September, climbed to 30.92 by the end of September and dropped 17 points the following week. That is ordinary in this data. Across 35 per-chain cells in the snapshot history, leaving out the ones ClearTrace has withdrawn or does not publish, the median absolute change over the quarter is 2.67 bps and the median range is 10.44. For the typical cell, the range inside the quarter is about three times the net move across it.&lt;/p&gt;

&lt;p&gt;A selection of the 54 readings, with the count behind each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Snapshot&lt;/th&gt;
&lt;th&gt;Median, bps&lt;/th&gt;
&lt;th&gt;Transactions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-01&lt;/td&gt;
&lt;td&gt;13.94&lt;/td&gt;
&lt;td&gt;4,434&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-24&lt;/td&gt;
&lt;td&gt;36.83&lt;/td&gt;
&lt;td&gt;4,171&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;td&gt;36.83&lt;/td&gt;
&lt;td&gt;4,171&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-29&lt;/td&gt;
&lt;td&gt;36.83&lt;/td&gt;
&lt;td&gt;4,171&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-26&lt;/td&gt;
&lt;td&gt;29.43&lt;/td&gt;
&lt;td&gt;7,598&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-09-14&lt;/td&gt;
&lt;td&gt;16.62&lt;/td&gt;
&lt;td&gt;4,877&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-09-29&lt;/td&gt;
&lt;td&gt;30.92&lt;/td&gt;
&lt;td&gt;5,919&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-05&lt;/td&gt;
&lt;td&gt;13.81&lt;/td&gt;
&lt;td&gt;5,921&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-14&lt;/td&gt;
&lt;td&gt;8.89&lt;/td&gt;
&lt;td&gt;6,838&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-19&lt;/td&gt;
&lt;td&gt;6.43&lt;/td&gt;
&lt;td&gt;16,940&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-21&lt;/td&gt;
&lt;td&gt;6.40&lt;/td&gt;
&lt;td&gt;17,913&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-23&lt;/td&gt;
&lt;td&gt;6.04&lt;/td&gt;
&lt;td&gt;15,621&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10-26&lt;/td&gt;
&lt;td&gt;7.72&lt;/td&gt;
&lt;td&gt;7,394&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 54 readings, and a two-panel chart of the series with its sample count, are on the &lt;a href="https://rantum.xyz/case-studies/metric-that-moved-82-percent.html" rel="noopener noreferrer"&gt;case study page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the population more than doubled
&lt;/h2&gt;

&lt;p&gt;The last step down, from 8.89 on 14 October to 6.43 on 19 October, coincides with the count behind the reading going from 6,838 transactions to 16,940. Two days later it was 17,913. Both readings cover seven days, so the week ending 19 October held about two and a half times as many ParaSwap transactions on Ethereum as the week ending 14 October.&lt;/p&gt;

&lt;p&gt;The jump belongs to that venue on that chain. The same venue on Base went from 4,961 transactions to 5,003 on the same sync. Of the 23 other venue cells on individual chains present on both snapshots, the largest rise was 1.37 times, and 16 of them fell. In Dune's public model repository, ParaSwap's own models were last changed in June, and I found no change to the shared aggregator models after 23 September.&lt;/p&gt;

&lt;p&gt;The two ParaSwap entrypoints that the snapshot history tracks separately did not grow either: together they show 1,487 transactions on 14 October and 1,396 on 19 October, although those rows refresh less often than the venue cell. The additional ten thousand or so transactions came through other contracts. They could be new flow, or flow that the priority order or the price coverage had been assigning elsewhere or dropping. Telling those apart takes a query I have not run for this piece. Either way, a median over 6,838 transactions and a median over 16,940 are medians over different populations.&lt;/p&gt;

&lt;p&gt;The closing reading has a second problem. On 21 October the signed median, which keeps the direction of each gap, was 0.67 bps, and 53.9 percent of scored fills were costs to the taker. The distribution was close to centred on zero. ClearTrace's own methodology note explains the consequence: when the gaps centre near zero, the absolute median reports how noisy the price feed is for those token pairs more than it reports execution. The window closes on a reading close to that regime.&lt;/p&gt;

&lt;p&gt;The readings after the window show the surge passing. On 23 October the cell read 6.04 on 15,621 transactions, lower again. On 26 October it read 7.72 on 7,394, with a signed median of 3.14 and 66.4 percent of fills as costs, which is also what ClearTrace's public API showed on 27 October. The count is back near where it sat before 19 October, and the reading has moved up with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A steady count is not a steady population
&lt;/h2&gt;

&lt;p&gt;My first reading of this was wrong. One of the two entrypoints, AugustusV6_2, fell from 62.31 bps to 4.31 over the same quarter while its count barely moved, from 871 transactions to 862. I took that as the part of the fall that was real: same population, lower cost.&lt;/p&gt;

&lt;p&gt;It does not hold. That row was refreshed far less often, with 11 distinct readings across 38 snapshots, and its window also opens on its own high and closes on its own low. Its readings span 4.31 to 62.31 on counts that never left the range from 627 to 1,239. The other entrypoint, AugustusFeeVault, went from 67.72 to 63.69 over the same window.&lt;/p&gt;

&lt;p&gt;The venue cell shows the same thing more plainly. Between 29 September and 5 October it fell from 30.92 to 13.81 while its count moved by two transactions, from 5,919 to 5,921. A count says how many transactions went into a median and nothing about which ones. The mix can change completely under an unchanged count, and a median follows the mix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survives depends on the baseline
&lt;/h2&gt;

&lt;p&gt;Comparing periods instead of endpoints helps, but it does not settle the question. In the first half of October, before the count jumped, the five readings ran from 13.81 down to 8.89, with a median of 11.36. Against August and September, whose readings had medians of 19.44 and 20.78, that is a fall of about 45 percent. Against the first three weeks of July, median 14.5, it is a fall of about 22 percent.&lt;/p&gt;

&lt;p&gt;Neither baseline is the obvious one, and the evidence behind each is thinner than the number of readings suggests. The readings are seven-day windows taken every two or three days, so the five October readings cover about two weeks of transactions. The counts are no help either: they held between 5,900 and 6,900 in early October but ran from 4,121 to 7,881 in August and September, and the previous section shows what a steady count is worth. On this cell alone, ParaSwap's October readings were lower than its late-summer ones, by an amount that depends on which period you call normal, for a reason the median cannot identify.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement that holds the comparison still
&lt;/h2&gt;

&lt;p&gt;ClearTrace runs a second instrument that avoids most of this. Its quote sampler asks each aggregator for a route on a set of token pairs at four sizes, from $1,000 to $1 million, and simulates the swap against chain state from a neutral address. It then compares the output with what Uniswap's default route delivers for the same pair and size in the same time window. Each observation carries its own baseline, and the pairs are chosen by ClearTrace rather than by whoever happened to trade that week.&lt;/p&gt;

&lt;p&gt;On the seven pairs the sampler priced in every period of the quarter, ParaSwap's median output against the Uniswap default stayed between -0.76 and -1.64 bps at every size, in every period from early July to 26 October, over 8,425 matched windows. The first half of October sits inside the range of the months before it, and the days of the surge differ from the fortnight before them by no more than 0.15 bps at any size. Most of that steady gap is the flat 1 basis point fee ClearTrace's sampler has measured ParaSwap taking on major pairs.&lt;/p&gt;

&lt;p&gt;The sampler measures something narrower than the on-chain cell. Its figure is ParaSwap's routing relative to one baseline route, on chosen pairs, with simulated swaps, and the pairs it tracks have shifted at the edges over the quarter, which is why only the seven present throughout are used here. What it shows is that whatever moved the on-chain cell did not show up in how ParaSwap routed these seven pairs at these four sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the 82 percent got chosen
&lt;/h2&gt;

&lt;p&gt;I found this number by asking which metric had moved most over the last 90 days, ranking every per-chain cell by the change between its first and last reading in the window, and taking the largest among the venue cells ClearTrace publishes.&lt;/p&gt;

&lt;p&gt;That ranking leans toward exactly this case. Of the 41 per-chain cells with at least ten snapshots in the window, three open on their own series high and close on their own series low, or the reverse. The ranking put those three first, fourth and seventh, and ParaSwap's cell is the fourth. Asking for the biggest mover between two dates is partly asking which series the calendar caught at its extremes. The unit matters as well: in percentage terms the largest published move went the other way, OKX on Base rising from 7.03 to 16.48 bps, on a count that fell from 5,032 transactions to 1,870.&lt;/p&gt;

&lt;p&gt;Three of the six largest moves sit in cells ClearTrace has withdrawn or does not publish, and two of those were withdrawn after we found the number did not mean what the column said.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would make the comparison mean something
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compare periods, with their spread, and say which baseline.&lt;/strong&gt; A month of readings has a median and a range. A change smaller than the range inside each month is weak evidence of anything, and a change whose size depends on the baseline needs every plausible baseline shown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Print the count beside every value.&lt;/strong&gt; A reading whose count jumps far outside its usual range starts a new series, and it should be read as one rather than as the next point on the old line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mark carried-forward readings and query versions.&lt;/strong&gt; A snapshot that repeats the last value because a run failed is not a new observation, and a series that spans a query revision is two series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pair each observation with its own baseline where the question allows it.&lt;/strong&gt; The discipline for this is implementation shortfall: the gap between the price at the moment of decision and the price achieved. ClearTrace's sampler is built on that pairing: each row holds a solicited quote and the simulated fill for it, and the comparison above sets each fill beside the baseline route's fill in the same window. Comparing two routes removes the price feed from the comparison, and fixing the pairs removes the change in mix between periods. Neither is available on a median of whatever traded that week.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the direction of the move matters
&lt;/h2&gt;

&lt;p&gt;A cost reading that rises 82 percent gives the venue a reason to ask why. One that falls 82 percent gives nobody outside the measurement a reason to look, so the check has to come from whoever publishes it.&lt;/p&gt;

&lt;p&gt;ClearTrace has a precedent in the same direction. Its Scorecard Edition 3 records that, in the window then measured, the cell for the 1inch Limit Order Protocol published 13.27 bps where the population left after removing one resolver's maker-side edge measured 29.73. That venue read about twice as good as it was.&lt;/p&gt;

&lt;p&gt;ParaSwap's cell was a published number, it held the largest move of the quarter, and on the instrument that holds the comparison still, the quarter barely registers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/metric-that-moved-82-percent.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Andrew Maury is the founder of &lt;a href="https://rantum.xyz" rel="noopener noreferrer"&gt;Rantum&lt;/a&gt;, a senior data science and ML studio that turns messy, fragmented and adversarial data into models, APIs and products that ship. The measurements in this piece come from &lt;a href="https://cleartracedata.com" rel="noopener noreferrer"&gt;ClearTrace&lt;/a&gt;, Rantum's neutral on-chain execution-quality project.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>defi</category>
      <category>ethereum</category>
      <category>datascience</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Outside the Polygon, or No Polygon at All</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 28 Oct 2026 20:15:17 +0000</pubDate>
      <link>https://dev.to/andrewmaury/outside-the-polygon-or-no-polygon-at-all-2ii5</link>
      <guid>https://dev.to/andrewmaury/outside-the-polygon-or-no-polygon-at-all-2ii5</guid>
      <description>&lt;p&gt;&lt;a href="https://addressintel.co" rel="noopener noreferrer"&gt;AddressIntel&lt;/a&gt;, our predictive real-estate intelligence project, fuses San Mateo and Santa Clara County GIS layers onto parcels: zoning, fire hazard severity, flood, fault, liquefaction, landslide, farmland, Williamson Act contracts, Coastal Zone. The mechanism is a point-in-polygon lookup against each county's ArcGIS REST endpoint, and then a derived answer about what can legally be built. The scored table holds 489,915 parcels across 16 cities.&lt;/p&gt;

&lt;p&gt;Writing the lookup took an afternoon. Every expensive mistake since has come from the same place: the pipeline had a definite answer to give and gave it, where something weaker was called for. Four failures are below, alongside the one case the pipeline handled correctly. Two are the pure form, a confident answer standing in for one the pipeline did not have: a layer emptied upstream that read as a clean negative, and a fallback rule chosen to be conservative that was not conservative where it mattered. The third is a confident answer that was simply wrong, a polygon hit reported as a legal conclusion it does not support. The fourth turned up while this was being written, a resume mark recording a question the pipeline had never asked. The case it handled correctly is a city declining to assess a parcel and the refusal surviving the trip, which earns its place because a careless query nearly flattened it. Two further mistakes in what follows are mine rather than the pipeline's, and both are marked where they fall.&lt;/p&gt;

&lt;p&gt;The fixes converge even where the faults differ: make the weaker answer representable, and keep it distinct from no.&lt;/p&gt;

&lt;p&gt;A sibling piece, &lt;a href="https://rantum.xyz/case-studies/adversarial-permit-portals.html" rel="noopener noreferrer"&gt;Scraping Adversarial Municipal Portals&lt;/a&gt;, covers the same shape in a different layer: a source that answers cheerfully and wrong, a rejected login returning an empty list. That one is about scraping HTML portals and detecting silent truncation. This one is about the spatial join and what a polygon lookup is entitled to conclude.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the lookup is
&lt;/h2&gt;

&lt;p&gt;There are two shapes of lookup, and the difference matters later. The listing path sends one point per property, &lt;code&gt;geometryType=esriGeometryPoint&lt;/code&gt; with &lt;code&gt;spatialRel=esriSpatialRelIntersects&lt;/code&gt;, and reads back either the intersecting feature's attributes or a count. The scoring path cannot afford that across half a million parcels, so it pulls each layer once for the city's bounding box and runs the point-in-polygon against parcel centroids locally. Every per-parcel figure here comes from that second shape. San Mateo County's layers sit on a self-hosted &lt;code&gt;gis.smcgov.org&lt;/code&gt; MapServer, and two of its towns publish their own zoning from their own ArcGIS organisations because the county layer answers &lt;code&gt;NO DATA ASSIGNED&lt;/code&gt; for them. Santa Clara County's sit on ArcGIS Online. Counting only the scoring path there are fourteen distinct hosts and twenty publishers, because a hostname is not a publisher. Nine of the fourteen are single-tenant hosts the publisher runs itself: seven cities with their own GIS servers, San Mateo County's self-hosted MapServer, and FEMA. The other five are shared &lt;code&gt;services*.arcgis.com&lt;/code&gt; tenancies carrying eleven publishers between them: Santa Clara County, two state agencies for the geologic and fire layers, seven more cities publishing from their own ArcGIS organisations, and a regional zoning aggregator. Sharing a host does not mean sharing it widely: two of those five carry a single publisher apiece. Three more cities' zoning arrives as a local file rather than a REST layer, two of them because they publish nothing on a host of their own. The scoring module that reaches them is 2,377 lines, and most of that is neither geometry nor SQL.&lt;/p&gt;

&lt;p&gt;The reliability work is the visible part, and it sits on the per-point listing path, not on the bulk scoring one. The Santa Clara host answers bursts with read timeouts and connection resets, so calls to it pass through a two-permit semaphore, two attempts with a jittered backoff sleep between them, and a staggered release so threads queued on the semaphore do not all fire the instant a slot frees. Above that sits a circuit breaker that opens after three consecutive call failures and skips the rest of the run. The scoring path makes larger and rarer bulk calls and carries none of this: three attempts by default, an unjittered backoff, no breaker. The breaker's own counters had to be put under a lock: without one, concurrent threads each crossed the threshold independently and logged the same trip line several times a few milliseconds apart, which is how the race was found.&lt;/p&gt;

&lt;p&gt;None of that is the interesting failure. A timed-out call is loud. The failures below were all quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  A layer can be emptied without erroring
&lt;/h2&gt;

&lt;p&gt;Santa Clara County republished two of its planning layers with zero rows countywide on 25 September 2026. The pipeline asked one of them, the Agricultural Preserves layer, whether each point fell inside a preserve, using &lt;code&gt;returnCountOnly&lt;/code&gt;. An empty hosted layer answers that query perfectly well. It returns a count of zero. So the lookup did not error, did not time out, and did not trip the breaker. It simply began answering &lt;em&gt;no&lt;/em&gt; for every parcel in the county, in the same shape as a true negative, and it would have gone on doing so indefinitely.&lt;/p&gt;

&lt;p&gt;This is the failure mode worth naming, because a spatial join over someone else's layer is exactly where it hides. A join has three possible answers and the API gives you two. There is inside the polygon, outside the polygon, and no polygon to be inside of, and the third arrives wearing the second's clothes.&lt;/p&gt;

&lt;p&gt;The discriminator we ended up with is a quirk of the hosting. A zero-row layer answers a bare &lt;code&gt;returnCountOnly&lt;/code&gt; request fine, but rejects with HTTP 400 and &lt;em&gt;Cannot perform query. Invalid query parameters.&lt;/em&gt; any request that both filters by geometry and materialises features. So when a feature fetch fails, the collector asks the layer for its unfiltered total row count. Zero means the layer really is empty upstream, and zero coverage is the correct report. Anything else, including a count it cannot obtain, leaves the layer marked unknown so the run still reads as failed:&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;if&lt;/span&gt; &lt;span class="nf"&gt;_layer_row_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="nf"&gt;print&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; WARN &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: layer is EMPTY upstream (0 rows countywide) &lt;/span&gt;&lt;span class="sh"&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;- reporting zero coverage, not a fetch failure (&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="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="nf"&gt;print&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; WARN &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: fetch failed (&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="s"&gt;) - layer treated as unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty list and &lt;code&gt;None&lt;/code&gt; are different values, deliberately, and every caller has to handle both.&lt;/p&gt;

&lt;p&gt;That discriminator sits on the bulk collection path, and it is worth being exact about which path it does not sit on. The per-point lookups the listing-level enrichment uses still ask &lt;code&gt;returnCountOnly&lt;/code&gt; one parcel at a time and still end in &lt;code&gt;return c &amp;gt; 0&lt;/code&gt;, with no row-count probe. The farmland, Williamson Act and county SB 9 layers all go through that path, and every one of them would still read an emptied layer as a clean negative. The ag-preserve signal was not fixed by teaching that path the difference. It was fixed by retiring the live lookup and reading a snapshot taken before the layer was emptied, which the file itself qualifies: it was recovered from pre-emptying caches, two cities' caches had already been overwritten, and it covers the scored footprint and is not a countywide extract. The general lesson survives the specific fix, and so does the exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  A polygon hit is not a legal conclusion
&lt;/h2&gt;

&lt;p&gt;The second failure produced wrong numbers instead of silence. SB 9, California's urban lot split law, disqualifies a site that is prime farmland or farmland of statewide importance. Santa Clara County publishes several agriculture-related layers, and for a while the pipeline folded three of them into one &lt;code&gt;primeFarmland&lt;/code&gt; flag: the state farmland mapping, Williamson Act contracts, and agricultural preserves. A hit on any of them disqualified the parcel as prime farmland.&lt;/p&gt;

&lt;p&gt;They are three different things, and only one of them is the statutory category. Measured against the scored table today, after the three were separated on 28 September 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;17 parcels&lt;/strong&gt; are prime farmland or farmland of statewide importance, the category the statute actually names. 15 in San Jose, 2 in Los Gatos.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;216 parcels&lt;/strong&gt; are under a Williamson Act contract. That is a tax-assessment contract restricting land to agricultural use. It is not prime farmland, and strictly it is not one of the statutory site criteria either.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;4,107 parcels&lt;/strong&gt; sit in an agricultural preserve, 2,626 of them in Los Gatos and 1,228 in San Jose. An ag preserve is neither prime farmland nor a voter-approved agricultural protection measure, so it is not an SB 9 bar at all.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three overlap only partly. All 216 Williamson parcels are also inside an ag preserve, but 16 of the 17 prime-farmland parcels are in neither other set, and the union of the three labels is 4,123 parcels today. The repository's record of the fix puts the old flag's reach at 4,104 Santa Clara parcels blocked as prime farmland, 104 of them ordinary San Jose tract houses the state farmland map classifies as urban and built-up land. That historical set is not the same set as today's union, because the old flag read the farmland layer unfiltered and today's reads only the two statutory categories. Of the 4,123, exactly 233 now carry a farmland-family disqualifier and only 17 of those carry the one the column used to give. The rest of the union is not thereby buildable: 3,157 of the 4,123 are still SB 9-blocked, almost all on fire or fault rather than on anything agricultural: 2,880 for very high fire and 707 for a fault zone, against 132 for lot size and 37 for flood.&lt;/p&gt;

&lt;p&gt;The Williamson parcels are still blocked, under their own name. The contract forbids the housing project for the length of its rolling term, so reporting such a parcel as eligible would be wrong in the more dangerous direction: it would tell someone they can split land they legally cannot. Blocked, with its reason named accurately, is the correct report, and the reason string is what carries the accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layers of answer, because one is not enough
&lt;/h2&gt;

&lt;p&gt;What makes this expressible is that the derivation emits two lists where a single verdict would collapse them. &lt;code&gt;sb9BlockReasons&lt;/code&gt; holds what bars the split: the statutory site criteria, plus the Williamson Act contract, which is not one of them and bars it anyway. &lt;code&gt;developmentBlockers&lt;/code&gt; holds everything that raises cost, risk or process friction, disqualifying or not. Several signals appear in one list and not the other, and the choice is load-bearing each time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fire.&lt;/strong&gt; Only a Very High Fire Hazard Severity Zone is statutorily SB 9-disqualifying. Plain High raises construction and insurance cost and bars nothing. 5,214 parcels are Very High and blocked; 10,780 are High and carry a cost blocker only.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Coastal Zone.&lt;/strong&gt; SB 9 and state ADU law do not override the Coastal Act, so a Coastal Development Permit is still required. The parcel is encumbered without being ineligible, so the signal is a development blocker and never a block reason.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Liquefaction and landslide.&lt;/strong&gt; 201,670 and 14,085 parcels respectively. Neither bars anything; both change what building there costs. Putting them in the disqualifier list would have made two fifths of the coverage area read as undevelopable.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Writing the overlay as &lt;em&gt;a Coastal Zone hit flips eligibility&lt;/em&gt; would be the same defect class as the farmland flag, just in the other direction. The accurate statement is narrower and less quotable: the overlay changes what the parcel costs and how long it takes, and sometimes, for specific named layers, it changes what is permitted.&lt;/p&gt;

&lt;p&gt;One figure here cuts against the way this layer gets described, including in our own earlier notes. Of the 7,579 listing-level enrichment rows, &lt;strong&gt;45&lt;/strong&gt; carry a Coastal Zone hit, and 42 of those are in Half Moon Bay. The other three sit in El Granada and Woodside, and across the remaining thirty cities the layer is correct and silent, with a single Burlingame row still unanswered. It is a real signal in one coastal town, and a case study that led with it would be presenting that town's overlay as a pipeline-wide feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback that was not conservative
&lt;/h2&gt;

&lt;p&gt;The second half of the pipeline is rules as code. A screening signal we care about is how built out a lot is, living area over allowable floor area, which needs each jurisdiction's floor-area rule. Those are 19 jurisdictions in a table of formulas, every entry carrying &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;asOf&lt;/code&gt; and &lt;code&gt;confidence&lt;/code&gt;: 9 high, 7 medium, 3 estimated, researched between 30 June and 3 September 2026, with 12 unincorporated county districts added on 27 October 2026. None of the three marked estimated is the raw default, and the labels repay reading closely. One is estimated from its city's 40 percent coverage standard times a stories factor, which lands near 0.52 effective, &lt;em&gt;above&lt;/em&gt; the curated cluster instead of below it. One still applies the default, now capped by a table in that city's own code, with the slope half of its rule still missing. The third is not a floor-area ratio at all. The estimated tier marks what has not been finished, and what each entry does under that label still has to be read.&lt;/p&gt;

&lt;p&gt;The default of 0.40 was chosen to be conservative. It sits at the low end of the curated cluster, so for a city near that cluster it understates allowable area, which biases against false &lt;em&gt;this lot has headroom&lt;/em&gt; signals. That reasoning is sound and it is not a safety property, and the difference is the whole of what follows.&lt;/p&gt;

&lt;p&gt;Four entries were curated on 3 September 2026 after an audit, all of them inheriting the default until that day, and the audit cut three ways. In one city the default held: across all 9,217 of its scored parcels it overstates only 11 percent of them and never by more than 1.20 times, which is the result the reasoning predicted. In two towns it did not. One has a ratio of 0.25 tapering to 0.15 above an acre, against which the default overstates allowable floor area by 1.60 times on a half-acre lot and 2.33 times at 200,000 square feet. The other is worse by an order of magnitude, because its caps are absolute rather than proportional: the repo records the default overstating it by 2.7 times at the minimum R-1 lot, 5.8 times at an acre and 29 times on a ten-acre parcel. In both towns the error is largest exactly where lots are biggest and a teardown call carries the most money. The fourth entry is the capped-default one described above, which is why it is still labelled estimated. An estate town is the shape a flat default gets most wrong, and it is also the shape where the signal gets used hardest.&lt;/p&gt;

&lt;p&gt;The lesson is not that the default was badly chosen. It is that a default is an answer. Uncurated should read as unknown, not as safe, and the &lt;code&gt;confidence&lt;/code&gt; field exists so that a caller can tell the difference instead of inheriting our optimism.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the city itself declines to answer
&lt;/h2&gt;

&lt;p&gt;The best-behaved source in this pipeline is the one that publishes its own uncertainty. Palo Alto's assessor parcel layer carries a floor-area field, and for some parcels it does not state a figure. It says instead that it cannot assess one, naming a creek, a flag lot or a right-of-way. That is a city telling you a constraint binds that it will not quantify.&lt;/p&gt;

&lt;p&gt;1,317 of the 20,849 scored Palo Alto parcels carry the resulting &lt;code&gt;far_not_assessable&lt;/code&gt; blocker. The arithmetic for those parcels is still run and still correct for the lot, and the flag rides alongside it to say that something we do not model is known to apply. That is a different statement from a missing value, and it is more useful than either a number or a blank.&lt;/p&gt;

&lt;p&gt;Extracting it took two tries, and the first try is instructive. Matching the field loosely on the phrase &lt;em&gt;can't assess&lt;/em&gt; also catches a different sentence the same layer uses, which means the district is not residential rather than that a physical constraint binds. The loose predicate pulled 7,593 parcels where the specific one pulls about 1,359, so more than four fifths of the matches were a different statement entirely. Two sentences in the same column, both reading as absence of a number, meaning unrelated things.&lt;/p&gt;

&lt;p&gt;We ingest that layer's judgement and not its numbers, on purpose. Its floor-area values agree with our own formula for Palo Alto on 99.23 percent of 14,044 parcels, checked 28 August 2026, with the residual explained by rounding in its acreage strings. That agreement is what makes the layer trustworthy on the question, and it is also why its figures are not worth importing: they are our own arithmetic coming back to us. The non-redundant content is the refusal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknown is also the resume cursor
&lt;/h2&gt;

&lt;p&gt;Keeping unknown distinct from no stops being an honesty argument once the backfill runs. The overlay backfill walks already-enriched rows with a four-worker pool, and rows whose lookups time out are left undone and picked up next run. What makes that work is that a failed lookup writes &lt;code&gt;None&lt;/code&gt; rather than &lt;code&gt;False&lt;/code&gt;, so the row stays unmarked and the resume predicate finds it again:&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="c1"&gt;# Only mark the SCC pass done when BOTH resolved; a None in either
# means that lookup errored -&amp;gt; retry next run. …
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SCC_CITIES&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;prime&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;wact&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sccPrimeChecked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a transient ArcGIS timeout had been recorded as a negative, the pipeline would be self-consistent, would converge, and would be wrong, with nothing left in the data to indicate which rows had never actually been asked. That is the theory. The column I reached for to demonstrate it does not show it. 2,217 enrichment rows carry a null corner-lot value, and I took all of them for a retry queue. Measured by development class, 1,121 are condominiums, 692 townhomes and 398 multifamily, and six are single-family homes. The first three groups are marked not-applicable and skipped by a check that runs before the resume predicate is ever evaluated, so their nulls are not outstanding work and never will be.&lt;/p&gt;

&lt;p&gt;A further meaning hides under that one. Six more rows carry no corner-lot key at all, because a first-ever failed lookup writes nothing where a later one writes a null, and the predicate reads the field with a lookup that cannot tell an absent key from a null value. All six are single-family. Twelve rows are the queue, and finding the second six meant reading the predicate instead of the column.&lt;/p&gt;

&lt;p&gt;A third set makes the point without rescuing it. 1,190 rows carry a null farmland value against 20 positive and 6,369 negative, and they are San Mateo County listings. The backfill records farmland as unknown for that county by design, because no state farmland source is wired for it, so no run will ever resolve those either.&lt;/p&gt;

&lt;p&gt;So one column, three meanings. Not yet asked, which is work. Does not apply, which never was. No source wired, which is a gap in the pipeline rather than in the run. All three are stored as null, and the schema says which is which only if you go and look at a different field. A null records that a value is not known. It says nothing about whether anyone is still going to look, and the lesson of this section is that I read my own column wrong before I measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this pipeline still does not know
&lt;/h2&gt;

&lt;p&gt;Three limits belong on the page, not in a footnote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zoning is mostly absent at the listing level.&lt;/strong&gt; 5,487 of the 7,579 enrichment rows have a null GIS zoning value, and a further 1,124 carry the county layer's own &lt;code&gt;NO DATA ASSIGNED&lt;/code&gt; sentinel, which the backfill treats as absent. By the pipeline's own test, 6,611 of 7,579 have no usable zoning and 968 do. Without zoning the derivation cannot reach a hard &lt;em&gt;eligible&lt;/em&gt; and returns &lt;em&gt;likely&lt;/em&gt; instead. That is one of two routes into the middle state and the smaller one: of the 109,132 scored parcels sitting there, 9,089 have no zone at all and 100,043 carry a zone that is simply not a single-family district, mostly multi-family and planned-development codes. Absence of a block reason means no disqualifier was found rather than that eligibility was established.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A resume cursor can mark a new question answered.&lt;/strong&gt; Writing this piece turned up one more instance of its own subject, in the mechanism the previous section praises. The Williamson Act check became its own signal on 28 September 2026 and the backfill was updated to require it, but rows marked complete under the previous rule were never re-asked, because the predicate that would re-ask them is the one reading the stale mark. Measured on 28 October 2026, before the fix: 4,552 rows marked as having completed the farmland pass, and 3,768 of them carrying no &lt;code&gt;williamsonAct&lt;/code&gt; key at all, with another 21 carrying it null. The per-parcel counts elsewhere on this page are unaffected, because the parcel-scoring path does not use the mark.&lt;/p&gt;

&lt;p&gt;The fix merged on 28 October 2026, and it took two merges, which is the part worth having. The obvious version tests whether the row carries the field at all, and that version is wrong in a quieter way: a failed lookup writes a null, so the presence test would be satisfied by the error and lock it in permanently. So the completeness test mirrors the write-back condition instead, both answers present and not null.&lt;/p&gt;

&lt;p&gt;That was not enough, because the write-back condition was itself unsafe. Making those 3,789 rows re-query opened a path that had been unreachable while they were skipped: the write stored the lookup's result without checking it, so a failed re-query replaced a resolved farmland answer with a null. Not one row at a time either, since once the circuit breaker opens every remaining call in the run returns null at once. A second change the same morning guards the two farmland writes, so a null replaces an answer only when there is no answer to lose. A third guards the coastal write just above them, which had been left alone and is the field the resume predicate keys on, so an unanswered lookup there both erased a resolved hit and re-opened the row. Three unguarded writes sat on one screen and the first repair fixed two of them, which is about the hit rate you should expect from a fix aimed at a symptom. The first fix's own test checked that a failed lookup is not marked complete and never checked what it writes, which is exactly the distance between a mark and the data it stands for. A mark records that a pass ran rather than that it answered what the pass asks today, and a field added afterwards has to be able to invalidate it. Merging the predicate did not answer anything by itself; the next scheduled run had to ask the question. It has since started doing so. Against the canonical copy the rows carrying a resolved Williamson value have gone from 763 to 962, and those still owing one from 3,768 to 3,575, which is the first evidence from production that the repair does what the test said it would.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two copies of this database exist, and the figures above come from different ones.&lt;/strong&gt; AddressIntel keeps a canonical copy that the application serves and a working copy on a development machine, both called the same thing. On 15 September 2026 four separate published figures were wrong from exactly that confusion, and the provenance test written in response identifies the canonical copy by requiring a marker table it carries and none of the marker tables only a development machine builds.&lt;/p&gt;

&lt;p&gt;The two halves of this piece sit on opposite sides of that line. The parcel scoring table is named in the test as a development marker, and the canonical copy genuinely does not carry it, so every per-parcel figure here, the 489,915 and everything derived from it, exists only on the working copy and has no canonical counterpart to check against. The listing enrichment table is named in neither set, and I first read that silence as the same answer. It is not. The canonical copy carries that table too, with 7,579 rows against the working copy's 4,004, and every listing in the smaller set is present in the larger, though the two copies' stored payloads have since diverged, mostly on derived fields. The listing-level figures above are therefore measured on the canonical copy, and where an earlier draft of this piece quoted the working copy's version of one, it was quoting a 53 percent sample and calling it the population.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A spatial join has three answers.&lt;/strong&gt; Inside, outside, and no layer to be inside of. If your schema cannot hold the third, you will serve it as the second.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check that a negative source can still produce a positive.&lt;/strong&gt; An upstream layer republished empty answers a count query cheerfully. Something in the pipeline has to be able to notice that a signal which used to fire has stopped firing entirely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep the overlay hit and the legal conclusion in different fields.&lt;/strong&gt; A polygon locates a parcel. A statute decides what that implies, and usually it implies less than the layer's name suggests.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Carry source, as-of date and confidence on every rule.&lt;/strong&gt; Rules as code without provenance is a number with no way to tell curated from guessed, and the guess is where the large errors live.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat a default as an answer.&lt;/strong&gt; Conservative in the typical case is not conservative in the tail, and the tail is where the decision is expensive.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Preserve a source's refusal to answer.&lt;/strong&gt; It is information, it is cheap to carry, and it is the one thing a downstream model cannot reconstruct.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The overlay does change the answer. What took longest to get right was the smaller claim underneath it: knowing when the overlay has not answered at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/point-in-polygon-parcel-overlays.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Andrew Maury is the founder of &lt;a href="https://rantum.xyz" rel="noopener noreferrer"&gt;Rantum&lt;/a&gt;, a senior data science and ML studio that turns messy, fragmented and adversarial data into models, APIs and products that ship. The pipeline in this piece is part of &lt;a href="https://addressintel.co" rel="noopener noreferrer"&gt;AddressIntel&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>gis</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>Null Is Not Zero: What an MCP Tool Description Has to Carry</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 14 Oct 2026 17:06:21 +0000</pubDate>
      <link>https://dev.to/andrewmaury/null-is-not-zero-what-an-mcp-tool-description-has-to-carry-26dc</link>
      <guid>https://dev.to/andrewmaury/null-is-not-zero-what-an-mcp-tool-description-has-to-carry-26dc</guid>
      <description>&lt;p&gt;A REST API's documentation is read by a developer once. An MCP tool description is the one piece of text a model reads on every single call, which makes it where you stop an agent misreporting your numbers. It is also prose, so it goes stale like prose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://addressintel.co" rel="noopener noreferrer"&gt;AddressIntel&lt;/a&gt; reads building permits and parcel records across the SF Peninsula and scores what could be built on a given lot. It already had a REST API. Putting a Model Context Protocol server in front of that API is, as engineering, close to trivial: the whole thing is 312 lines of TypeScript across two files, six tools, and a client that adds a header, builds a query string and unwraps the API's error envelope.&lt;/p&gt;

&lt;p&gt;What is not trivial is that the consumer changed. A developer reading REST docs forms a mental model once, writes code against it, and that code then behaves the same way forever. A model reads the tool description on every call and decides, each time, what the returned numbers mean and how to report them to whoever asked. The description is not documentation. It is the contract, and it is enforced by nothing.&lt;/p&gt;

&lt;p&gt;That matters here more than it would for most data, because this data is full of ways to be confidently wrong. A teardown ROI of &lt;code&gt;null&lt;/code&gt; is not a zero. A score that ranks candidates is not a return. A negative number is usually correct. Get any of those wrong in a summary to a buyer and you have invented a deal that does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What got built
&lt;/h2&gt;

&lt;p&gt;Six tools over stdio, published to npm as &lt;code&gt;addressintel-mcp&lt;/code&gt; and listed in the official MCP registry under the DNS-authenticated namespace &lt;code&gt;co.addressintel/permits&lt;/code&gt;. &lt;code&gt;search_permits&lt;/code&gt; for issued permits by city, keyword and valuation; &lt;code&gt;list_sb9_inventory&lt;/code&gt; for lot-split and duplex eligibility with the per-parcel block reasons; &lt;code&gt;list_adu_leads&lt;/code&gt; and &lt;code&gt;list_redevelopment_leads&lt;/code&gt; for feasibility-ranked parcels; &lt;code&gt;list_market_signals&lt;/code&gt; for the opportunity feed; &lt;code&gt;get_property_intelligence&lt;/code&gt; for one property in depth.&lt;/p&gt;

&lt;p&gt;The declaration keeps each tool's validation schema and its advertised JSON Schema side by side in one object, because a tool that spells its shape out twice in two separate blocks will eventually spell it differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ToolDef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ZodTypeAny&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* advertised to the client */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// validates the incoming call&lt;/span&gt;
  &lt;span class="nl"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions in the transport layer are worth more than the rest of the code put together, and both exist because the caller is an installed agent rather than a developer at a terminal.&lt;/p&gt;

&lt;p&gt;The first is what happens with no API key. The first draft of this client sent no auth header at all, so every endpoint returned 401 and the server was useless until you went and got a key. Now an unset key falls back to the literal string &lt;code&gt;demo&lt;/code&gt;, which the API recognises as a public tier at 10 requests per minute and 5 rows per call. The server does something real the moment it is installed. A human will go find a key when they hit a wall; an agent will simply report that the tool is broken.&lt;/p&gt;

&lt;p&gt;The second is error shape. The API returns a readable &lt;code&gt;error&lt;/code&gt; in its failure envelope, and that string gets surfaced to the model rather than a bare status line, because it is what tells the agent whether to narrow the query, back off, or ask the user for a key. A status code tells a model nothing it can act on. For the same reason, a lookup that misses returns &lt;code&gt;{ found: false, message: "No property found with ID: ..." }&lt;/code&gt; rather than null: outside our coverage area a miss is a normal answer, not a failure, and it should arrive in words the model can pass along.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;API_BASE&lt;/code&gt; defaults to the published domain rather than the bare App Hosting URL. Both serve the API, but the latter moves with the backend, and an installed client is the worst place to hard-code something that moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The descriptions are where the guardrails live
&lt;/h2&gt;

&lt;p&gt;Here is the part I did not expect to spend the most time on. The description field for &lt;code&gt;list_market_signals&lt;/code&gt; ends with three sentences that are not describing anything:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Quote those assumptions alongside the number. &lt;code&gt;null&lt;/code&gt; means not modellable, with &lt;code&gt;unmodellableReason&lt;/code&gt; naming the missing input: treat it as unknown, never as zero. Negatives are normal here; most of this market does not pencil.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is an instruction to a model, sitting in a schema field. It is there because the alternative is an agent that says "four of these properties show a 0% return" when what the data said was "I could not model four of these properties." &lt;code&gt;get_property_intelligence&lt;/code&gt; carries the matching one in the other direction: its scores rank candidates, none of them is a return, and the tool explicitly tells the model that this endpoint carries no ROI figure at all and points it at the other tool for one.&lt;/p&gt;

&lt;p&gt;I went looking for how often that guard actually fires. Asking the live API for the five rows a demo-key agent gets, on 2026-10-14:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;San Mateo: &lt;code&gt;proformaROI&lt;/code&gt; null, reason &lt;code&gt;no_envelope_rule&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;San Jose: &lt;code&gt;proformaROI&lt;/code&gt; null, reason &lt;code&gt;no_envelope_rule&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Saratoga: &lt;code&gt;proformaROI&lt;/code&gt; -7&lt;/li&gt;
&lt;li&gt;Los Altos Hills: &lt;code&gt;proformaROI&lt;/code&gt; null, reason &lt;code&gt;no_envelope_rule&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;San Jose: &lt;code&gt;proformaROI&lt;/code&gt; null, reason &lt;code&gt;no_envelope_rule&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four of five null, one modellable at -7%, no positives. Both instructions are load-bearing on the very first call a new agent makes. An agent that reads null as zero reports four flat deals; an agent that does not know negatives are normal either suppresses the one real number or flips its sign. Five rows is a demo-tier sample and I am not generalising it to the corpus, but it is exactly what an agent sees, which is the population that matters for this question.&lt;/p&gt;

&lt;p&gt;The reason the nulls are nulls took me a second read of the predicate to state correctly, and it is not the jurisdiction story I assumed. &lt;code&gt;no_envelope_rule&lt;/code&gt; is parcel-scoped, not city-scoped: it is true when no curated allowable floor area governs a single-family rebuild on that parcel, which five separate conditions can cause. Measured against a production snapshot on 2026-09-03, 501 of 1,039 modelled active listings landed there, and 423 of those were condos, townhomes and multi-family units that never had an envelope to give. Another 56 were unincorporated county parcels, 2 were single-family rows the floor-area pass had not reached, and 20 carried a curated envelope more than four times the existing house, which is a rule that exists being deliberately distrusted. So the dominant case is a property type the model does not apply to, not a city nobody has curated.&lt;/p&gt;

&lt;p&gt;In all five cases the served proforma refuses rather than substituting a default, which is the only defensible choice: a substituted envelope produces a number indistinguishable from a real one. The predicate was added after a Saratoga listing cleared an internal shortlist at 39.8% on a nine-thousand-square-foot build that nothing but &lt;code&gt;existing x 1.5&lt;/code&gt; had sized. The on-page calculator still seeds a default for a human to drag; the served number never quotes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two of my own instructions had already gone stale
&lt;/h2&gt;

&lt;p&gt;While checking the above I found that the description I have been praising is wrong, in two places. One drifted three days after the server's last commit, the other about two weeks later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hard cost.&lt;/strong&gt; The &lt;code&gt;list_market_signals&lt;/code&gt; description tells the model the proforma assumes "$400/sqft build," and tells it to quote those assumptions alongside the number. The live API serves &lt;code&gt;assumptions.costPerSqft: 550&lt;/code&gt;. The constant moved from 400 to 550 on 2026-08-21, three days after the server's last commit, because 550 is the midpoint of a verified $400-700 builder band and 400 was the band's floor, which flattered every deal. Measured over 407 gated active listings at the time of that change, moving the hard cost 400 to 550 to 700 took the median ROI from -3.9% to -14.3% to -22.9%, and the count of parcels clearing 20% net from 60 to 28 to 15.&lt;/p&gt;

&lt;p&gt;So the payload carries 550 and the prose next to it says 400, and the prose is the thing instructing the model to quote it. The field is right there in the same response, but nothing obliges a model to prefer a field it was not told to read over a sentence it was told to repeat. A stale number in REST docs misleads a developer who will probably notice. A stale number in a tool description is actively read out to the end user as an assumption behind a figure it does not match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The history window.&lt;/strong&gt; On 2026-09-03 the free tier was windowed to 30 days of history, grandfathering every key created before 2026-09-04. The server's paging argument still says only "Clamped to your API key's tier ceiling," which was a complete description of the tiers when it was written and is not one now. Nothing in the server mentions a history window at all; the word does not appear in its source, README or manifest.&lt;/p&gt;

&lt;p&gt;The API itself is better behaved than its tool description, and the difference is the whole point. The two routes that apply the window, permits and market signals, both report a &lt;code&gt;historyWindow&lt;/code&gt; field in the response, explicitly so a windowed caller can tell a quiet 30 days from a broken query; the other four routes the tools hit are not windowed at all. What is missing is any instruction to read it. For a question like "has anything been permitted at this address," a free key minted since that date is answering from the last 30 days, and the difference between a no and an unknown is sitting in a field the model was never told about. Worth being precise about the scope, too, since "30 days" sounds larger than it is: on the permits tool it windows permit history, but on &lt;code&gt;list_market_signals&lt;/code&gt; it touches sold rows only and the active feed stays whole.&lt;/p&gt;

&lt;p&gt;There is a third, cheaper instance I may as well report: &lt;code&gt;src/index.ts&lt;/code&gt; still announces itself to clients as version &lt;code&gt;1.1.0&lt;/code&gt; while the package and the registry manifest are both at 1.1.2. That one misleads nobody, which is exactly why it survived. I am not counting it alongside the other two, because those are instructions a model acts on and this is a string.&lt;/p&gt;

&lt;p&gt;None of these is exotic. They are the ordinary consequence of putting operational constants into prose in a separate repository with its own release cycle, and they are the strongest argument I have for generating the volatile parts of a tool description from the same constants the API serves, rather than writing them by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting listed is the other half of the work
&lt;/h2&gt;

&lt;p&gt;Publishing an MCP server and having anyone able to find it are different projects. Seven surfaces, re-checked live on 2026-10-14:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Official MCP registry&lt;/strong&gt; - listed, v1.1.2. Took a DNS-authenticated namespace and a manifest that validates against the schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Glama&lt;/strong&gt; - listed, tools indexed. Auto-indexed, then claimed; the maintenance score wanted CI, a tagged release and a &lt;code&gt;glama.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;awesome-mcp-servers&lt;/strong&gt; - merged 2026-09-07. A PR, corrected to the repo's own category conventions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm&lt;/strong&gt; - 1.1.2, published from CI with a scoped token. The &lt;code&gt;bin&lt;/code&gt; shebang is what makes it npx-runnable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mcp.so&lt;/strong&gt; - submission still open at 68 days. Free listings are &lt;code&gt;nofollow ugc&lt;/code&gt;; the paid dofollow tier was declined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smithery&lt;/strong&gt; - blocked upstream. The bundle path rejects any bundle declaring tools, and their issue has been open since June.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PulseMCP&lt;/strong&gt; - structurally closed. New submissions are paused while they rework ingestion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four of seven landed cleanly, one is open indefinitely, one is blocked by a bug in somebody else's tooling and one has the door shut. The useful pattern is that the surfaces which worked are the ones with a machine-checkable artifact behind them: a manifest that validates, a published package, a repo a crawler can read. The ones that stalled are the ones gated on a human reading a queue. If you are planning this work, budget for the manifest and the package, and treat the directory listings as a lottery you enter cheaply.&lt;/p&gt;

&lt;p&gt;On adoption I have nothing to report and will not pretend otherwise. npm shows 191 downloads in the 30 days to 2026-10-13, which for a package in this position is mostly mirrors and crawlers rather than people; the repo has no stars; PulseMCP alone lists over 21,700 servers. AddressIntel has never been monetized and has no customers. What exists is a published, installable capability, measured honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this shows
&lt;/h2&gt;

&lt;p&gt;The adapter was an afternoon. The work was deciding what a model is allowed to conclude from each field, writing that into the one place the model reads on every call, and then going back and checking the contract against the live API instead of trusting it. That check is what found the $400 and the missing history window, and neither was visible from the code, the README or the repo's own commit history.&lt;/p&gt;

&lt;p&gt;Exposing data to agents is becoming the default ask, and the easy part of it is the protocol. The hard part is that an agent will restate your numbers to someone who cannot see the payload, which means every caveat that used to live in a footnote now has to be machine-readable and kept in sync with the system that produces it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Andrew Maury is the founder of &lt;a href="https://rantum.xyz" rel="noopener noreferrer"&gt;Rantum&lt;/a&gt;, a senior data science and ML studio that turns messy, fragmented and adversarial data into models, APIs and products that ship. Originally published at &lt;a href="https://rantum.xyz/case-studies/mcp-tool-descriptions.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>api</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Fingerprinting Hidden DEX Frontends by the Bytes Standard Decoders Ignore</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 30 Sep 2026 15:30:28 +0000</pubDate>
      <link>https://dev.to/andrewmaury/fingerprinting-hidden-dex-frontends-by-the-bytes-standard-decoders-ignore-1c5i</link>
      <guid>https://dev.to/andrewmaury/fingerprinting-hidden-dex-frontends-by-the-bytes-standard-decoders-ignore-1c5i</guid>
      <description>&lt;p&gt;A swap's calldata often carries a few bytes past the last ABI word boundary. Decoders throw them away. They are some of the only on-chain evidence that an interface tagged the trade, and most of the work went into not saying which one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Shared execution infrastructure gives a DEX frontend no standard way to sign its own trades. A swap placed in a wallet's built-in swap tab, in a portfolio app, or on a white-label interface reaches the same routers and the same pools as a swap placed on the router's own website, and lands on-chain looking identical. That is the attribution problem ClearTrace exists to work on.&lt;/p&gt;

&lt;p&gt;Some interfaces do leave a mark, in a place nothing reads. A well-formed ABI call is a four-byte selector followed by some number of thirty-two-byte words. Stick a tag on the end and the call still works, because the decoder reads the arguments it expects and ignores whatever trails them. Etherscan renders the call cleanly. A decoded-trades table carries the swap with no sign of the tag at all.&lt;/p&gt;

&lt;p&gt;So the tag sits there unread, in the one part of the transaction everybody throws out. Finding it is arithmetic on a length:&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;has_calldata_suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_hex&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    A well-formed ABI call is a 4-byte selector (8 hex chars) followed by some
    number of 32-byte words (64 hex chars each). A trailing remainder that
    doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t fill a word is an appended fingerprint: the signature of modular
    frontends and wallet-native swap extensions.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;input_hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drop_hex_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_hex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&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;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&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;%&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole detector. (The prefix strip is inline in the original; I gave it a name here so the arithmetic is the only thing on the page.) Notice what it does not do. It reads none of the bytes. It tells you &lt;em&gt;something&lt;/em&gt; is there and nothing at all about what, which turns out to shape everything downstream of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bytes actually look like
&lt;/h2&gt;

&lt;p&gt;The test is one of the vectors in ClearTrace's attribution kernel, next to direct-versus-proxy routing, fee-recipient detection and aggregator-router matching. The kernel does no I/O: hand it a transaction, a receipt and a function that resolves addresses to labels, and it hands back a classification. That means every decision in it can be tested against a static fixture. A second module maps the classification onto the published taxonomy, one of named frontend, aggregator-API-direct, wallet-embedded, bots and MEV, or unattributed, and tags it with a confidence tier that describes the &lt;em&gt;method&lt;/em&gt; rather than how well-known the name is.&lt;/p&gt;

&lt;p&gt;Both run per transaction on the live public API, so I could just point it at real trades. Everything below came out of it on 2026-09-30. The raw calldata I pulled separately, from a public node.&lt;/p&gt;

&lt;p&gt;I took fifty consecutive Ethereum DEX trades above $1,000: a 3-minute-48-second window on 2026-09-29, 16:10:23 to 16:14:11 UTC, against Uniswap, Curve and Fluid pools, ranging from $1,043 to $1,999,358 with a median of $3,452. (Those sizes are the feed's leg-summed notional, a larger basis than the conserved figure in the aggregate table further down. I am flagging it because the last section of this piece complains about exactly that kind of double-count.) Eight of the fifty carried an unaligned tail on the top-level call. Here is what was actually in them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1inch AggregationRouterV6&lt;/strong&gt;, selector &lt;code&gt;0x07ed2379&lt;/code&gt;, 4 unaligned bytes, tail &lt;code&gt;2a6f45f2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unlabelled contract&lt;/strong&gt; (&lt;code&gt;0x278d...f8d2&lt;/code&gt;), twice, selector &lt;code&gt;0x70521ae9&lt;/code&gt;, 3 unaligned bytes, tail &lt;code&gt;000012&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MetaRouterGateway&lt;/strong&gt;, twice, selector &lt;code&gt;0xa11b1198&lt;/code&gt;, 29 unaligned bytes, beginning &lt;code&gt;bc_nlhvsi52&lt;/code&gt; in ASCII&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unlabelled contract&lt;/strong&gt; (&lt;code&gt;0x881d...300c&lt;/code&gt;), selector &lt;code&gt;0x5f575529&lt;/code&gt;, 1 unaligned byte, tail &lt;code&gt;1f&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 7702 delegated EOA&lt;/strong&gt; (&lt;code&gt;0x0000...089b&lt;/code&gt;), twice, leading bytes &lt;code&gt;0x80000000&lt;/code&gt;, 23 and 30 unaligned bytes, packed payload&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MetaRouterGateway pair is the one I would hang something on: a byte-identical twenty-nine-byte tail on two separate transactions, starting with the ASCII string &lt;code&gt;bc_nlhvsi52&lt;/code&gt;. Somebody's client identifier, in plain text, in calldata, where no standard decoder looks. The four bytes on the 1inch &lt;code&gt;swap&lt;/code&gt; call are almost certainly a referral or partner tag, sitting after every declared argument has been filled, though I cannot prove that from the bytes alone. The three-byte &lt;code&gt;000012&lt;/code&gt; tail does repeat across two transactions to the same contract, which is the pattern this vector is named for, but three low-entropy bytes are about as likely to be a packed trailing field as a tag.&lt;/p&gt;

&lt;p&gt;Then there is the last row, which is where the whole thing gets its limit. Its leading four bytes are &lt;code&gt;0x80000000&lt;/code&gt; and the rest is not ABI-encoded at all, just a packed argument block. There is no last word boundary for a tag to sit past, so the unaligned remainder the test reports is the payload. When I looked up the contract, its code turned out to be twenty-three bytes starting &lt;code&gt;0xef0100&lt;/code&gt;, an EIP-7702 delegation designator rather than a program, so the account is a delegated EOA and there probably is a wallet-side execution surface behind it. That came out of the contract code rather than the detector, though. What the detector caught here was an encoding choice.&lt;/p&gt;

&lt;p&gt;That ceiling is structural and there is no clever way around it. Length arithmetic cannot tell an appended tag from an encoding that was never word-aligned to begin with, and two of my eight hits are the second kind. Some unknown share of every hit is noise like that. You can still use a detector in that state, but only if nothing downstream is ever allowed to read a hit as an identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the eight hits were allowed to conclude
&lt;/h2&gt;

&lt;p&gt;So how many of the eight produced a named frontend? &lt;strong&gt;None.&lt;/strong&gt; All eight came back &lt;code&gt;unattributed&lt;/code&gt;, at &lt;code&gt;low&lt;/code&gt; confidence, with a separate &lt;code&gt;fingerprinted&lt;/code&gt; flag set true. Two rules did that work, and a third sits in front of both.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A suffix on an aggregator's own router names no frontend.&lt;/strong&gt; One of the eight was a 1inch AggregationRouterV6 call. A tag on an aggregator router could belong to the aggregator or to somebody's interface layered on top of it, and there is no way to tell, so neither gets the credit. There is one hole in this, and the code does not hide it: if the router's label matches a &lt;em&gt;wallet&lt;/em&gt;, it is taken as an in-wallet swap at high confidence no matter what the aggregator flag says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A suffix whose label matches nothing on the marker lists names nothing either.&lt;/strong&gt; The other seven were on contracts with no label, or with a label that appears on neither the frontend nor the wallet list: three unlabelled, the two delegated accounts, the two MetaRouterGateway calls. I should be honest about that last pair. MetaRouterGateway &lt;em&gt;is&lt;/em&gt; a routing interface. It goes unnamed because the marker list has not caught up, which is a coverage gap dressed up as a principled refusal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MEV and bots get checked before the suffix at all.&lt;/strong&gt; Two of the fifty went to &lt;code&gt;bots_mev&lt;/code&gt; on their originator label alone. Neither carried a suffix, so nothing actually collided here. It matters anyway: the biggest contract by suffix-overlay volume on Ethereum is an atomic-arbitrage searcher. Check the suffix first and you strip that searcher of its bot classification and file it as fingerprinted-unattributed instead. Worse, anything labelled as both a bot and a wallet would go out as an in-wallet swap at high confidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flag is doing more work than it looks like. &lt;code&gt;unattributed&lt;/code&gt; with &lt;code&gt;fingerprinted: false&lt;/code&gt; means I found no evidence. &lt;code&gt;unattributed&lt;/code&gt; with &lt;code&gt;fingerprinted: true&lt;/code&gt; means an interface definitely tagged this trade and I could not work out which one. Those are two different admissions, and squashing them together is how a coverage number quietly starts flattering you.&lt;/p&gt;

&lt;p&gt;The two layers also rank the suffix differently, which confused me until I read both. The kernel's &lt;code&gt;primary_vector&lt;/code&gt; puts it &lt;em&gt;fourth&lt;/em&gt;, behind proxy, aggregator and fee-recipient, because that field answers how the trade was routed. The taxonomy puts it &lt;em&gt;second&lt;/em&gt;, behind only the MEV check, because that one answers what I know about the interface. Same transaction, two questions, two orderings.&lt;/p&gt;

&lt;p&gt;One guard in the naming path failed, and it failed the way guards do. To attach a name, the resolved label has to look like an interface, which in practice is a substring match against a marker list. One protocol on that list has a two-character name that is also the prefix every hex address starts with. The labeller, meanwhile, puts addresses inside names all the time: &lt;code&gt;Wintermute (0x51c7…8ac2)&lt;/code&gt;, &lt;code&gt;Ethereum First Funder: 0xa4aF…&lt;/code&gt;. Every label of that shape read as a frontend. So a suffix-tagged transaction carrying one of them went out as a named frontend at &lt;em&gt;high&lt;/em&gt; confidence, unless an infrastructure exclusion happened to catch it first. The fix strips address-shaped tokens out of the label before matching and then requires the marker to stand alone as its own word. Of all the places to put a matching bug, the high-confidence tier is the worst one.&lt;/p&gt;

&lt;p&gt;The feed I drew these from is a rolling one-day sample, so it has already rotated and you cannot reproduce my fifty. Here are the eight hashes instead, so you can put them through the endpoint yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0xbd1df7e5bfa30e7b28acae7343def9e6f2137e047072d267d4f4833892ae0ed2&lt;/code&gt; (1inch AggregationRouterV6)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0x562b40b68022f1ed6d9c97a1ceec06d3f37215331509a2f28d1ccaf5ce33f75d&lt;/code&gt; (unlabelled, &lt;code&gt;0x278d...f8d2&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0xb008c636327b9f43e7c928c94fcd6fe22ca5d5001a028a941d2b2d8c624d5345&lt;/code&gt; (unlabelled, &lt;code&gt;0x278d...f8d2&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0x9bb462fced2c7b5d7aabe31332d32ff000a4cfcea41a44a65127c18c8da01935&lt;/code&gt; (MetaRouterGateway)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0x198342c58a22d5264dcfb1e5ed49a4d76faf72353d351bbb1047092935a2fdb7&lt;/code&gt; (MetaRouterGateway)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0x7f0deb69de0f2e9d54ad7a85615ec20997d240b8f9f6577fe4bfe6cdf6173e6a&lt;/code&gt; (unlabelled, &lt;code&gt;0x881d...300c&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0x39f7f878402227b87b2d1197e68692791d626ee728d1bc333522161612310aea&lt;/code&gt; (7702 delegated EOA)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0x685667cee982f6aecc8ad596a832076804cc59846b960d207cdf144ad48acdb9&lt;/code&gt; (7702 delegated EOA)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  More evidence, less attribution
&lt;/h2&gt;

&lt;p&gt;The endpoint has a deep mode that fetches internal call traces, widens the aggregator match to the whole call path, and runs the suffix test on every sub-call. More information, so better answers, or that was my assumption. One caveat first, because the code is plain about it: the live path counts a suffix on any traced call frame, reverted and static ones included, while the Dune version of the same vector requires a successful call. So some of the jump below is just a looser test. I did not measure how much. Here are the twenty newest transactions of the sample, run both ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Carrying a calldata suffix: 3 top-level only, 7 with traces&lt;/li&gt;
&lt;li&gt;Attributed to any class: 4 top-level only, 3 with traces&lt;/li&gt;
&lt;li&gt;Fingerprinted and unidentified: 3 top-level only, 7 with traces&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before anyone lifts those numbers: they are per-transaction counts on fifty consecutive trades above $1,000, and they are not coverage. ClearTrace publishes coverage volume-weighted over a seven-day window, currently 89.5% on Ethereum. A fifty-trade slice of mostly retail-sized swaps does not compare to that figure in either direction.&lt;/p&gt;

&lt;p&gt;Traces more than doubled the fingerprints and left the sample &lt;em&gt;less&lt;/em&gt; attributed, which is not what I expected to find. Five records changed state. Four of them just picked up the fingerprinted flag. One picked up an identity: an originator labelled only &lt;code&gt;Market Maker&lt;/code&gt; turned out to reach KyberSwap's MetaAggregationRouterV2 further down the call path, and moved from &lt;code&gt;unattributed&lt;/code&gt; to &lt;code&gt;aggregator_api_direct&lt;/code&gt;. Two went the other way, down from &lt;code&gt;aggregator_api_direct&lt;/code&gt; at medium confidence to fingerprinted-unattributed at low, because a suffix showed up on a sub-call and the aggregator rule says a tagged router is not the interface. The fifth one is my favourite: an ERC-4337 EntryPoint call whose traces &lt;em&gt;also&lt;/em&gt; reached MetaAggregationRouterV2, and the suffix rule threw that identity away too.&lt;/p&gt;

&lt;p&gt;One name gained, two lost. I am fairly relaxed about that trade, and one of the two was never a published name anyway, since its venue is withheld from output under a separate policy. Tune this system for coverage and it keeps both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the big number is an overlay and not volume
&lt;/h2&gt;

&lt;p&gt;The same test also runs at scale, in the Dune query behind the aggregate attribution table, over every successful internal call instead of just the top-level one. At that grain it goes off on most of the market. From the live API on 2026-09-30, against a trailing seven-day window whose attribution table was written 2026-09-29 16:20 UTC:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ethereum:&lt;/strong&gt; 4,077 contracts, $6.16B conserved volume, $5.13B suffix overlay (83.3%), and 2,180 rows whose overlay exceeds that row's own volume&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base:&lt;/strong&gt; 3,716 contracts, $5.71B, $4.10B (71.9%), 1,392 rows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arbitrum:&lt;/strong&gt; 852 contracts, $1.07B, $527M (49.2%), 352 rows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimism:&lt;/strong&gt; 231 contracts, $143M, $117M (82.2%), 112 rows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On 2,180 of Ethereum's 4,077 contract rows, the suffix figure comes out larger than that row's entire conserved volume. The two are counted at different grains: conserved volume takes each transaction's notional once, the overlay sums the legs. The top row on Ethereum is that atomic-arb searcher again, at a $982.1M overlay against $945.0M of its own routed volume.&lt;/p&gt;

&lt;p&gt;You cannot add a column to a total it can exceed. So it rides along as a reported overlay, out of the headline sum, with the reasoning written into the query instead of left for whoever reads the output next. The query keeps a note about what happens if you fold the sibling fee overlay back in: the Ethereum total jumps sharply and overshoots the independent third-party estimate of the same market. Double-counting announces itself as a number you like.&lt;/p&gt;

&lt;p&gt;Which settles what this vector is actually for. At the trace grain it is nearly everywhere, so it cannot tell frontends apart. At the transaction grain it is rare and specific and, on a live sample, names nobody. Either way its job is to mark the edge of what I know.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this proves
&lt;/h2&gt;

&lt;p&gt;Finding a signal nobody else reads took an afternoon. The rest of the work was the layer that decides what the signal is allowed to conclude, and then checking what that layer really does against live trades rather than trusting what its docstring says about it. On this sample it turned eight fingerprints into zero names and one honest flag, and dropped two medium-confidence attributions as soon as better evidence said they were shaky.&lt;/p&gt;

&lt;p&gt;Any vendor can report coverage. The hard part is drawing a line you can defend between what a dataset knows, what it suspects and what it only inferred, and then putting that line on every record so a buyer can act on it. That is the part that survives contact with an adversarial domain, and it is the part Rantum builds.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/calldata-suffix-fingerprinting.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Andrew Maury is the founder of &lt;a href="https://rantum.xyz" rel="noopener noreferrer"&gt;Rantum&lt;/a&gt;, a senior data science and ML studio that turns messy, fragmented, and adversarial data into models, APIs, and products that ship. He spent roughly four years on data infrastructure at 0x Labs, advised the Uniswap Grants Program, and has done client work for Art Blocks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>ethereum</category>
      <category>datascience</category>
      <category>defi</category>
    </item>
    <item>
      <title>Scraping Adversarial Municipal Portals: A Permit Pipeline That Knows When It Failed</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 16 Sep 2026 16:24:02 +0000</pubDate>
      <link>https://dev.to/andrewmaury/scraping-adversarial-municipal-portals-a-permit-pipeline-that-knows-when-it-failed-pie</link>
      <guid>https://dev.to/andrewmaury/scraping-adversarial-municipal-portals-a-permit-pipeline-that-knows-when-it-failed-pie</guid>
      <description>&lt;p&gt;Most California cities publish their building permits, and almost none of them publish the same way. The San Francisco Peninsula alone runs six vendor portal products (eTRAKiT, on at least two incompatible forks; Tyler Technologies' EnerGov and Civic Self Service; Accela; Clariti; PermitStack), one municipal open-data feed, and one city whose only complete record is a monthly report it posts as a document. Searches are login-gated, JavaScript-rendered, and driven by ASP.NET postbacks that rewrite the form under you. Two of the portals answer only to a real browser.&lt;/p&gt;

&lt;p&gt;A blocked request is loud: it throws, the run goes red, somebody fixes it. The failures that cost you are the ones that return a well-formed answer to a question you did not ask.&lt;/p&gt;

&lt;p&gt;This is a write-up of the permit-collection layer behind &lt;a href="https://rantum.xyz/case-studies/address-intel.html" rel="noopener noreferrer"&gt;AddressIntel&lt;/a&gt;, a real-estate intelligence product built on fragmented municipal records. As of 2026-09-16 it holds &lt;strong&gt;187,838 permits across 20 permitting jurisdictions&lt;/strong&gt;: nineteen cities and towns, plus unincorporated San Mateo County, whose rows carry the names of seven coastal communities. 177,522 of the permits carry an assessor's parcel number and 128,324 carry a declared valuation above zero.&lt;/p&gt;

&lt;p&gt;Two things about that count belong next to it. Coverage is uneven, from 53,160 rows in Palo Alto to 28 in Foster City, and a city with few rows is either quiet or under-collected, which the rest of this piece is about telling apart. And 39,830 rows, 21% of the total, carry no issued date: 37,235 of those are Palo Alto's, from an open-data export that publishes an application date and a lifecycle status for each permit (33,641 of them Finaled) but no issue date. The remaining 2,595 are spread across five other sources with mixed statuses, including 372 marked Issued that carry no date for it. One Saratoga row is dated 2026-10-03, seventeen days after this was written, with an application date of 2025-10-01. It is stored as the portal printed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two failures that look like data
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Failure one: the empty list that looks like a quiet month.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One city's portal login was being rejected on every run. The scraper printed a warning, returned an empty permit list, and the pipeline reported success. A city we actively cover disappeared from the product for weeks and nothing alarmed.&lt;/p&gt;

&lt;p&gt;Then the absence turned into a zero. The city's demolition-permit count rendered as &lt;code&gt;0&lt;/code&gt;, indistinguishable from a city that had filed none, and that zero reached public copy written for builders in that market.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure two: the result set that is not the answer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every eTRAKiT host caps a search result. It applies the cap before sorting, it emits no error, and once the grid's page size is raised past the cap the pager that would have read "page 1 of N" disappears. Nothing on the page distinguishes a truncated result set from a complete one except its size.&lt;/p&gt;

&lt;p&gt;The rows it keeps follow the portal's own internal order, which skews heavily oldest-first, so the truncation drops the newest permits: the only ones a recent-activity scrape exists to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Measure the cap; never assume it
&lt;/h2&gt;

&lt;p&gt;Because nothing on the page announces truncation, the size of the result set is the only tell available, and it has to be read before anything is trusted. Each host's cap was measured directly on 2026-08-25, one query per host, page size raised to 2000 so the whole set arrived at once:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Cap&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;ISSUED AT LEAST 01/01/2015&lt;/code&gt; returned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Atherton&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;100 rows, newest issued 2018-07-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hillsborough&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500 rows, newest issued 2020-01-08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saratoga&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500 rows, newest issued 2015-04-09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redwood City&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;1000 rows, 743 of them from 2015&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cap ranges from 100 to 1000 across four hosts of nominally the same product, so it cannot be hardcoded once and forgotten. It is also independent of page size: page one of the un-resized grid reports "page 1 of N" with N times the page size equal to the cap exactly, and raising the page size past the cap returns the cap with no pager at all.&lt;/p&gt;

&lt;p&gt;Sorting does not help. Sorting &lt;code&gt;AT LEAST 01/01/2026&lt;/code&gt; on the Hillsborough host by issued date descending, which is what the scraper's own sort helper does, still returns that query's 500 rows stopping at 2026-04-22, while the city has been issuing permits continuously since. (That is a different query from the one in the table above, which is why its cut-off date differs; both saturate at 500.) Sorting reorders the survivors. It does not change which rows survived.&lt;/p&gt;

&lt;p&gt;Two hosts in the fleet are login-gated, so their caps are &lt;em&gt;unmeasured&lt;/em&gt; rather than absent, and the code keeps that distinction. For those, the fallback test is a total landing exactly on a round number: a result set is a count of permits a city happened to issue, and that count landing on exactly 500 by coincidence is far less likely than it landing there because something clipped it.&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;etrakit_is_saturated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;True when a result set of `total_rows` is sitting on its host&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s cap.

    Saturated means TRUNCATED: the portal returned as many rows as it will
    ever return, so there are almost certainly more records matching the
    search than came back, and the ones missing are the newest.

    Deliberately &amp;gt;= rather than ==: a host that raises its cap, or one whose
    cap was measured low, must still trip this.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;total_rows&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;etrakit_result_cap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;cap&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;total_rows&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total_rows&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ETRAKIT_ROUND_CAPS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Find the one bucket the platform cannot truncate
&lt;/h2&gt;

&lt;p&gt;Detecting truncation is only useful if there is something better to do than report it. The standard answer, bisect the query until each half fits, is unavailable here: the search offers one criterion and five operators (&lt;code&gt;BEGINS WITH&lt;/code&gt;, &lt;code&gt;CONTAINS&lt;/code&gt;, &lt;code&gt;EQUALS&lt;/code&gt;, &lt;code&gt;AT LEAST&lt;/code&gt;, &lt;code&gt;AT MOST&lt;/code&gt;), there is no &lt;code&gt;BETWEEN&lt;/code&gt; and no second field, the string operators reject a partial date outright, and &lt;code&gt;AT MOST&lt;/code&gt; truncates from the same end, so an &lt;code&gt;AT MOST&lt;/code&gt; query on the Atherton host comes back with 100 rows whose newest is 2008-04-28.&lt;/p&gt;

&lt;p&gt;That leaves &lt;code&gt;EQUALS&lt;/code&gt; on a single calendar day as the only bounded bucket the platform offers. Measured across the four date-search hosts, a single day returns 5 to 22 rows against caps of 100 to 1000. A bucket that small never reaches the cap, and the scraper checks each day's total anyway, so a day that did saturate would be reported rather than half-read.&lt;/p&gt;

&lt;p&gt;The walk runs newest-first on a 45-day budget, because each day costs a postback and its settle time, and the freshest permits are the ones worth spending the budget on. Whatever the budget does not reach is named in the run log and recorded as a source failure. A historical backfill asks for 1825 days, which this path will not walk; the alternative is a backfill that returns the portal's oldest 500 rows and calls itself complete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the instrument reads.&lt;/strong&gt; Across the sixteen scheduled runs from 2026-09-10 to 2026-09-16, twelve reached the Atherton host's date search. All twelve saturated its 100-row cap and fell back to the day-walk; the other four were cut off by the time budget before reaching it. None of the sixteen recorded an uncovered window, so the fallback never had to report a gap it could not close.&lt;/p&gt;

&lt;p&gt;The outcome is visible in the database, which holds &lt;strong&gt;138 Atherton permits issued on or after 2026-08-16&lt;/strong&gt;, for a search the portal will not return more than 100 rows of. The town's 1,298-page records ingest cannot account for the surplus: it landed on 2026-08-05, before that window opens. Saturation on this host is the steady state, and without the fallback every one of those twelve runs would have reported success while handing back the oldest hundred rows of the window.&lt;/p&gt;

&lt;p&gt;The budget that binds is &lt;code&gt;max_permits&lt;/code&gt;, at 50. Every one of those runs stopped after 12 to 16 of the 31 days on that cap, which the walk inherits from the plain search. The 45-day figure is the ceiling on how far back a walk may reach.&lt;/p&gt;

&lt;p&gt;A saturation the day-walk fully recovered from costs time and is therefore &lt;strong&gt;not&lt;/strong&gt; recorded as a failure. Only days the walk could not cover are. A busy city would otherwise red the pipeline twice a day forever, which is the alarm-nobody-reads failure mode the health gate exists to prevent.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Two gates, and the incident between them
&lt;/h2&gt;

&lt;p&gt;The health registry records only failures the scrapers &lt;em&gt;know&lt;/em&gt; about: a rejected login, an unreachable portal, a saturated search that could not be recovered. It does not infer breakage from a low permit count, because a quiet city and a broken scraper both produce zero permits, and guessing between them is how you build an alarm that cries wolf and then gets ignored. If a scraper cannot tell you it failed, that is a gap in the scraper.&lt;/p&gt;

&lt;p&gt;That rule is right for the registry, and it was not enough for the pipeline. On 2026-09-01, two cities were found silently dead in production: Sunnyvale had added no permit row for 14 days, Woodside for 33. Every run in that window reported success, and three separate guards missed it. The registry saw nothing, because neither city failed in a way its scraper knew about: Woodside's date-walk searched a portal that has no date search and got a legitimate empty result, and Sunnyvale's aggregator answered HTTP 200 with frozen data. The freshness check read &lt;code&gt;MAX(scraped_at)&lt;/code&gt; across every city at once, which San Jose alone holds at today forever, so it reported all sources fresh on the morning both cities were 14 and 33 days dead. The third guard reported which targets the time budget had cut off; Sunnyvale had been reached in 8 of the previous 11 runs and ingested nothing on every one.&lt;/p&gt;

&lt;p&gt;A permit source can fail by succeeding at nothing, and nothing was watching whether rows actually landed, per city, over time. A second gate now does that. It infers from volume, the inference the registry refuses to make, and it makes it in one place, explicitly, with a threshold that can be read.&lt;/p&gt;

&lt;p&gt;Sizing that threshold is where the interesting mistake lives. The obvious approach is to learn each city's normal silence from its own history, and that was tried first. Measured against the production snapshot, a dozen unrelated cities shared an identical 13-day gap in August 2026, a rotation failure that had reached only half the target list for twelve days. A threshold learned from that history would have set Sunnyvale's tolerance to 20 days and slept straight through its 14-day outage. A guard that learns "normal" from a record containing its own failure mode gets blinder every time it fails. The threshold is sized from how much each city normally files instead, with a ten-day floor.&lt;/p&gt;

&lt;p&gt;Three details of the registry follow from the same reasoning.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failures flush on every failure.&lt;/strong&gt; The scrape executes under a &lt;code&gt;timeout&lt;/code&gt; that terminates it when the budget expires, routinely. Anything saved only at the end of the run is lost exactly when a slow, failing source is the likeliest cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gate runs last, after the sync and the data-store save.&lt;/strong&gt; The run goes red, but every byte the scrape did manage to collect is already persisted. A red run that kept its data is the goal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Known-unscrapable sources report without reddening the run.&lt;/strong&gt; Where the fix is a new data source rather than a retry, a permanent red is a switched-off alarm. Entry into that list requires a tracked issue and a stated reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The retry policy is drawn on the same line. A timeout, a stale element reference, an intercepted click, a grid that did not re-render, an ASP.NET postback that never landed: these differ between two runs against unchanged markup, so they get one more attempt with a fresh driver. Two red runs in August 2026 were of this kind, one dying on an index error and one on a renderer timeout. For the first, the run ninety minutes earlier had walked the identical pages, blocks and pagers to a clean finish; that comparison was not made for the second. Neither was a markup change, and a single retry would very likely have absorbed both. In both, the sync and the data-store save had already succeeded, so they were red purely on an exit code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NoSuchElement&lt;/code&gt; is excluded from that list. An element that is simply absent is the signature of a portal whose markup changed, which is real breakage and must red the run on the first attempt.&lt;/p&gt;

&lt;p&gt;One inference across sources is safe. One quiet city is ordinary. Every city sharing a single scraper going quiet at once is a fact about the shared code, so a platform whose every source came back empty is reported as a known failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Knowing when scraping is the wrong answer
&lt;/h2&gt;

&lt;p&gt;One town's portal has no issued-date search at all, only permit number and site address. A number-walk is technically possible; the town's permits carry year-prefixed series identifiers that the fleet's number-walk path already enumerates elsewhere. It is still the wrong fix, because that portal's public detail panel carries no declared valuation and no parcel number, and declared valuation is the field the town is carried for. A number-walk would restore row count while losing the column that matters, at roughly thirty-six seconds per permit. The route out is a recurring public-records request, and that channel now carries the town's entire permit record. Re-measured at publication against the September refresh, received 2026-09-11: &lt;strong&gt;5,237 permits, of which 2,460 carry a declared valuation totalling $322,162,086&lt;/strong&gt;. The town confirmed on 2026-09-01 that a standing arrangement is not available, so each refresh is its own request.&lt;/p&gt;

&lt;p&gt;That entry in the registry also carries its own correction. It used to assert that the number-walk was impossible because the town's permit identifiers were pure numeric strings with no series prefix. They are not. The claim had been written while the town had zero scraped rows and nobody had yet seen a real permit number from it, and it survived until a records delivery revealed the actual format. Written into a code comment, an assumption made in the absence of data reads exactly like a measurement.&lt;/p&gt;

&lt;p&gt;The results grid clips long descriptions to thirty characters server-side, with a literal ellipsis and no title attribute holding the full string, so the complete value is not on the page. The ellipsis is kept rather than stripped: it is the only record that the stored value is a prefix, and stripping it would make a clipped string indistinguishable from a complete one. Full strings arrive later from the sources that print them whole, and the writer never overwrites a value it already holds, so a clipped scrape cannot regress a complete record.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys
&lt;/h2&gt;

&lt;p&gt;A permit table assembled from these sources can carry a claim about its own coverage. The saturation check, the day-walk, the two gates and the retry line each turn one specific silent failure into a logged one, and the numbers above are what they read on a given week. When a vendor changes a form control, the run goes red and the data it collected stays saved.&lt;/p&gt;

&lt;p&gt;A scraper that could not report its own failure produced a zero, and that zero became a published statistic. The pipeline now records "unknown" separately from "none", and the difference is what a buyer of this data is paying for.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/adversarial-permit-portals.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Andrew Maury is the founder of Rantum, a senior data science and ML studio that turns messy, fragmented, and adversarial data into models, APIs, and products that ship. Previously ~4 years on data infrastructure at 0x Labs; past work advising the Uniswap Grants Program, and client work for Art Blocks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>python</category>
      <category>webscraping</category>
      <category>proptech</category>
    </item>
    <item>
      <title>Backed, or Just Named: Thirteen Forged Tokenized-Equity Tickers</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 09 Sep 2026 20:49:17 +0000</pubDate>
      <link>https://dev.to/andrewmaury/backed-or-just-named-thirteen-forged-tokenized-equity-tickers-17m</link>
      <guid>https://dev.to/andrewmaury/backed-or-just-named-thirteen-forged-tokenized-equity-tickers-17m</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/backed-or-just-named.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Thirteen contracts on Base forged five of Coinbase's tokenized-equity tickers and recorded $393,805 of volume before they stopped. Coinbase says the genuine tokens beside them are backed one-for-one. Neither the name nor the bytecode tells the two apart. One on-chain check narrows it, and it is worth being exact about how far.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things a token can be
&lt;/h2&gt;

&lt;p&gt;In Money Stuff on 9 September 2026, Matt Levine set out a taxonomy for tokenized stocks. A token carrying a company's name, he observes, can be one of exactly four things. It can be &lt;em&gt;stock&lt;/em&gt;, issued on-chain by the company itself. It can be &lt;em&gt;an SPV&lt;/em&gt;: "I buy 1,000 AMC shares, I put them in a pot, and I issue exactly 1,000 tokens against that pot." It can be &lt;em&gt;a swap&lt;/em&gt;, an IOU for the asset's return. Or it can be &lt;em&gt;nothing&lt;/em&gt;: "I issue 1,000 tokens each representing one AMC share, and you buy them, and I don't owe you anything. They're just, like, crypto tokens with the name 'AMC' on them." He notes the fourth is out of fashion in equity tokenization, "though it is a very traditional approach in crypto generally."&lt;/p&gt;

&lt;p&gt;Levine is writing about Robinhood, whose tokens Robinhood describes, in a statement he quotes, as "backed 1:1 by the corresponding underlying equity." By his taxonomy a token like that is the second kind. That classification is mine, applied to his categories; he is not describing the contracts below, and this piece borrows his ladder and nothing else.&lt;/p&gt;

&lt;p&gt;What the taxonomy leaves open is the question a holder actually has. Four categories are named, but nothing in that column says how someone holding a token would work out which of the four they hold. That question has a partial on-chain answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What measurement can and cannot settle
&lt;/h2&gt;

&lt;p&gt;A rung is a claim about the world off the chain. Whether shares are sitting in a custodian's account is not visible from a contract, so on-chain data cannot confirm the first three rungs. No amount of reading state proves a pot exists.&lt;/p&gt;

&lt;p&gt;It can do the opposite. On-chain data can show that nothing is behind a token, which rules out the first three and leaves only the fourth. The check is cheap and it runs in one direction. What follows is a namespace where running it separates the second rung from the fourth inside a single set of search results.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found
&lt;/h2&gt;

&lt;p&gt;Everything below comes from two sources. Contract state (&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;symbol&lt;/code&gt;, &lt;code&gt;decimals&lt;/code&gt;, &lt;code&gt;totalSupply&lt;/code&gt;, &lt;code&gt;eth_getCode&lt;/code&gt;) was read directly from Base over RPC on 3 September 2026 and again on 9 September. Volume comes from one execution of a query over Dune's decoded &lt;code&gt;dex.trades&lt;/code&gt; table for Base, window 1 July to 3 September 2026, run on 3 September; those figures are leg sums, which count each leg of a route and are an upper bound on the collapsed per-trade figure.&lt;/p&gt;

&lt;p&gt;Coinbase publishes a list of thirteen B20 tokenized-equity contracts on Base. On 3 September, four of them had traded: NVDAc, AAPLc, METAc and GOOGLc, tracking NVIDIA, Apple, Meta and Alphabet. By 9 September ten of the thirteen carried supply, so the asset class is being issued faster than this piece is being written. In the window the four traded &lt;strong&gt;$241,972,775&lt;/strong&gt;, which is 0.167% of all Base DEX volume in the same window. Aerodrome Slipstream carried 86.46% of it.&lt;/p&gt;

&lt;p&gt;Alongside them, in the same window, thirteen impostor contracts forged five of those tickers: NVDAc, AAPLc, METAc, GOOGLc and COINc. Together they recorded &lt;strong&gt;$393,804.68&lt;/strong&gt;. Ten of the thirteen ran a uniform pattern: roughly half an hour of life, between 58 and 71 distinct taker addresses, then nothing. The three COINc contracts ran differently, each with a single taker, two of them recording $138,882 between them in consecutive half-hours.&lt;/p&gt;

&lt;p&gt;COINc is worth a sentence on its own. Coinbase's COINc contract is on the published list, but as of 9 September its supply is zero; the genuine token has not been issued. The three COINc forgeries recorded $138,882 against a real token that did not yet exist. Nothing in this dataset is a cleaner example of Levine's fourth rung.&lt;/p&gt;

&lt;h3&gt;
  
  
  The address shape is not an identifier
&lt;/h3&gt;

&lt;p&gt;Coinbase's B20 contracts share an 88-bit address prefix, &lt;code&gt;0xb200000000000000000000&lt;/code&gt;, which Base documents as the standard's address convention. It reads like a namespace badge. Eighty-eight bits is not something a forger mines, and nobody had to: the prefix comes with the deployment path, and &lt;strong&gt;7,532 distinct tokens on Base&lt;/strong&gt; carry it. The first pass at measuring this asset class matched on the prefix, and the highest-volume NVDAc, AAPLc and METAc rows it returned were forgeries. A prefix check does not need to be defeated. It clears the impostors for free.&lt;/p&gt;

&lt;h3&gt;
  
  
  The name does not separate them either
&lt;/h3&gt;

&lt;p&gt;The obvious fallback is to read &lt;code&gt;name()&lt;/code&gt; and compare it with the company. That clears the forgeries that match and flags only the ones that do not bother.&lt;/p&gt;

&lt;p&gt;Two of the thirteen return a &lt;code&gt;name()&lt;/code&gt; of &lt;code&gt;NVIDIA Corporation&lt;/code&gt;, byte-identical to Coinbase's own contract. Four use &lt;code&gt;Meta Platform Inc.&lt;/code&gt;, one character away from the real &lt;code&gt;Meta Platforms Inc.&lt;/code&gt; Four more return the bare ticker. A name check passes the two exact matches and catches the four that were never going to pass anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  And neither does the bytecode
&lt;/h3&gt;

&lt;p&gt;Bytecode fingerprinting is the technique that cracked &lt;a href="https://rantum.xyz/case-studies/liquidity-mirage.html" rel="noopener noreferrer"&gt;the mainnet impersonator fleet&lt;/a&gt;, where 1,005 contracts shared one byte-identical 5,150-byte runtime. It returns nothing here. &lt;code&gt;eth_getCode&lt;/code&gt; at every address in this namespace, genuine and forged alike, is the single reserved byte &lt;code&gt;0xef&lt;/code&gt;. Whatever machinery on Base answers &lt;code&gt;eth_call&lt;/code&gt; for these addresses, it is not per-contract runtime, so there is no code to hash and no fingerprint to compare. I did not investigate the mechanism; the negative result is what matters for detection.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to detect it
&lt;/h2&gt;

&lt;p&gt;One check remains, and it is the one that reads the backing rather than the branding. Coinbase's contracts carry &lt;strong&gt;8 decimals and a supply denominated in shares&lt;/strong&gt;. Every impostor carries &lt;strong&gt;18 decimals and exactly 1,000,000,000 units&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- Base mainnet, read 2026-09-09 (morning, UTC-7)
-- canonical NVDAc
decimals()    = 8
totalSupply() = 15,473.08          -- a share count

-- forged NVDAc, same prefix, same name() string
decimals()    = 18
totalSupply() = 1,000,000,000.00   -- a round number, chosen not derived
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intuition runs backwards. A supply of one billion at 18 decimals is not a forgery signature; it is the ordinary default in this namespace. Five unrelated tokens on the same prefix, picked by hand on 3 September, are all 18 decimals and four of the five carry exactly one billion. The impostors look normal. &lt;strong&gt;Coinbase's tokens look unusual&lt;/strong&gt;, because 8 decimals and a share count are what one-for-one backing forces on you. The test only works if you already know what the genuine article looks like, which means the issuer's published contract list is the thing actually separating them, and the supply shape is a way to notice when a row does not match it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The stronger signal: the supply moves
&lt;/h3&gt;

&lt;p&gt;A static reading is a snapshot, and a forger can copy a share-shaped number for the cost of one deployment. A claim backed one-for-one has to be minted when someone deposits and burned when someone redeems, so its supply moves. Reading the same contracts six days apart:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;token&lt;/th&gt;
&lt;th&gt;supply 2026-09-03&lt;/th&gt;
&lt;th&gt;supply 2026-09-09&lt;/th&gt;
&lt;th&gt;change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NVDAc (canonical)&lt;/td&gt;
&lt;td&gt;12,640.08&lt;/td&gt;
&lt;td&gt;15,473.08&lt;/td&gt;
&lt;td&gt;up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AAPLc (canonical)&lt;/td&gt;
&lt;td&gt;5,773.03&lt;/td&gt;
&lt;td&gt;7,276.36&lt;/td&gt;
&lt;td&gt;up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;METAc (canonical)&lt;/td&gt;
&lt;td&gt;2,978.89&lt;/td&gt;
&lt;td&gt;2,130.54&lt;/td&gt;
&lt;td&gt;down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GOOGLc (canonical)&lt;/td&gt;
&lt;td&gt;not recorded&lt;/td&gt;
&lt;td&gt;7,976.14&lt;/td&gt;
&lt;td&gt;(no 09-03 reading)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all 13 impostors&lt;/td&gt;
&lt;td&gt;1,000,000,000.00&lt;/td&gt;
&lt;td&gt;1,000,000,000.00&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By the evening of 9 September the canonical figures had moved again: NVDAc to 15,537.08, GOOGLc to 8,046.14, and AAPLc down to 6,702.59. A number that goes down is the useful one, because a decrease is what redemption looks like from outside, and a fixed-supply forgery cannot show one without actually burning tokens.&lt;/p&gt;

&lt;p&gt;It is important not to overstate this. A forger with a mint-and-burn function can simulate movement, so drift is evidence of an active issuer process rather than of custody. What it does is raise the cost of forgery from a one-time deployment to an ongoing one. In this dataset none of the thirteen paid it: every impostor sat at exactly one billion across both readings. That is the same shape of result as the name check, forgers who did not bother, at a higher price. It rules out the empty case and is consistent with the second rung, which is the most that reading a chain delivers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;p&gt;It does not verify Coinbase's custody. Everything above is consistent with one-for-one backing and rules out the empty case. Confirming that shares sit in an account is an audit question, not an on-chain one, and nothing here touches it.&lt;/p&gt;

&lt;p&gt;It does not establish who was on the other side of the $393,805. "Taker" here means a distinct address that took the other side of a swap in the decoded trade data. Whether those addresses were independent of the deployers was not measured, and the uniform ~30-minute, ~60-taker lifecycle is consistent with automated new-token sniping. So the $393,805 is volume the pools recorded, not demonstrated third-party demand, and nothing here claims anyone was harmed. The finding is that the forgeries were indistinguishable by the checks most people would run; whether anyone ran one is unknown.&lt;/p&gt;

&lt;p&gt;The window is 1 July to 3 September 2026, and volumes are leg sums. The thirteen impostors are the ones that appeared in that window; nothing rules out others before or since.&lt;/p&gt;

&lt;p&gt;Supply figures drift, and that is the point of the section that uses them. The 3 September canonical readings had all moved by the morning of 9 September and again by the evening. Every figure here is a timestamped reading, not a standing fact.&lt;/p&gt;

&lt;p&gt;The separate Ethereum mainnet fleet shares a playbook with these contracts. Whether it shares an operator was not looked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;Three intuitive checks on this namespace all fail. The address prefix is shared by 7,532 tokens and costs nothing to obtain. The name is copyable and was copied byte-for-byte. The bytecode is a single reserved byte for everyone. Three plausible verification strategies, each returning a confident wrong answer.&lt;/p&gt;

&lt;p&gt;The check that works reads what is behind the token and watches whether that number behaves like a claim on something. It cannot confirm the claim. It can rule out its absence, and in this case it did, for ten of Coinbase's contracts and none of the thirteen forgeries.&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>security</category>
      <category>datascience</category>
      <category>web3</category>
    </item>
    <item>
      <title>One Pool Deep: Available Capacity Is Not a Realized Fill</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Tue, 08 Sep 2026 16:23:05 +0000</pubDate>
      <link>https://dev.to/andrewmaury/one-pool-deep-available-capacity-is-not-a-realized-fill-2i66</link>
      <guid>https://dev.to/andrewmaury/one-pool-deep-available-capacity-is-not-a-realized-fill-2i66</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/research/capacity-vs-realized-execution.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two measurements of Uniswap execution landed on the same surface this month, and at a glance they look like the same measurement. One reconstructs how much liquidity a pair actually holds. The other measures what a trader's order actually received. A desk evaluating execution needs both, and using one where the other belongs is a mistake worth naming. This is the short version. The full note, with every figure and its source, is on the rantum.xyz research page linked above.&lt;/p&gt;

&lt;h2&gt;
  
  
  The supply-side measurement: how much a pair could absorb
&lt;/h2&gt;

&lt;p&gt;A public study on ethresear.ch, "When Multiple Pools Behave Like One: Impact-Constrained Capacity Concentration in Uniswap v3," reconstructs daily Uniswap-v3 pool state from Mint, Burn, and Swap events and asks a counterfactual question: for a given pair on a given day, what is the maximum notional you could push at a fixed price-impact tolerance, on the single best pool versus split across the pair's sibling fee-tier pools?&lt;/p&gt;

&lt;p&gt;Its headline result, across 293,273 family-day-direction-impact cells over 78 pairs on Ethereum (January 2024 to June 2026), is that the depth that matters at a fixed impact tolerance sits in one fee tier. The median effective number of pools is 1.076, and a single tier holds a median 96.3% of impact-constrained capacity. Despite several fee-tier pools per pair, at the liquidity layer a pair behaves like one deep pool. The author scopes this tightly and says so: it measures capacity at the liquidity layer, and gas, routing frictions, MEV, and cross-layer effects are out of scope. It is how much could be executed, not what was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demand-side measurement: what an order actually received
&lt;/h2&gt;

&lt;p&gt;ClearTrace starts from the opposite end. It takes an aggregator's quote and the fill that actually settles, measures the gap in basis points, and decomposes it into the pieces that caused it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;output_gap_bps = (quoted_out - realized_out) / quoted_out * 10,000
slippage_bps   = output_gap_bps - fee_bps - mev_bps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reverted trade is never forced into that formula, because a failed trade decomposed nothing. It is counted as a rate across a batch instead. This runs over actual and fork-simulated fills at fixed sizes ($1k, $10k, $100k, $1M), and it deliberately includes the three things the capacity study sets aside: MEV, reverts, and routing topology.&lt;/p&gt;

&lt;p&gt;The numbers it produces are realized outcomes. On the 2026-08-26 sampler export across Ethereum aggregators, the median fill lands at or within a fraction of a basis point of its quote at every size cohort, while fork-verifiable quotes fail to realize at a low single-digit rate that varies by size (3.6% at $1k, 2.2% at $100k, 4.5% at $1M). These are demand-side measurements: what a specific order got, after the router did its work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why they are different objects
&lt;/h2&gt;

&lt;p&gt;Available capacity is a ceiling. A realized fill is a result. The distance between them is exactly the routing, MEV, and reverts the capacity study places out of scope, so neither number validates the other. A pair with an effective pool count near one can still deliver a clean fill, because a router works around a shallow book by splitting across venues. A pair with ample impact-constrained capacity can still realize badly, because MEV or a revert lands on the actual trade and never touches the counterfactual optimizer. They answer different questions about the same pair.&lt;/p&gt;

&lt;p&gt;ClearTrace runs nothing resembling the study's method. There is no pool-state reconstruction and no capacity optimizer anywhere in its codebase. It never needed one, because it measures the trade rather than the terrain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one place they connect
&lt;/h2&gt;

&lt;p&gt;There is a real link, and stating it precisely is more useful than overstating it. The study describes the terrain a router faces: at the AMM layer a pair is effectively one deep pool. ClearTrace's realized data shows median fills holding near their quote across sizes anyway. Those two facts are consistent only if size-aware routers are spreading orders across mechanisms rather than leaning on that one pool, and that is what the per-trade routing data shows: in the same export, 83.7% of sampled $1M routes split across more than one venue, a median of three distinct venues per route, against 69.0% at $1k.&lt;/p&gt;

&lt;p&gt;At size, the extra venues are increasingly not AMM pools at all. On the same export, the share of KyberSwap's sampled Ethereum routes touching at least one off-chain RFQ leg rises from 2.2% at $1k to 10.0% at $10k, 21.5% at $100k, and 81.1% at $1M over the full sample span. That last figure moves month to month, so read it as a range rather than a constant. RFQ depth is exactly the liquidity a pool-state reconstruction cannot see, so at $1M the realized fills in this data route dominantly through depth that is invisible to the study's optimizer by construction. The study explains why aggressive size-aware routing exists. ClearTrace measures whether that routing actually delivers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it means for a desk
&lt;/h2&gt;

&lt;p&gt;If you evaluate execution, you want both views and you should not substitute one for the other. The supply view tells you how much a venue can absorb before impact bites, which is a property of the pool. The demand view tells you what your order actually received, which is a property of your trade and the route it took. A venue can look deep and fill you badly. A venue can look shallow and fill you well through a router. One number without the other is half the picture.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The full research note, including the sourcing for every figure, is at &lt;a href="https://rantum.xyz/research/capacity-vs-realized-execution.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;. ClearTrace is at &lt;a href="https://cleartracedata.com" rel="noopener noreferrer"&gt;cleartracedata.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Andrew Maury is the founder of Rantum, a senior data science and ML studio that turns messy, fragmented, and adversarial data into models, APIs, and products that ship. He has 6+ years in crypto: he previously scaled data at 0x Labs, contributed to Dune's Spellbook, and advised the Uniswap Grants Program.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>defi</category>
      <category>ethereum</category>
      <category>datascience</category>
      <category>web3</category>
    </item>
    <item>
      <title>The Liquidity Mirage: $3.28B of Reported Liquidity, Backed by 0.0007 ETH</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:27:40 +0000</pubDate>
      <link>https://dev.to/andrewmaury/the-liquidity-mirage-328b-of-reported-liquidity-backed-by-00007-eth-5058</link>
      <guid>https://dev.to/andrewmaury/the-liquidity-mirage-328b-of-reported-liquidity-backed-by-00007-eth-5058</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/liquidity-mirage.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Uniswap V3 pool on Ethereum mainnet reports $3,286,019,931 of liquidity. The real assets backing it are 0.000739 WETH, which at $2,410.77 per ETH is about $1.78. Both figures are accurate, and the gap between them is the whole story.&lt;/p&gt;

&lt;p&gt;This is what turned up while building a much less interesting thing: a browser extension that flags impersonator tokens in DEX listings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The challenge
&lt;/h2&gt;

&lt;p&gt;Token discovery surfaces have a verification problem that price feeds do not. A ticker is not an identifier. Anyone can deploy a contract whose &lt;code&gt;name()&lt;/code&gt; returns "Dai Stablecoin" and whose &lt;code&gt;symbol()&lt;/code&gt; returns "DAI", and nothing in the resulting search result distinguishes it from the real one. The contract address does, and the contract address is exactly the field that gets truncated in every list view.&lt;/p&gt;

&lt;p&gt;The usual answer is a blocklist of known scams, which is a race you lose: new contracts deploy faster than any list is maintained. The inverse works better. Instead of enumerating what is fake, enumerate what is canonical, keyed on &lt;code&gt;(ticker, chainId)&lt;/code&gt;, and flag a row that shares a ticker on the same chain with a different address.&lt;/p&gt;

&lt;p&gt;Building that check surfaced something more interesting than the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  One bytecode, two deployers
&lt;/h2&gt;

&lt;p&gt;Searching a handful of blue-chip tickers on DexScreener returned rows that looked like ordinary duplicates. They were not. Fourteen contracts on Ethereum mainnet impersonated nine well-known tokens and ran an identical playbook rather than being fourteen coincidences.&lt;/p&gt;

&lt;p&gt;Those fourteen turned out to be a rounding error. Enumerating every contract the two deployer wallets ever created returns &lt;strong&gt;1,031 deployments, 1,005 of which carry that same 5,150 byte bytecode&lt;/strong&gt;, split almost exactly evenly at 503 and 502. Running the detector described below across all 1,005 finds &lt;strong&gt;184 impersonating a canonical ticker outright, across 143 distinct real tokens&lt;/strong&gt;, plus 59 more cloning tickers that have several legitimate claimants. The remaining 762 use names no curated list carries, which is ordinary memecoin noise. That is still a floor: only two deployer wallets were enumerated.&lt;/p&gt;

&lt;p&gt;Every one of the fourteen returns the &lt;strong&gt;same 5,150 byte runtime bytecode&lt;/strong&gt;, SHA-256 prefix &lt;code&gt;7ff6feb2ee87face&lt;/code&gt;. The tokens they impersonate do not resemble each other at all: real DAI is 7,904 bytes, CRV 4,369, COMP 6,795, PEPE 4,517, every one distinct. Identical bytecode across fourteen supposedly unrelated tokens is a deployment fingerprint, not a coincidence.&lt;/p&gt;

&lt;p&gt;Blockscout's contract metadata names the source &lt;code&gt;MintBurnTeamToken&lt;/code&gt;, an off-the-shelf mintable template, and attributes all fourteen to just two externally owned accounts, split eight and six. No creation transaction is indexed for any of them, which is consistent with factory deployment instead of direct creation.&lt;/p&gt;

&lt;p&gt;The tickers spoofed: DAI, CRV, APE, SHIB and LDO twice each, plus COMP, ENS, GRT and SNX. Nine tickers, fourteen contracts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mirage: $3.28 billion from an initialization tick
&lt;/h2&gt;

&lt;p&gt;The impersonation is cheap and common. The reported liquidity is the interesting part. Take the fake DAI pool, &lt;code&gt;0x90e6...5a87&lt;/code&gt;, a Uniswap V3 pool at the 0.3% fee tier, and read its state directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;balanceOf(fakeDAI, pool)  = 2,506,024,274.84   // worthless token
balanceOf(WETH,    pool)  =         0.000739   // the entire real backing
slot0().tick              =          -75,180   // chosen at initialization
price(token1/token0)      =    0.0005434 WETH  // derived from that tick
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Uniswap V3 pool is initialized at whatever price its creator chooses, because that price is a tick rather than a consequence of deposited capital. A V3 position is then defined by a price &lt;em&gt;range&lt;/em&gt;, and that is the part that does the work here.&lt;/p&gt;

&lt;p&gt;The Mint event for this pool provides liquidity across the range &lt;code&gt;[-75180, 887220]&lt;/code&gt;. The lower bound is exactly the initialization tick, the price the pool still sits at today. The upper bound, 887220, is the highest tick the 0.3% fee tier permits, so the range runs from the chosen price to effectively infinite price. When the price sits at the very bottom of a range, the position is denominated entirely in token0 and requires none of token1 at all.&lt;/p&gt;

&lt;p&gt;So the deposit was &lt;strong&gt;2,506,024,276 minted tokens and 0.000000000 WETH&lt;/strong&gt;. Not a rounding error of WETH. None. The same structure appears on the CRV, SHIB and COMP pools, each opened with a single-sided position and zero WETH. The 0.000739 WETH sitting in this pool today did not come from the deployer; it is the accumulated dust of the daily cron buying its own token, a few thousandths of a cent at a time.&lt;/p&gt;

&lt;p&gt;DexScreener values those reserves at the pool's spot price and reports the product as liquidity:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2,506,024,274 tokens x $1.31 = $3,286,019,931 of "liquidity."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The displayed price rounds to $1.31, and reproducing the total to the dollar takes the unrounded $1.3112. That rounding is worth $3.1 million on its own, which is a fair illustration of the scale being discussed.&lt;/p&gt;

&lt;p&gt;The pool holds 0.000739 WETH, and that is a hard ceiling: it is the most anyone could ever extract by selling the token back into it, whatever the quoted price says. At $2,410.77 per ETH that ceiling is $1.78. The reported figure exceeds it by a factor of roughly 1.8 billion to one. Nothing is wrong with the arithmetic. The arithmetic is correct on inputs that were manufactured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fingerprint repeats, exactly
&lt;/h2&gt;

&lt;p&gt;What makes this tractable to detect is that the operation never varied its parameters. Every one of the six pools sampled shows the same signature: a large reported liquidity, one cent of reported 24 hour volume, and precisely one buy and zero sells. Re-read 24 hours later, on 2026-09-02, all six returned identical figures.&lt;/p&gt;

&lt;p&gt;That is not neglect. It is a schedule. The fake DAI pool's full swap history is 103 trades between 24 February and 2 September 2026, and in the last sixty days it traded on &lt;strong&gt;forty out of sixty days, thirty-seven of them between 04:00 and 04:59 UTC&lt;/strong&gt;. The other three sampled pools cluster in the same window. Each trade is dust: roughly 0.012 of the fake token out for about 0.0000066 WETH in, about one and a half cents.&lt;/p&gt;

&lt;p&gt;So the "one buy, $0.01 of volume" reading is not a stale cache and not a dead pool. It is a daily cron job buying a fraction of a cent of its own token, which is exactly enough to keep the pair looking traded to an indexer that sorts on recent activity. The listing is maintained.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ticker spoofed&lt;/th&gt;
&lt;th&gt;Pool created&lt;/th&gt;
&lt;th&gt;Reported liquidity&lt;/th&gt;
&lt;th&gt;24h volume&lt;/th&gt;
&lt;th&gt;Buys / sells&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DAI&lt;/td&gt;
&lt;td&gt;2026-02-24&lt;/td&gt;
&lt;td&gt;$3,286,019,931&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;1 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRV&lt;/td&gt;
&lt;td&gt;2026-02-28&lt;/td&gt;
&lt;td&gt;$431,462,383&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;1 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ENS&lt;/td&gt;
&lt;td&gt;2026-03-07&lt;/td&gt;
&lt;td&gt;$389,657,165&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;1 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LDO&lt;/td&gt;
&lt;td&gt;2026-02-28&lt;/td&gt;
&lt;td&gt;$212,719,545&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;1 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;COMP&lt;/td&gt;
&lt;td&gt;2026-03-08&lt;/td&gt;
&lt;td&gt;$130,141,448&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;1 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SHIB&lt;/td&gt;
&lt;td&gt;2026-03-08&lt;/td&gt;
&lt;td&gt;$42,017,195&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;1 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those six pools alone account for &lt;strong&gt;$4,492,017,667&lt;/strong&gt; of reported liquidity. All six were created between 24 February and 8 March 2026, and all six were still live, still listed, and still returned by ticker search on 1 September 2026, more than six months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the exposure is, and where it is not
&lt;/h2&gt;

&lt;p&gt;This is not evenly distributed across venues, and it would be sloppy to imply it is. A live ticker search for CRV in the Uniswap interface returns six genuine Curve deployments across six chains, plus yCRV and cvxCRV, and none of the fleet. Searching by contract address is a different matter: the picker resolves any address pasted into it, fleet contracts included. What the curated list governs is what surfaces when a user types a ticker, and none of the fourteen appear on the Uniswap Labs default list.&lt;/p&gt;

&lt;p&gt;The exposure sits on aggregators like DexScreener that index every pool by design, where comprehensiveness is the product and filtering is left to the reader. That is a stated design choice and not an oversight, which is precisely why the filtering has to come from somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting it without accusing the innocent
&lt;/h2&gt;

&lt;p&gt;The detector is a canonical allowlist keyed on &lt;code&gt;(ticker, chainId)&lt;/code&gt;, built from independently maintained token lists rather than hand entered: 2,289 tickers and 3,675 ticker and chain pairs. Two decisions carry most of the weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The chain key is not optional.&lt;/strong&gt; Keying on ticker alone flags every legitimately bridged token as an impersonator. The same real PEPE appears on seven chains, the same real CRV on six. A ticker only check would have called all of them fraudulent on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources are ranked, not pooled.&lt;/strong&gt; Curated lists (Uniswap Labs Default, 1inch) decide the canonical address. A permissive source (CoinGecko), which lists essentially every token with a market page, gets no vote on the address. It only reports how many other projects claim the same ticker on that chain. Treating all sources as equal peers deletes the tickers that matter most: PEPE, SHIB, UNI and APE all vanish, because a permissive list carries "Pepe King Prawn" and "El Sapo Pepe" alongside the real thing. Their presence is not evidence that real PEPE is ambiguous.&lt;/p&gt;

&lt;p&gt;That second signal then gates the accusation. A red "not the canonical contract" verdict is only issued when every source agrees the ticker has exactly one claimant on that chain. Where a ticker has several legitimate claimants, the verdict degrades to "more than one project uses this ticker, check the address yourself."&lt;/p&gt;

&lt;p&gt;That gate exists because the first version did not have it, and was wrong twice on a single page. It flagged Milady Cult Coin, its own project with several million in liquidity and trading since December 2024, for not being Cult DAO. It flagged the wojak token doing $202k a day for not being the Wojak Coin doing $5.5k a day, calling the more actively traded asset the fake. Neither was impersonating anything. Both tickers simply have two real claimants, and the canonical holder of a ticker is a curator's assignment rather than a fact. A measurement layer that cannot tell those two situations apart should not be issuing verdicts on either.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;p&gt;Nobody appears to have fallen for it, and that is worth stating precisely because it is stronger than the usual "no victim flow was traced". Reading every swap on four of the pools, the counterparty is always the wallet that deployed the token: 100 of 100 on the fake DAI pool, 99 of 99 on CRV, 93 of 93 on SHIB, 77 of 77 on COMP. In six months these pools have had &lt;strong&gt;no external participants at all&lt;/strong&gt;. The contracts are live, findable and misrepresented, and so far the only person trading them is the person who made them.&lt;/p&gt;

&lt;p&gt;Nor is "one operator" established. The two deployer wallets each service only their own tokens, and they are not linked. Both were touched early by the same address, which looked like a possible controller until it was read: it sends one identical 514 byte message to each of 2,996 distinct recipients, and the message decodes to a freelance designer advertising branding work to token deployers. It is unsolicited marketing, not a funding link.&lt;/p&gt;

&lt;p&gt;The two deployer accounts were not deanonymised. The &lt;code&gt;MintBurnTeamToken&lt;/code&gt; label comes from public contract metadata and was not independently source verified. Pool economics were measured on six pools and the daily schedule on four; the other 999 contracts are confirmed by bytecode and ticker, not by reading each one's pool.&lt;/p&gt;

&lt;p&gt;Every number above was read directly from Ethereum mainnet via &lt;code&gt;eth_getCode&lt;/code&gt;, &lt;code&gt;eth_call&lt;/code&gt;, pool state and swap logs, or from the public DexScreener and Blockscout APIs, on 2026-09-01 and 2026-09-02. Prices move and pools can be removed, which is why the measurements are timestamped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;Verification and indexing are different jobs, and conflating them scales badly. An indexer that reports what a pool says about itself is not lying. It is faithfully reporting manufactured inputs. The gap between reported and real is where the work is, and closing it takes ordinary discipline: read the underlying state directly, key your joins on identifiers instead of labels, and be willing to return "I do not know" instead of a confident wrong answer.&lt;/p&gt;

&lt;p&gt;Fabrication is getting cheaper. Seven ten-thousandths of an ETH bought a $3.28 billion listing that survived six months on a major discovery surface. Independent verification gets more valuable in exact proportion.&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>security</category>
      <category>ethereum</category>
      <category>datascience</category>
    </item>
    <item>
      <title>The Revert Rate That Lies: Why You Cannot Rank a DEX Metric Across Execution Models</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 02 Sep 2026 14:53:54 +0000</pubDate>
      <link>https://dev.to/andrewmaury/the-revert-rate-that-lies-why-you-cannot-rank-a-dex-metric-across-execution-models-k55</link>
      <guid>https://dev.to/andrewmaury/the-revert-rate-that-lies-why-you-cannot-rank-a-dex-metric-across-execution-models-k55</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/revert-rate-comparability.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A revert rate answers one question: how often did a swap fail on-chain? It reads like a clean reliability number, and a leaderboard that ranks venues by it looks like a fair reliability ranking. It is not one, because the number only means the same thing across venues that fail the same way.&lt;/p&gt;

&lt;p&gt;This is a short account of a measurement trap that is easy to fall into, hard to see once you have, and directly hostile to a benchmark that claims to be neutral. It is also a record of ClearTrace falling into it, shipping the mistake to production, and then building the fix into the pipeline so the invalid comparison cannot be made again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same number, two different measurements
&lt;/h2&gt;

&lt;p&gt;Start with how a swap actually fails, because that is what decides whether its failure is countable.&lt;/p&gt;

&lt;p&gt;On a router, the user signs and submits the swap. 1inch, Uniswap, KyberSwap, ParaSwap, and Odos all work this way. If the swap fails, it fails on-chain, ClearTrace sees it, and the revert rate is exactly what it looks like: the odds your transaction does not land.&lt;/p&gt;

&lt;p&gt;On a batch-auction venue like CoW Protocol, the user signs an order, not a transaction. A solver wins the batch and submits one settlement transaction for everyone in it. An order that cannot be filled, from a bad price, no solver, or expiry, simply never becomes a transaction. The user's failure is invisible on-chain by construction.&lt;/p&gt;

&lt;p&gt;On an intent venue like 1inch Fusion, the shape is the same. The user signs an intent, a third-party resolver fills and submits it, and an unfilled or expired intent leaves no trace on-chain.&lt;/p&gt;

&lt;p&gt;So the on-chain revert rate of a batch-auction or intent venue is structurally near zero, and it is near zero regardless of how reliably the venue actually fills. It is not measuring reliability. It is measuring settlement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The direction of the error is the dangerous part
&lt;/h2&gt;

&lt;p&gt;Measured live on Ethereum over the trailing seven days (September 2026), CoW's revert rate reads 0.53% and 1inch's Limit Order Protocol reads 0.56%, while the routers 1inch and KyberSwap read 1.11% and 1.75%. Sort that single column and the two off-chain venues sit at the top as the most reliable.&lt;/p&gt;

&lt;p&gt;That ordering is a category error, and the specific way it is wrong is what makes it corrosive. It flatters exactly the venues a third party can measure least, because their failures never reach the chain to be counted. A benchmark that systematically favors the venues it cannot see is not neutral, whatever its intentions. That is the fastest way to lose the one thing a third-party benchmark is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The discriminator is who submits, not where it settles
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is "off-chain settlement means no reverts." It is wrong in a way that matters. In classic request-for-quote, the market maker signs a quote, but the taker still submits the transaction carrying that signature. An expired quote or a short maker balance therefore does revert on-chain, in the user's own transaction. A venue like that is router-shaped for measurement even though its price came from off-chain.&lt;/p&gt;

&lt;p&gt;So the rule is not about where the price originated. It is a single question: does a user's failed swap become a transaction anyone can count? If yes, the venue is a router and its revert rate is comparable. If no, its revert rate is structurally near zero and is not a signal. If the answer is not established, or the venue runs more than one model, it is unclassified and never ranked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The taxonomy, and the guard that enforces it
&lt;/h2&gt;

&lt;p&gt;ClearTrace encodes those four models as one taxonomy that every consumer imports from a single module, so the rule cannot drift between the dashboard, the API, and the pre-send checks. A short excerpt:&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="c1"&gt;# Execution models
&lt;/span&gt;&lt;span class="n"&gt;ROUTER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BATCH_AUCTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INTENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UNCLASSIFIED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;router&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;batch_auction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;intent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unclassified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# The ONLY model whose tx-level revert rate measures user reliability, because it
# is the only one where the transaction that fails belongs to the user.
&lt;/span&gt;&lt;span class="n"&gt;REVERT_COMPARABLE_MODELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;ROUTER&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CrossModelComparison&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Raised when code tries to rank/compare revert rates across execution models.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rank_by_revert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revert_rate_pct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Rank venues by revert rate, best first, WITHIN ONE EXECUTION MODEL.
    Refuses to return a list that mixes models, because such a list ranks nothing.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;rankable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rankable&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_revert_comparable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_key&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;refused&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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;strict&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;CrossModelComparison&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refusing to rank revert rates across execution models: these venues do &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not submit the user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s transaction, so their on-chain revert rate is not &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comparable to a router&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rankable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rate_key&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;rankable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every published row is stamped with its &lt;code&gt;execution_model&lt;/code&gt; and a &lt;code&gt;revert_comparable&lt;/code&gt; flag. Those two fields are live on the public API today, so a downstream consumer, whether the ClearTrace dashboard or an outside script, can never read a batch-auction revert rate as reliability without being told otherwise. And a compare page does not print a suppressed head-to-head as a blank. It replaces the number with the reason: a sentence explaining that the two venues settle differently, that the off-chain venue's on-chain revert rate is therefore structurally near zero regardless of how reliably it fills, and that putting it beside a router's would overstate its reliability rather than measure it. The refusal is legible, not silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under-claiming is the default
&lt;/h2&gt;

&lt;p&gt;A venue ClearTrace has not established is unclassified, and unclassified never ranks on revert. Absence of evidence is not evidence. A venue that runs both models is also unclassified: Bebop settles taker-submitted quotes, where a failed fill reverts in the user's own transaction, and solver-submitted intents, where failures never land. A rate over that mixed population answers no question, so ClearTrace does not rank it until the two are split. The same discipline applies to a venue like Tokenlon, whose very low on-chain rate would top any reliability ranking, which is exactly why it needs evidence about who submits rather than an assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the guardrail exists
&lt;/h2&gt;

&lt;p&gt;None of this is hypothetical. ClearTrace once shipped the sentence "CoWSwap's revert rate is 0.04% versus ParaSwap's 1.41%" to production, in body copy and in indexed FAQ schema. That is a 35x reliability claim, and it was purely an artifact of settlement: CoW's number was structurally low because unfillable orders never became transactions, not because it executed 35 times more reliably than a router. A flattering wrong number costs a neutral benchmark its credibility exactly as a damaging one does. The taxonomy, the &lt;code&gt;CrossModelComparison&lt;/code&gt; guard, and a pre-send claim gate that blocks any cross-model revert claim before it leaves the building are the response to that specific mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it proves
&lt;/h2&gt;

&lt;p&gt;The rare skill here is not a model or a scraper. It is knowing which comparison is invalid and building the refusal into the pipeline, so a metric that looks universal is only ever computed over the population where it means something. Finding signal in adversarial data includes declining to manufacture signal that is not there, even when the invalid version would make the product look more complete.&lt;/p&gt;

&lt;p&gt;For a foundation, a grant program, or a protocol team spending real money against a metric, that is the difference between a dashboard and a source of record: a benchmark whose numbers survive contact with the teams they describe, because the one comparison that would embarrass it is blocked in code rather than caught in review.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The full case study is at &lt;a href="https://rantum.xyz/case-studies/revert-rate-comparability.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;. ClearTrace is at &lt;a href="https://cleartracedata.com" rel="noopener noreferrer"&gt;cleartracedata.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Andrew Maury is the founder of Rantum, a senior data science and ML studio that turns messy, fragmented, and adversarial data into models, APIs, and products that ship. He has 6+ years in crypto: he previously scaled data at 0x Labs, contributed to Dune's Spellbook, and advised the Uniswap Grants Program.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>defi</category>
      <category>ethereum</category>
      <category>datascience</category>
      <category>web3</category>
    </item>
    <item>
      <title>Where Did This Trade Come From? Attributing DEX Volume in Dune SQL</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Thu, 27 Aug 2026 15:14:59 +0000</pubDate>
      <link>https://dev.to/andrewmaury/where-did-this-trade-come-from-attributing-dex-volume-in-dune-sql-g43</link>
      <guid>https://dev.to/andrewmaury/where-did-this-trade-come-from-attributing-dex-volume-in-dune-sql-g43</guid>
      <description>&lt;p&gt;&lt;em&gt;This is an adapted excerpt from Chapter 6 of my book, &lt;a href="https://www.amazon.com/dp/B0H9GF88GR" rel="noopener noreferrer"&gt;The DeFi Data Quick-Start: Reading On-Chain Truth with Dune Analytics&lt;/a&gt;. The book goes from your first Dune query to a dashboard you can defend. This chapter is the hardest problem in it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one field that isn't there
&lt;/h2&gt;

&lt;p&gt;A row in &lt;code&gt;dex.trades&lt;/code&gt; tells you the chain, the venue, the tokens, the amounts, and the transaction hash. It tells you everything except the one field half the money in DeFi wishes existed: where the trade came from. No &lt;code&gt;origin: uniswap-web-app&lt;/code&gt;, no &lt;code&gt;frontend: metamask-swaps&lt;/code&gt;, nothing.&lt;/p&gt;

&lt;p&gt;That missing field is worth a great deal. Incentive programs, business development deals, and market-share claims all hinge on an answer nobody can read directly off the chain. Reconstructing it is the discipline called attribution, and it is mostly a matter of knowing where to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two identical rows, two different worlds
&lt;/h2&gt;

&lt;p&gt;Consider two rows from &lt;code&gt;dex.trades&lt;/code&gt;: same day, same pool, each a swap of 2 WETH for USDC minutes apart. In the curated table they are nearly indistinguishable.&lt;/p&gt;

&lt;p&gt;Behind the first is a person. She opened her wallet app, tapped the built-in swap button, and accepted a quote. Her wallet's backend called an aggregator, the aggregator picked the route, and the trade settled through a router into the pool. Four parties touched that flow, and if anyone deserves credit for bringing the user, it is the wallet whose button she tapped.&lt;/p&gt;

&lt;p&gt;Behind the second is a bot: a market-making program calling the pool contract directly. No frontend, no human, no quote, just an automated rebalancing loop.&lt;/p&gt;

&lt;p&gt;A volume dashboard counts these two rows identically. An incentive program that rewards "adoption" pays them identically. Attribution is the work of telling them apart, at scale, from public data alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious answers are wrong
&lt;/h2&gt;

&lt;p&gt;The chain gives you two address fields per transaction, and it pays to see exactly why neither answers the question.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tx_from&lt;/code&gt; is the wallet that signed the transaction. It tells you who traded, not which interface brought them. Ten thousand different &lt;code&gt;tx_from&lt;/code&gt; addresses might all be customers of a single wallet app.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tx_to&lt;/code&gt; is the first contract the transaction called. This is the field naive dashboards actually use, and it systematically credits the wrong layer. It names the router, the plumbing, not the frontend. Every trade through a given aggregator's contract shows the same &lt;code&gt;tx_to&lt;/code&gt;, whether the user came from the aggregator's own site, a wallet's swap tab, or an integration none of us has heard of. Attributing by &lt;code&gt;tx_to&lt;/code&gt; is like attributing retail sales to the delivery truck.&lt;/p&gt;

&lt;p&gt;The true origin, the app or interface where the user actually was, usually never appears on-chain as an address at all. It has to be inferred from the shape of the transaction. Different origins leave different fingerprints. There are four kinds in practice, and the first is nearly free when it is present.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the trade signs its own work
&lt;/h2&gt;

&lt;p&gt;Sometimes a transaction carries more than the trade itself. A few APIs append a few identifying bytes to the end of the call data: a tag the contract never reads, riding along to say "this order was built by integration X." The 0x API's affiliate suffix worked this way. Others pass the integrator as an explicit argument that the contract decodes, the way 1inch's referral parameter routes referral fees. Both land on-chain, permanently, in every block explorer.&lt;/p&gt;

&lt;p&gt;Where these tags exist, attribution is one join away: extract the marker, join to &lt;code&gt;dex.trades&lt;/code&gt; on the transaction hash, and map tags to names. Here is the appended-tag form as a runnable probe. It ranks the most common 16-byte call-data tails across a few hours of Ethereum DEX flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tails&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt;
        &lt;span class="n"&gt;bytearray_substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;bytearray_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="mi"&gt;16&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;calldata_tail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tx_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_usd&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trades&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
    &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;ethereum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;
        &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tx_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;
        &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'6'&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blockchain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ethereum'&lt;/span&gt;
        &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'6'&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;
        &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;bytearray_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt;
    &lt;span class="n"&gt;calldata_tail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;-- tags ride on transactions, not fills&lt;/span&gt;
    &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;distinct&lt;/span&gt; &lt;span class="n"&gt;tx_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;txs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_usd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;volume_usd&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tails&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;calldata_tail&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;x00000000000000000000000000000000&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;txs&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it and the tagging convention stares back at you. The query drops the all-zero tail up front, because that bucket is mostly untagged flow and call data that ends in zero-padded arguments. What remains is candidates. When I ran this in July 2026 while drafting the book, one high-frequency tail held the hex spelling of a short ASCII name with a counter beside it: an integrator marking its own orders in plain sight. Run the probe yourself and you will find it, or whoever is tagging this month.&lt;/p&gt;

&lt;p&gt;One caution before you attribute anything to a tail. It can also be the end of an ordinary function argument, since token addresses produce repeating fragments too. Treat a candidate tag as a hypothesis. Pull a handful of its transactions, check the entry contract, and confirm the pattern holds across several days before it earns a name.&lt;/p&gt;

&lt;p&gt;The real limit is the word voluntary. Tagging is a convention some APIs offer and some integrators use. It is not a standard, and it is not enforced. Absence of a tag is not evidence of absence of a frontend. A system that counts only tagged flow will undercount exactly the integrators who never opted in, and overstate the "direct" share by the same amount.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other three techniques, in brief
&lt;/h2&gt;

&lt;p&gt;When a trade carries no tag, three more techniques pick up the flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Known-contract registries.&lt;/strong&gt; Routers, aggregator entry points, wallet swap contracts, and custom proxies all have stable addresses. Label one once, and every past and future trade through it becomes attributable retroactively. A single proxy label can reclassify millions in volume. This is why &lt;a href="https://cleartracedata.com" rel="noopener noreferrer"&gt;ClearTrace&lt;/a&gt; tracks over 172,000 attributed contracts across Ethereum, Base, Arbitrum, and Optimism: the unlabeled long tail is where misattribution hides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace fingerprints.&lt;/strong&gt; The internal call tree has a shape. A protocol's own frontend produces a compact tree, a wallet-native swap adds a fee hop and an extra transfer, an aggregator split fans across several pools, and a bot calling the pool directly shows almost no tree at all. No single shape is definitive, but across thousands of transactions from one entry point they classify with high confidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fee-recipient clustering.&lt;/strong&gt; Most frontends skim a small fee to a collection address as part of the swap. That address is a signature: every trade paying the same recipient came through the same integration, named or not. I wrote a full walk-through of this one on its own, &lt;a href="https://dev.to/andrewmaury/who-got-paid-identifying-anonymous-dex-frontends-by-their-fee-recipient-50i8"&gt;Who Got Paid: Identifying Anonymous DEX Frontends by Their Fee Recipient&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part most dashboards skip
&lt;/h2&gt;

&lt;p&gt;The four techniques combine into a cascade, strongest evidence first. One sensible ordering: explicit call-data tag, then registry match, then trace fingerprint, then fee-recipient cluster. A real system tunes that order to its own confidence in each signal, and screens out known bot and MEV contracts before classifying anything. And then the step that matters most:&lt;/p&gt;

&lt;p&gt;If none of them fires, leave the trade unattributed.&lt;/p&gt;

&lt;p&gt;That is the design, not a failure mode. A neutral attribution system reports its unattributed share instead of forcing every trade into a bucket, and it treats that residual as a headline number about its own coverage. When you see an attribution chart that sums to a clean 100% with no "unknown" slice, you are looking at a method that either got very lucky or made something up. No serious system attributes everything; a real one measures its own coverage and reports the gap.&lt;/p&gt;

&lt;p&gt;So here is ClearTrace's gap, for the record. As of August 2026, roughly 10% of DEX volume across Ethereum, Base, Arbitrum, and Optimism sits in the unattributed bucket, ranging from 8% on Ethereum to 17% on Optimism. The shape underneath is the interesting part: nearly 99% of tracked contract rows are unnamed, while nearly 90% of volume is named. The head is easy. The long tail is the job.&lt;/p&gt;

&lt;p&gt;State the method, show the denominator, publish the residual.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://www.amazon.com/dp/B0H9GF88GR" rel="noopener noreferrer"&gt;The DeFi Data Quick-Start&lt;/a&gt; is on Amazon now, in Kindle and paperback. The book's runnable queries and a live dashboard are on the free companion page at &lt;a href="https://rantum.xyz/quickstart" rel="noopener noreferrer"&gt;rantum.xyz/quickstart&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>ethereum</category>
      <category>datascience</category>
      <category>dune</category>
    </item>
    <item>
      <title>Who Got Paid: Identifying Anonymous DEX Frontends by Their Fee Recipient</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Thu, 20 Aug 2026 17:40:08 +0000</pubDate>
      <link>https://dev.to/andrewmaury/who-got-paid-identifying-anonymous-dex-frontends-by-their-fee-recipient-50i8</link>
      <guid>https://dev.to/andrewmaury/who-got-paid-identifying-anonymous-dex-frontends-by-their-fee-recipient-50i8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/fee-recipient-attribution.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most swaps do not announce their origin. A meta-frontend, a wallet's in-app swap tab, and a Telegram trading bot can all route through the same public router, emit the same &lt;code&gt;Swap&lt;/code&gt; event, and leave calldata that looks generic. Three of ClearTrace's four attribution vectors work on routing evidence: was the entrypoint a known aggregator, did the user call a pool directly or through a proxy, is there a fingerprint appended to the calldata. When all three come up empty, the transaction is anonymous by every structural measure.&lt;/p&gt;

&lt;p&gt;But an anonymous frontend is usually still a business, and a business takes a cut. When that cut is taken inside the swap transaction itself, the way affiliate and referral skims are, it has to move on-chain where the receipt records it. That is the evidence this vector reads. A frontend that sweeps its revenue later, or monetizes off-chain entirely, leaves nothing here. And one dependency shapes everything downstream: finding who got paid is mechanical, but naming them requires a label somebody has already attached to that address.&lt;/p&gt;

&lt;p&gt;The problem is that the cut does not identify itself. There is no fee standard on Ethereum. Individual projects emit their own fee events, &lt;a href="https://github.com/lifinance/contracts/blob/main/src/Periphery/FeeCollector.sol" rel="noopener noreferrer"&gt;LiFi's collector contract&lt;/a&gt; logs a &lt;code&gt;FeesCollected&lt;/code&gt; event a decoder can subscribe to, but nothing generic marks one token movement as revenue and another as settlement across the long tail of routers this vector exists for. A frontend's revenue is an ordinary ERC-20 &lt;code&gt;Transfer&lt;/code&gt; log sitting in a receipt beside the swap's own transfers, structurally identical to them. You cannot decode it, because there is nothing to decode. You can only work out which transfers are &lt;em&gt;not&lt;/em&gt; part of the swap and see who is left.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: identification by exclusion puts every error in the exclusion set
&lt;/h2&gt;

&lt;p&gt;Turning "which transfer is the fee" into "which transfers are not the swap" is the move available here, and it relocates all of the risk into the exclusion list. Every category you forget to exclude becomes a false frontend. The user receiving their own output tokens is not a fee. The contract that was called is not a fee. A pool receiving the input side of the trade is not a fee. A token contract receiving its own token, which some fee-on-transfer designs do, is not a fee. A burn address is not a fee. Miss any one of those and the vector confidently reports a counterparty as the frontend that originated the trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: a fee sink is a signal in one direction only
&lt;/h2&gt;

&lt;p&gt;This one is not obvious until it breaks something. Once a candidate recipient is found, its identity comes from a resolved label, and the labels contain infrastructure names as well as product names. A contract labelled as a fee vault is strong evidence when it is &lt;em&gt;receiving&lt;/em&gt; the skim: something paid it, and that something is the monetization path you are trying to name. The same label on the contract the user &lt;em&gt;called&lt;/em&gt; means the opposite, or rather it means nothing, because a fee vault is not a user interface and treating it as one invents a frontend out of plumbing.&lt;/p&gt;

&lt;p&gt;So the same marker has to be a positive signal in one position and no signal in the other. In the taxonomy this is a single boolean, &lt;code&gt;as_fee_recipient&lt;/code&gt;, threaded through the name classifier to select which exclusion set applies. It is one flag standing in for a real asymmetry: evidence about who got paid does not transfer to evidence about who was called.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: getting paid is not the same as being the frontend
&lt;/h2&gt;

&lt;p&gt;Even a clean recipient with a clean label is indirect evidence. Referral programs pay addresses that belong to partners rather than to the interface. A single fee address can serve several products from the same team. An affiliate skim names whoever holds the affiliate relationship, which is often but not always the surface the user actually touched. The vector answers "who monetized this trade," and that is a strong hint about the frontend rather than a proof of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/attribution_kernel.py: the fee-recipient vector.
# ERC20 transfers to an address that is not the user, not the called
# contract, not a pool, not a token contract, and not a burn sink —
# i.e. a party skimming a fee out of the swap.
&lt;/span&gt;&lt;span class="n"&gt;excluded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tx_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tx_to&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;BURN_ADDRESSES&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;pools&lt;/span&gt;
&lt;span class="n"&gt;token_contracts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;fee_recipients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_amount&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_contracts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fee_recipients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;resolve_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_contract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw_amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# exact; no USD/decimals applied
&lt;/span&gt;    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions in that block are worth pulling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The amount is stored exactly and never converted.&lt;/strong&gt; &lt;code&gt;raw_amount&lt;/code&gt; is the integer from the transfer's data field, kept as a string, with no token decimals applied and no USD price attached. That looks like an omission and is a refusal. Converting it requires the token's decimals and a price at that block, and both are lookups that can fail quietly and produce a number that is wrong by orders of magnitude while still looking plausible in a table. Attribution does not need the fee's value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recipients are deduplicated within a transaction.&lt;/strong&gt; A single address can receive several transfers in one swap, on a multi-hop route or when both sides of a trade pay the same collector. Reporting it repeatedly would imply several fee events where there was one relationship.&lt;/p&gt;

&lt;h2&gt;
  
  
  The confidence tier, and the bug that set it
&lt;/h2&gt;

&lt;p&gt;A matched recipient is handed to the frontend taxonomy, which resolves the label into a kind (wallet, frontend, MEV bot, or nothing) and returns a bucket with an explicit confidence. A calldata-suffix match returns &lt;code&gt;high&lt;/code&gt;. A fee-recipient match returns &lt;code&gt;medium&lt;/code&gt;, always, by construction: indirect but specific, which is exactly what an affiliate payment is.&lt;/p&gt;

&lt;p&gt;The tiering earns its keep, and the repository records what happened when the name matching underneath it was too loose. An earlier version treated &lt;code&gt;0x&lt;/code&gt; as a plain substring marker for the 0x protocol. But &lt;code&gt;0x&lt;/code&gt; is also the prefix of every hex address, and the labeler routinely embeds addresses in names, rendering them as &lt;code&gt;Wintermute (0x51c7…8ac2)&lt;/code&gt; or emitting heuristic labels like &lt;code&gt;Ethereum First Funder: 0xa4aF…&lt;/code&gt;. Every such label matched. The comment in the source states the damage plainly: any suffix-tagged transaction carrying any non-MEV, non-wallet label was booked as a named frontend at high confidence. One loose substring rule silently promoted a large class of unknowns into confident, named, wrong attributions.&lt;/p&gt;

&lt;p&gt;The fix was a stricter entity pattern, but the durable lesson is about tiering. A system that only emits identities cannot be audited after a matching bug. A system that emits identities with the evidence class attached lets you find every attribution that rested on the broken rung and re-evaluate exactly those.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under-claiming, in code
&lt;/h2&gt;

&lt;p&gt;The taxonomy sorts every transaction into one of five buckets: named frontend, aggregator API direct, wallet embedded, bots and MEV, or unattributed. The interesting case is a transaction that carries a calldata fingerprint proving some frontend tagged it, where the identity cannot be resolved. The obvious move is to guess from the router. The implemented move is to return &lt;code&gt;unattributed&lt;/code&gt; with a &lt;code&gt;fingerprinted&lt;/code&gt; flag set, so the leaderboard can show "fingerprinted, unidentified" as its own quantity rather than folding it into either a named frontend or the raw unknown pile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one real run returns
&lt;/h2&gt;

&lt;p&gt;Method pieces are cheap without a run, so here is one: the 50 most recent Ethereum swaps in ClearTrace's synced &lt;code&gt;dex.trades&lt;/code&gt; sample, a seven-minute window from 2026-07-01, receipts refetched from an archive node on 2026-08-20 and pushed through the kernel in receipt-only mode with ClearTrace's own label store answering &lt;code&gt;resolve_name&lt;/code&gt;. All 50 classified. On 39, at least one transfer survived the five exclusions. The calldata-suffix vector settled 23, the aggregator and bot rungs took four each, fourteen resolved nothing, and the fee vector decided the final attribution on five.&lt;/p&gt;

&lt;p&gt;Then we inspected the five, and this is the part that earns the confidence tier. Every one rested on a label attached to routing infrastructure rather than to a monetizing interface. Two resolved through an adapter contract whose name contains a venue string, so the frontend marker matched plumbing. The three we dissected transfer by transfer were not fees at all. In a $4,165 stablecoin swap routed through TransitSwapRouterV5, the surviving recipients were a market-making counterparty that received 2.655 WETH and paid back 4,165 USDC, a routing contract that both received and forwarded funds, and an address carrying an aggregator label that received the full trade notional. The taxonomy walked the list, hit the aggregator label, and booked a named frontend at medium confidence. In a $172k swap, three surviving recipients received the identical amount, which is not three fees, it is the notional in flight.&lt;/p&gt;

&lt;p&gt;So the run's honest summary: in receipt-only mode, the exclusion set cannot tell a fee from a hop. Everything in a multi-hop route that is not the user, the entry contract, a pool, a token contract, or a burn survives, wearing whatever label it has. The vector found the route, not the revenue. The structural fix is known and not yet built: exclude every address that also &lt;em&gt;sends&lt;/em&gt; within the same receipt, or use trace data to remove the whole call path. Neither exists in the kernel today. This run is the argument for building both.&lt;/p&gt;

&lt;p&gt;What the tier system did do is exactly its job. All five wrong names went out the door marked medium and &lt;code&gt;fee_recipient&lt;/code&gt;, which means one query finds every attribution resting on this rung, and re-evaluating them after the exclusion fix touches nothing booked on suffix or aggregator evidence. An untiered system would have published the same five names as flat facts and left no seam to find them by.&lt;/p&gt;

&lt;p&gt;And that is also the honest answer to where this rung lives in production until the fix lands. The receipt-only kernel serves single transactions on demand, with the method and confidence in every response, and feeds no leaderboard. The vector's SQL twin, which does feed the aggregate dashboards, already treats fee-recipient volume as an overlay rather than as truth: the query credits the full trade amount to every qualifying intermediate, reports the result as its own column, and never folds it into the conserved volume total, with a comment in the repo measuring the over-crediting it would otherwise cause at roughly 80% of an Ethereum total. The rung stays because a quarantined hint is still a hint; nothing it produces reaches a published total.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not claim
&lt;/h2&gt;

&lt;p&gt;It does not cluster frontends by fee rate. The obvious next step from "who got paid" is "how much, as a rate," on the theory that a consistent basis-point cut would group several anonymous routers under one operator. That is not implemented, and the reason is in the design above: the vector deliberately holds the amount as a raw integer, so a rate would require the decimals-and-price conversion it exists to avoid, applied to both the fee and the trade notional, before any two swaps could be compared. A fee rate is also weak identity evidence in a way an address is not: interface fees cluster around a handful of conventional sizes, so a rate is a habit shared across unrelated products, where an address belongs to exactly one operator. The address is the identifier.&lt;/p&gt;

&lt;p&gt;It also inherits every limit of the labeling layer beneath it. The vector can isolate a recipient cleanly and still return nothing, because there is no label for that address, and the run above adds the sharper failure: when the store's labels are attached to infrastructure, or simply wrong, the vector inherits those errors at medium confidence. Whatever it names, it names because someone already labelled the collector, so on genuinely anonymous frontends it can only surface what the label store already holds, and on this sample it correctly named none. And the exclusion set is a fixed list of five categories, not a learned classifier: a fee routed through an intermediate contract that resembles a pool, or paid in a later transaction rather than inside the swap, is invisible to this pass by construction.&lt;/p&gt;

&lt;p&gt;Nothing in a swap receipt is labelled as revenue. The fee is legible only as the transfer left over once everything structural has been ruled out, so the exclusions are the entire product, and every category they miss walks into the output wearing a name. The run above is that account for this vector, published rather than filed.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full case study, including the taxonomy cascade and what this proves to a client, is at &lt;a href="https://rantum.xyz/case-studies/fee-recipient-attribution.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;. ClearTrace is at &lt;a href="https://cleartracedata.com" rel="noopener noreferrer"&gt;cleartracedata.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>ethereum</category>
      <category>python</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
