<?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>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>
    <item>
      <title>Reconstructing Wallet Journeys Across DEX Frontends, One Chain at a Time</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 05 Aug 2026 16:04:07 +0000</pubDate>
      <link>https://dev.to/andrewmaury/reconstructing-wallet-journeys-across-dex-frontends-one-chain-at-a-time-33kn</link>
      <guid>https://dev.to/andrewmaury/reconstructing-wallet-journeys-across-dex-frontends-one-chain-at-a-time-33kn</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/multichain-journey-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;A blockchain records what happened, but not the story that connects it. Every DEX trade is a row in a wide, flat table: an address, a venue, a timestamp, an amount. There is no session, no user ID, no ordered clickstream. So when someone asks an ordinary product question, "after a user swaps on our frontend, where do they go next, and where did they come from?", the ordering that a journey is made of does not exist as a column. It has to be reconstructed.&lt;/p&gt;

&lt;p&gt;Reconstructing it with window functions is the easy part, and it is where most write-ups on this stop. The work that decides whether the output means anything happens before the first &lt;code&gt;LAG&lt;/code&gt;, in three places where the raw data will happily produce a confident, wrong answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: a leg is not a trade
&lt;/h2&gt;

&lt;p&gt;Dune's &lt;code&gt;dex.trades&lt;/code&gt; stores one row per swap leg. A single aggregator route through three pools writes three rows for one user action. Sequence those rows directly and you manufacture hops the user never made, several within the same second, separated by a zero-minute gap.&lt;/p&gt;

&lt;p&gt;ClearTrace measured this leg inflation at 2.15x on Odos/Ethereum while building its attribution engine, which is why every volume path in that engine collapses to one row per transaction before any arithmetic. A journey query needs the same collapse for the same reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the busiest wallets are not users
&lt;/h2&gt;

&lt;p&gt;On an unfiltered transition count, MEV searchers and arbitrage bots take the entire top of the table. They hop venues thousands of times a day, which is exactly the behaviour the query is built to surface, and they are not people whose journey anyone wants to understand. Any transition map published without a bot guard is largely a map of arbitrage routing wearing the label "user behaviour."&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: the chains are separate timelines
&lt;/h2&gt;

&lt;p&gt;It is tempting to run one query over every chain at once and call the output a cross-chain journey. &lt;code&gt;dex.trades&lt;/code&gt; spans all chains, so partitioning by wallet without a chain filter interleaves that wallet's Ethereum, Base, Arbitrum, and Optimism swaps into one sequence, then reports the gaps between unrelated chains as dwell time.&lt;/p&gt;

&lt;p&gt;The wallet did not travel from a Base entrypoint to an Arbitrum one, because a bridge, a different key, and a different session sat in between. The honest unit of reconstruction is one wallet on one chain. Cross-chain behaviour is a separate problem, and answering it takes bridge-level evidence that a timestamp sort cannot supply.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the query does
&lt;/h2&gt;

&lt;p&gt;A per-chain reconstruction with the guards in front of the window function. It resolves the &lt;em&gt;entrypoint&lt;/em&gt; of each trade (the router or frontend contract the transaction was sent to) rather than the venue it settled on, collapses legs to one row per transaction, drops dust and implausible wallets, then lets each remaining swap read its own predecessor. The output is a ranked edge list: for every ordered pair of entrypoints, how many distinct wallets crossed, how many times, and the median wait in between.&lt;/p&gt;

&lt;p&gt;One detail worth naming: &lt;code&gt;project&lt;/code&gt; in &lt;code&gt;dex.trades&lt;/code&gt; is the DEX venue (Uniswap, Curve), not the interface a user touched. Building hops from it reports venue routing as user behaviour. Resolving &lt;code&gt;tx_to&lt;/code&gt; against the chain's decoded contracts, and leaving anything unresolved as &lt;code&gt;Unknown Proxy&lt;/code&gt;, keeps the map about interfaces and keeps the unknowns visible instead of silently absorbed into a named venue.&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="c1"&gt;-- Dune SQL (Trino) query for the Multi-Hop Origin Trace (Vector #3)&lt;/span&gt;
&lt;span class="c1"&gt;-- Reconstructs each wallet's ordered path between ENTRYPOINTS (the router or&lt;/span&gt;
&lt;span class="c1"&gt;-- frontend contract a swap was sent to) and rolls the hops up into a ranked&lt;/span&gt;
&lt;span class="c1"&gt;-- transition map: which entrypoint feeds which, for how many distinct wallets,&lt;/span&gt;
&lt;span class="c1"&gt;-- and how long they wait before the next swap.&lt;/span&gt;
&lt;span class="c1"&gt;--&lt;/span&gt;
&lt;span class="c1"&gt;-- SCOPE IS ONE CHAIN PER RUN. dex.trades spans every chain, so an unfiltered&lt;/span&gt;
&lt;span class="c1"&gt;-- PARTITION BY tx_from interleaves a wallet's Ethereum, Base, Arbitrum and&lt;/span&gt;
&lt;span class="c1"&gt;-- Optimism swaps into a single timeline that never happened, and reports the&lt;/span&gt;
&lt;span class="c1"&gt;-- gaps between them as user dwell time. {CHAIN} is substituted per chain the&lt;/span&gt;
&lt;span class="c1"&gt;-- same way queries/master_attribution_query_template.sql is templated&lt;/span&gt;
&lt;span class="c1"&gt;-- (ethereum | base | arbitrum | optimism); {{Params}} are Dune runtime params.&lt;/span&gt;

&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;base_trades&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;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;tx_from&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;block_time&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="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;-- The entrypoint is the contract the user's transaction was sent TO.&lt;/span&gt;
        &lt;span class="c1"&gt;-- Resolve it to a name the same way queries/sankey_routing_edges.sql&lt;/span&gt;
        &lt;span class="c1"&gt;-- does, and leave it as 'Unknown Proxy' when nothing resolves it rather&lt;/span&gt;
        &lt;span class="c1"&gt;-- than substituting the venue it happened to settle on.&lt;/span&gt;
        &lt;span class="c1"&gt;--&lt;/span&gt;
        &lt;span class="c1"&gt;-- NOTE: t.project is the DEX VENUE (uniswap, curve), NOT the interface&lt;/span&gt;
        &lt;span class="c1"&gt;-- that originated the trade. Building hops out of t.project reports&lt;/span&gt;
        &lt;span class="c1"&gt;-- venue routing as if it were user behaviour: a single aggregator route&lt;/span&gt;
        &lt;span class="c1"&gt;-- that touches three pools looks like a user visiting three frontends.&lt;/span&gt;
        &lt;span class="k"&gt;REPLACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Unknown Proxy'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'{CHAIN}: '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&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;entrypoint&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;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;CHAIN&lt;/span&gt;&lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="n"&gt;contracts&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="o"&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_to&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;'{CHAIN}'&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;'{{Days_Back}}'&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;per_tx&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;-- Collapse legs to ONE row per transaction before any sequencing.&lt;/span&gt;
    &lt;span class="c1"&gt;-- dex.trades stores one row PER SWAP LEG; a multi-hop route prices several&lt;/span&gt;
    &lt;span class="c1"&gt;-- legs for ONE user trade (measured at 2.15x inflation on Odos/ethereum,&lt;/span&gt;
    &lt;span class="c1"&gt;-- see master_attribution_query_template.sql). Sequencing raw legs invents&lt;/span&gt;
    &lt;span class="c1"&gt;-- transitions the user never made, several per second, separated by a&lt;/span&gt;
    &lt;span class="c1"&gt;-- 0-minute gap. tx_to is the transaction's `to` field, so the entrypoint is&lt;/span&gt;
    &lt;span class="c1"&gt;-- constant per tx and one row survives the collapse.&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&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;tx_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;entrypoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block_time&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;block_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;MAX&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;notional_usd&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;base_trades&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="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;eligible_wallets&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;-- Bot and dust guard. An unfiltered transition count is dominated by MEV&lt;/span&gt;
    &lt;span class="c1"&gt;-- and arbitrage wallets that hop venues thousands of times a day. That is&lt;/span&gt;
    &lt;span class="c1"&gt;-- real on-chain activity, but it is not a user journey, and left in it sets&lt;/span&gt;
    &lt;span class="c1"&gt;-- the entire top of the table. Drop dust trades first, then drop any wallet&lt;/span&gt;
    &lt;span class="c1"&gt;-- whose swap count over the window is implausible for a human.&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tx_from&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;per_tx&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;notional_usd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;min_notional_usd&lt;/span&gt;&lt;span class="p"&gt;}}&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;HAVING&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;max_swaps_per_wallet&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;ordered_swaps&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;-- The reconstruction: partition by wallet, order by time, and let each swap&lt;/span&gt;
    &lt;span class="c1"&gt;-- read its own predecessor. tx_hash breaks ties so two swaps in the same&lt;/span&gt;
    &lt;span class="c1"&gt;-- block order deterministically instead of shuffling between runs.&lt;/span&gt;
    &lt;span class="c1"&gt;--&lt;/span&gt;
    &lt;span class="c1"&gt;-- LAG alone is enough for the transition map: every ordered pair in a&lt;/span&gt;
    &lt;span class="c1"&gt;-- wallet's path is emitted once as (previous -&amp;gt; current), so adding LEAD&lt;/span&gt;
    &lt;span class="c1"&gt;-- would restate the same edges in the opposite direction and double-count&lt;/span&gt;
    &lt;span class="c1"&gt;-- them. LEAD is the same primitive for a forward-looking per-wallet view.&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="n"&gt;p&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entrypoint&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;current_entrypoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entrypoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tx_from&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;previous_entrypoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tx_from&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;block_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;previous_swap_time&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;per_tx&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;eligible_wallets&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tx_from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tx_from&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notional_usd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;min_notional_usd&lt;/span&gt;&lt;span class="p"&gt;}}&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;previous_entrypoint&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;from_entrypoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;current_entrypoint&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;to_entrypoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;-- Distinct wallets is the honest headline: COUNT(*) alone lets one wallet&lt;/span&gt;
    &lt;span class="c1"&gt;-- that crosses the same pair 500 times read as 500 users.&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_from&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;wallets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;transitions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;-- Median, not mean: one wallet returning three weeks later drags an average&lt;/span&gt;
    &lt;span class="c1"&gt;-- dwell time far past anything a typical user did.&lt;/span&gt;
    &lt;span class="n"&gt;APPROX_PERCENTILE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;DATE_DIFF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'minute'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous_swap_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block_time&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="mi"&gt;5&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;median_gap_mins&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ordered_swaps&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;previous_entrypoint&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
    &lt;span class="c1"&gt;-- A journey is movement between entrypoints; ignore consecutive self-swaps.&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;current_entrypoint&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;previous_entrypoint&lt;/span&gt;
    &lt;span class="c1"&gt;-- Minimum dwell. A wallet that lands on a second entrypoint inside the same&lt;/span&gt;
    &lt;span class="c1"&gt;-- minute did not decide anything; that is automation, and on ethereum/7d it&lt;/span&gt;
    &lt;span class="c1"&gt;-- is what a swap-count cap alone leaves behind -- the top of the unfiltered&lt;/span&gt;
    &lt;span class="c1"&gt;-- table came back as near-symmetric A-&amp;gt;B / B-&amp;gt;A pairs at a 0-minute median&lt;/span&gt;
    &lt;span class="c1"&gt;-- (pool-to-router and back), which is the shape of in-block bot activity&lt;/span&gt;
    &lt;span class="c1"&gt;-- rather than navigation. Costs the fastest legitimate users; worth it.&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;DATE_DIFF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'minute'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous_swap_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;min_gap_mins&lt;/span&gt;&lt;span class="p"&gt;}}&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="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="c1"&gt;-- Suppress edges thin enough to be one wallet's habit rather than a pattern.&lt;/span&gt;
&lt;span class="k"&gt;HAVING&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_from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;min_wallets&lt;/span&gt;&lt;span class="p"&gt;}}&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;wallets&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why distinct wallets and a median
&lt;/h2&gt;

&lt;p&gt;Two small choices at the end carry most of the interpretive weight.&lt;/p&gt;

&lt;p&gt;Counting rows lets a single wallet that crosses the same pair five hundred times read as five hundred users, so the headline column counts distinct wallets and keeps the raw event count beside it as a separate, clearly labelled number. And dwell time is a long-tailed distribution: one wallet returning three weeks later drags a mean far past anything typical, so the query reports a median. Neither choice makes the chart look better, and both make it hold up when someone checks it.&lt;/p&gt;

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

&lt;p&gt;Run against Ethereum on 5 August 2026 over a 7-day window, with a $100 minimum trade size, wallets capped at 50 swaps for the week, a one-minute minimum gap between hops, and edges suppressed below 20 wallets. The query returned 103 entrypoint-to-entrypoint edges covering 19,518 hops in about seven seconds. These are the ten largest by distinct wallets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From entrypoint&lt;/th&gt;
&lt;th&gt;To entrypoint&lt;/th&gt;
&lt;th&gt;Wallets&lt;/th&gt;
&lt;th&gt;Hops&lt;/th&gt;
&lt;th&gt;Median gap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Unknown Proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UniversalRouter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;935&lt;/td&gt;
&lt;td&gt;1,233&lt;/td&gt;
&lt;td&gt;2h 39m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UniversalRouter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unknown Proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;893&lt;/td&gt;
&lt;td&gt;1,165&lt;/td&gt;
&lt;td&gt;3h 39m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Unknown Proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Router02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;765&lt;/td&gt;
&lt;td&gt;1,278&lt;/td&gt;
&lt;td&gt;2h 30m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Unknown Proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UniswapV2Factory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;762&lt;/td&gt;
&lt;td&gt;1,216&lt;/td&gt;
&lt;td&gt;2h 14m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UniswapV2Factory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unknown Proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;720&lt;/td&gt;
&lt;td&gt;1,210&lt;/td&gt;
&lt;td&gt;3h 49m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Router02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unknown Proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;716&lt;/td&gt;
&lt;td&gt;1,176&lt;/td&gt;
&lt;td&gt;3h 19m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UniswapV2Factory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Router02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;628&lt;/td&gt;
&lt;td&gt;1,112&lt;/td&gt;
&lt;td&gt;2h 14m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Router02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UniswapV2Factory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;619&lt;/td&gt;
&lt;td&gt;1,104&lt;/td&gt;
&lt;td&gt;2h 12m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FWA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UniversalRouter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;292&lt;/td&gt;
&lt;td&gt;506&lt;/td&gt;
&lt;td&gt;6h 50m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Unknown Proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DexRouter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;285&lt;/td&gt;
&lt;td&gt;379&lt;/td&gt;
&lt;td&gt;3h 29m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Names are Dune's decoded contract labels, verbatim. Several describe a contract rather than a product (&lt;code&gt;Router02&lt;/code&gt;, &lt;code&gt;UniswapV2Factory&lt;/code&gt;), a useful reminder that a decoded name is not a brand; mapping those to the interface a user actually touched is a separate entity-resolution problem, and it is why &lt;code&gt;Unknown Proxy&lt;/code&gt; is left standing here instead of being guessed at.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bot guard earning its place
&lt;/h3&gt;

&lt;p&gt;An earlier pass of the same run used only a swap-count cap (100 for the week) and no minimum gap. It returned 115 edges and 34,674 hops, and the top of the table looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;UniswapV2Factory&lt;/code&gt; -&amp;gt; &lt;code&gt;Router02&lt;/code&gt;, 2,269 wallets, median gap &lt;strong&gt;0 minutes&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Router02&lt;/code&gt; -&amp;gt; &lt;code&gt;UniswapV2Factory&lt;/code&gt;, 2,265 wallets, median gap &lt;strong&gt;0 minutes&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A near-perfectly symmetric pair, in both directions, with no time passing between the hops. That is not a person moving between two interfaces; it is automated in-block activity, and a swap-count cap alone never caught it because each individual wallet stayed under the threshold. Across the whole first pass, edges with a zero-minute median carried 40% of all hops. Adding the one-minute floor removed them entirely, and that same Uniswap pair fell to 628 wallets at a 2h 14m median, which is a number that behaves like a person deciding something.&lt;/p&gt;

&lt;p&gt;The other thing the run says plainly: 58% of the surviving hops still touch an &lt;code&gt;Unknown Proxy&lt;/code&gt; on one side. Journey reconstruction inherits whatever the labeling layer knows, and on this window the largest single destination in DEX navigation is a contract nobody has named yet.&lt;/p&gt;

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

&lt;p&gt;The table above is one query execution on one chain over one week, which is enough to demonstrate the method and not enough to characterise anybody's product. A single seven-day window on Ethereum says nothing about seasonality, nothing about the other three chains, and nothing about any named venue's quality.&lt;/p&gt;

&lt;p&gt;Two more limits worth stating. Entrypoint resolution inherits whatever the chain's decoded contract set knows, so a genuinely unresolved router stays &lt;code&gt;Unknown Proxy&lt;/code&gt; and appears in the map as an unknown node, which on this run is most of the map. And the bot guards are heuristic thresholds, not a classifier: a swap-count cap and a minimum dwell remove wallets whose behaviour is implausible for a person, and they will also drop the fastest legitimate power users along the way. Every threshold is a query parameter, so a reader can move them and watch the answer change, which is why they are exposed at all. The 40% figure above is exactly what moving one of them cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this generalises
&lt;/h2&gt;

&lt;p&gt;The technique is about ordering, not about blockchains. Wherever records exist but the sequence connecting them does not (wallets, users, devices, transactions), the same window-function pass reconstructs it, and the same three traps decide whether the resulting funnel describes customers or machines. Collapse the rows that represent one action, exclude the automated actors, and keep separate timelines separate. The reconstruction is the easy half.&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. Recent work: &lt;a href="https://cleartracedata.com" rel="noopener noreferrer"&gt;ClearTrace&lt;/a&gt; (neutral on-chain execution intelligence) and AddressIntel (predictive real-estate intelligence).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>sql</category>
      <category>blockchain</category>
      <category>analytics</category>
    </item>
    <item>
      <title>The Dashboard That Lied: Why DeFi Volume Isn't the Same as Adoption</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Tue, 28 Jul 2026 14:36:39 +0000</pubDate>
      <link>https://dev.to/andrewmaury/the-dashboard-that-lied-why-defi-volume-isnt-the-same-as-adoption-2l6c</link>
      <guid>https://dev.to/andrewmaury/the-dashboard-that-lied-why-defi-volume-isnt-the-same-as-adoption-2l6c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is an adapted excerpt from the opening of my new 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;. If it makes you want to run the queries yourself, that is the whole idea.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The dashboard that lied
&lt;/h2&gt;

&lt;p&gt;Picture a scenario that plays out somewhere in DeFi every quarter.&lt;/p&gt;

&lt;p&gt;The foundation behind an L2 launches an incentive program: trade on our network, earn rewards proportional to your volume. Six weeks in, the growth dashboard looks spectacular. Daily DEX volume has tripled, unique trading wallets are up 40%, and someone drafts a blog post about "organic adoption."&lt;/p&gt;

&lt;p&gt;Then someone on the data team pulls the underlying trades. A third of the new volume traces back to a few dozen wallets trading the same two tokens back and forth with each other, dozens of times an hour, at sizes calibrated to just clear the rewards threshold. Another slice is bot flow whose activity scales with volume mechanically. Much of what remains routes through aggregators, so nobody can actually say which frontend, if any, deserves credit for bringing the user.&lt;/p&gt;

&lt;p&gt;The chart was not wrong. Every transaction on it really happened, and every one is publicly verifiable by anyone with a block explorer. The chart was still a lie, because "volume" was standing in for "adoption," and on-chain those are very different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transparency paradox
&lt;/h2&gt;

&lt;p&gt;Blockchain data is strange: it is at once the most transparent dataset in finance and one of the easiest to misread.&lt;/p&gt;

&lt;p&gt;In traditional markets, data arrives pre-interpreted. An exchange reports volume through regulated channels, an auditor signs the books, a vendor cleans and labels the feed before you see it. You trust a chain of institutions, and in exchange the numbers come with meaning attached.&lt;/p&gt;

&lt;p&gt;On-chain, the trust model is inverted. You do not have to trust anyone, because every transaction is public, permanent, and independently verifiable. But nothing arrives labeled. The chain records what happened with perfect fidelity and says almost nothing about why it happened or who was really behind it. It is a flawless court stenographer that transcribes every word and identifies none of the speakers.&lt;/p&gt;

&lt;p&gt;The specific gap that matters: when a swap executes on a decentralized exchange, no standard field announces where the trade came from. Nothing says "this user came from the Uniswap web app" or "this was a wallet's built-in swap feature" or "this is a market-making bot talking directly to the contract." Everything except the raw movement of tokens has to be inferred.&lt;/p&gt;

&lt;p&gt;Skip that inference and just count, and three kinds of flow masquerade as adoption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wash trading&lt;/strong&gt; is a trade with yourself: one entity controls both sides and swaps an asset back and forth to be counted trading, not to trade. Anywhere volume is rewarded, and round-tripping costs a fraction of a cent, washing becomes a yield strategy and people industrialize it. A volume counter cannot tell it from real demand, because it counts every leg the same.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy routing&lt;/strong&gt; is subtler, because the trades are usually real. Most DEX flow passes through aggregators, routers, wallet swap buttons, and custom proxy contracts before it lands in a pool. A naive dashboard sees the router as the trader, credits the wrong layer, and can tally the same order two or three times as it moves through the stack. The interface that actually brought the user is often invisible on-chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MEV-farm flow&lt;/strong&gt; is the perverse one. In a sandwich attack a bot buys right before a user's swap, lets the user execute at a worse price, and sells right after. Three transactions land where there was one real trade. A single instance of a user being exploited registers as a tripling of activity, so a venue with heavy sandwich activity shows inflated volume precisely because its users are getting worse execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  What neutral measurement actually requires
&lt;/h2&gt;

&lt;p&gt;If dashboards can be this wrong on data this transparent, honest measurement takes four commitments. They sound obvious. Few published DeFi metrics survive all four.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Independence from the measured party.&lt;/strong&gt; The methodology is not controlled by anyone with a stake in the result. That is the difference between an audit and a press release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stated, reproducible methodology.&lt;/strong&gt; Which trades counted, which were excluded and why, how origin was assigned, what the denominator is. On-chain, anyone can re-run the query and check your work, so faith is not required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same ruler for everyone.&lt;/strong&gt; One standard across every venue, even when the result is unflattering, measured against an external baseline rather than each venue's self-reported one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest denominators.&lt;/strong&gt; State the base rate and pick the denominator before seeing which one tells the better story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the real sense in which on-chain data is different. Not that it is harder to misread than other data, but that for once you can check. The book exists to make you the person who checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the book does with it
&lt;/h2&gt;

&lt;p&gt;The DeFi Data Quick-Start is short and hands-on. In about a hundred pages it goes from your first query against live DEX trades to a dashboard you can defend: navigating the data stack from raw nodes to curated tables, attributing volume to the frontend that actually originated it, detecting sandwich attacks from in-block ordering, and measuring execution quality against a neutral price baseline. Every query in it was run against live on-chain data. The methodology draws on years building data infrastructure at 0x Labs and on ClearTrace, the execution-intelligence system we built to attribute DEX flow across Ethereum, Base, Arbitrum, and Optimism.&lt;/p&gt;

&lt;p&gt;If you want to stop taking on-chain numbers at face value, that is what it is for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DeFi Data Quick-Start: Reading On-Chain Truth with Dune Analytics&lt;/strong&gt; is &lt;a href="https://www.amazon.com/dp/B0H9GF88GR" rel="noopener noreferrer"&gt;on Amazon&lt;/a&gt; in Kindle and paperback, and more is on the &lt;a href="https://www.amazon.com/author/andrewmaury" rel="noopener noreferrer"&gt;author page&lt;/a&gt;. The book's queries, runnable and forkable, plus a live dashboard, are free on the companion page at &lt;a href="https://rantum.xyz/quickstart" rel="noopener noreferrer"&gt;rantum.xyz/quickstart&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you read it and it is useful, an honest review on Amazon helps a new title like this get found. No pressure either way. &lt;a href="https://www.amazon.com/review/create-review?asin=B0H9GF88GR" rel="noopener noreferrer"&gt;Leave a review&lt;/a&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>blockchain</category>
      <category>ethereum</category>
      <category>datascience</category>
      <category>dune</category>
    </item>
    <item>
      <title>What DEX Volume Actually Measures — and Why Attribution Has to Come First</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Fri, 24 Jul 2026 12:00:39 +0000</pubDate>
      <link>https://dev.to/andrewmaury/what-dex-volume-actually-measures-and-why-attribution-has-to-come-first-1h3h</link>
      <guid>https://dev.to/andrewmaury/what-dex-volume-actually-measures-and-why-attribution-has-to-come-first-1h3h</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/research/cross-frontend-attribution.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source. The full preprint is on Zenodo, &lt;a href="https://doi.org/10.5281/zenodo.21513262" rel="noopener noreferrer"&gt;DOI 10.5281/zenodo.21513262&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Decentralized exchange volume is one of the most-cited numbers in crypto. Liquidity-mining programs pay against it. Layer-2 foundations report it as adoption. Grant committees read it as evidence a product is being used. It is treated as a proxy for real people doing real trades.&lt;/p&gt;

&lt;p&gt;It is a weak proxy, for a structural reason: the trade-execution infrastructure everyone shares exposes no standard way for a frontend to announce who it is on-chain. A wallet-native swap widget, an aggregator, an institutional smart contract, and a meta-router can all produce the same kind of settled trade, and none is required to leave a signature saying which one it was. Once volume is not tied back to a true origin, it is trivially inflated — by wash trading, proxy routing, and MEV-adjacent flow — and any incentive program built on top of it rewards the wrong behavior.&lt;/p&gt;

&lt;p&gt;I wrote a preprint on the attribution methodology we built for &lt;a href="https://cleartracedata.com" rel="noopener noreferrer"&gt;ClearTrace&lt;/a&gt;, a neutral third-party execution-intelligence system covering Ethereum, Base, Arbitrum, and Optimism. This is the plain-language version; the full methodology, validation, and limitations are in the paper.&lt;/p&gt;

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

&lt;p&gt;If you cannot say &lt;em&gt;which frontend&lt;/em&gt; produced a trade, you cannot say whether the volume is organic. And you usually cannot say which frontend produced a trade, because that information is not natively recorded anywhere. Attribution is the missing first step every downstream number quietly depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four independent heuristics
&lt;/h2&gt;

&lt;p&gt;The paper describes four ways to recover frontend origin from raw transaction and trace data. They are deliberately independent — each fails on different trades, so where several agree the signal is strong, and where they disagree you have found something worth a closer look.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Calldata-suffix trapping&lt;/strong&gt; — many frontends append an identifying tag to the end of the transaction calldata. Where it exists, it is the cleanest signal available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy-router mismatch detection&lt;/strong&gt; — when the contract that appears to send a trade is not the one that actually originated it, the mismatch itself is evidence of routing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-hop origin tracing&lt;/strong&gt; — following a trade back through intermediary contracts to the interface a user actually touched, rather than stopping at the last hop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fee-recipient clustering&lt;/strong&gt; — grouping trades by where the fee ends up, which tends to cluster by frontend even when nothing else does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No single heuristic is trusted on its own. The point is the ensemble.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribution first, then execution quality — kept separate
&lt;/h2&gt;

&lt;p&gt;On top of the attribution layer sits an execution-quality layer that benchmarks the price a trade actually got against a volume-weighted average price (VWAP) oracle. That quote-accuracy score is kept deliberately separate from a sandwich/MEV-exposure score. They answer different questions — "did you get a fair price?" and "were you exposed to predatory ordering?" — and blending them into one number hides more than it reveals.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the paper reports
&lt;/h2&gt;

&lt;p&gt;Rather than assert the method works, the preprint reports what happened running it in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validation from an in-production quote-accuracy sampler, with 112 of 168 tracked cells reaching statistical significance over a 9.6-day window.&lt;/li&gt;
&lt;li&gt;A worked hypothesis reversal: a major aggregator that looked strong until additional data revealed order-size-dependent quote degradation.&lt;/li&gt;
&lt;li&gt;A structural finding on Arbitrum: the near-zero sandwich-attack count there is genuine structural immunity, not a coverage gap, confirmed by reading detector active-days alongside the raw victim count and consistent with Arbitrum's first-come-first-served private sequencing. It is a distinction that is easy to get backwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also states the limitation plainly: this is single-team validation, and the paper argues for treating cross-frontend attribution as a first-class, auditable input to incentive design rather than an afterthought bolted onto a volume dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the paper
&lt;/h2&gt;

&lt;p&gt;The full preprint — methodology, validation, and limitations — is on Zenodo under &lt;a href="https://doi.org/10.5281/zenodo.21513262" rel="noopener noreferrer"&gt;DOI 10.5281/zenodo.21513262&lt;/a&gt;, licensed CC-BY 4.0. The abstract and citation live at &lt;a href="https://rantum.xyz/research/cross-frontend-attribution.html" rel="noopener noreferrer"&gt;rantum.xyz/research&lt;/a&gt;, and the methodology is deployed in production at &lt;a href="https://cleartracedata.com" rel="noopener noreferrer"&gt;cleartracedata.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>ethereum</category>
      <category>datascience</category>
      <category>web3</category>
    </item>
    <item>
      <title>Naming the “Unknown Proxy”: On-Chain Entity Resolution With a Cheapest-First Ladder</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Wed, 22 Jul 2026 16:08:47 +0000</pubDate>
      <link>https://dev.to/andrewmaury/naming-the-unknown-proxy-on-chain-entity-resolution-with-a-cheapest-first-ladder-3cch</link>
      <guid>https://dev.to/andrewmaury/naming-the-unknown-proxy-on-chain-entity-resolution-with-a-cheapest-first-ladder-3cch</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fopr3fdc8m4fz9bcmcp17.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fopr3fdc8m4fz9bcmcp17.png" width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On-chain attribution has a stubborn tail. You can trace DEX volume back to the contract that carried it, and still be left staring at the most important rows labeled as nothing more useful than Unknown Proxy (0x…). Those anonymous entries are the flow that matters most: institutional desks, custom enterprise frontends, and wallet-native swap extensions. This is how ClearTrace turns them into named entities, cheaply, without guessing, and without burning paid API credits on answers a free source already holds.&lt;/p&gt;

&lt;h4&gt;
  
  
  The challenge
&lt;/h4&gt;

&lt;p&gt;ClearTrace attributes DEX volume to the frontend that originated each trade, across Ethereum, Base, Arbitrum, and Optimism. But a single unnamed proxy can sit near the top of a chain’s volume table and quietly distort the entire picture of who is actually transacting.&lt;/p&gt;

&lt;p&gt;No single data source names them all. Etherscan misses verifications that Sourcify holds. A proxy hides the implementation that carries its real identity. The same router, redeployed byte-for-byte across four chains, looks like four unrelated unknowns. Paid entity APIs would name a chunk of it, but running them across every unknown, on every sync, burns credits fast.&lt;/p&gt;

&lt;p&gt;The hard part is resolving each address to the best available label without paying for answers a free source already holds.&lt;/p&gt;

&lt;h4&gt;
  
  
  What we built: a resolution ladder
&lt;/h4&gt;

&lt;p&gt;For each unknown contract, ClearTrace walks a fixed sequence of sources ordered cheapest-and-most-authoritative first, stopping at the first real name. Roughly a dozen rungs, from a free eth_call to paid entity APIs tried last. The order itself does most of the work: by the time an address reaches a paid source, the free rungs have already named the vast majority of volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ground truth first: the deployer’s own list.&lt;/strong&gt; The single highest authority is a protocol publishing its own deployment addresses. This rung was added after a hard lesson: a contract’s &lt;em&gt;absence&lt;/em&gt; from a third party’s decoded set is not evidence that it is unofficial. When the deployer publishes the addresses, that file is the answer, and it outranks every heuristic below it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-chain registries.&lt;/strong&gt; Some protocols track their own deployments in on-chain registries. A single free eth_call (for example, an ownerOf against the 0x Settler deployer's ERC-721) returns the currently active contract for a feature. Authoritative, no third-party API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free local and public sources.&lt;/strong&gt; Intel tables already in the local database, then the Etherscan v2 multichain API, then the keyless public mirrors Sourcify and Blockscout, which frequently hold verifications Etherscan does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structural resolution.&lt;/strong&gt; When no directory has a name, the bytecode does. Identical normalized runtime bytecode to an already-named contract on &lt;em&gt;any&lt;/em&gt; chain inherits its label. Proxies are followed to their implementation via the EIP-1167 minimal-proxy target or the EIP-1967 implementation and beacon storage slots, and the proxy inherits that name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployer-graph attribution.&lt;/strong&gt; Still-unnamed contracts are attributed through their creator: a named deployer EOA, its ENS primary name, or the majority label of sibling contracts the same deployer already shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paid sources, last and gated.&lt;/strong&gt; Arkham (only when a session is configured) and Nansen (paid credits) sit at the bottom. Nansen halts for the rest of the run the moment it reports credit exhaustion, and a final pass batches every still-unnamed address into &lt;em&gt;one&lt;/em&gt; Dune labels.contracts execution, never one call per address.&lt;/p&gt;

&lt;h4&gt;
  
  
  Cross-chain identity
&lt;/h4&gt;

&lt;p&gt;Two facts collapse a lot of duplicate unknowns into single known entities.&lt;/p&gt;

&lt;p&gt;First, teams deploy byte-identical routers across chains, so ClearTrace fingerprints each contract as a SHA-256 of its runtime bytecode with the Solidity CBOR metadata trailer stripped. That trailer differs between otherwise-identical builds, so removing it lets one labeled instance name its twins everywhere.&lt;/p&gt;

&lt;p&gt;Second, the same key controls an address on every EVM chain, so a mainnet ENS primary name identifies a wallet cross-chain. The pipeline also recognizes EIP-7702 delegated EOAs by their 0xef0100 designator and labels them through their delegate, so a delegated wallet never masquerades as a high-volume "Unknown Proxy" whale.&lt;/p&gt;

&lt;h4&gt;
  
  
  Refusing to guess
&lt;/h4&gt;

&lt;p&gt;A wrong label is worse than an honest “Unknown.” Every rung is filtered against a blocklist of meaningless names: generic activity tags like “High Activity,” and proxy wrapper class names like TransparentUpgradeableProxy or ERC1967Proxy that describe the shape of a contract, not its owner. Masking those forces the ladder to fall through to a rung that resolves the actual implementation instead of propagating a name that says nothing. Each label also carries a confidence tier and is re-checked on a fixed interval, so a name from a weaker rung is replaced when a more authoritative source later resolves the same address.&lt;/p&gt;

&lt;h4&gt;
  
  
  Spending the effort where the volume is
&lt;/h4&gt;

&lt;p&gt;Unknown volume is heavily concentrated in a few large addresses, so the pipeline ranks unresolved contracts globally by attributed volume and resolves the top of that list each sync, rather than sweeping every address round-robin. It is the cheapest way to move the most attributed volume from “Unknown” to named per run. In a recent snapshot of the label store, 259 of the resolved contracts carried entity names, and about 89% of those (231 of 259) were named by the free and structural rungs before any live paid lookup to Arkham, Nansen, or the batched Dune call.&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="c1"&gt;-- Rank still-unnamed contracts by the volume they carry, across all chains,&lt;/span&gt;
&lt;span class="c1"&gt;-- so the resolution ladder spends its effort (and any paid rungs) on the&lt;/span&gt;
&lt;span class="c1"&gt;-- addresses that actually distort attribution.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;resolved_contract_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;blockchain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total_volume_usd&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dune_attribution&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolved_contract_name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'Unknown%'&lt;/span&gt;
       &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;resolved_contract_name&lt;/span&gt; &lt;span class="n"&gt;GLOB&lt;/span&gt; &lt;span class="s1"&gt;'0x[0-9a-fA-F]*'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;blockchain&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&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;total_volume_usd&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;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Any one vendor names part of the on-chain world. The value is in the ladder that combines authoritative, free, and paid sources in the right order, resolves structurally when none of them answer, and knows when to stop. For a foundation, grant program, or protocol team, that is the difference between “a large unknown near the top of the table” and a named entity you can actually reason about. It runs today inside ClearTrace’s live dashboard and public API at &lt;a href="https://cleartracedata.com" rel="noopener noreferrer"&gt;cleartracedata.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Andrew Maury is the founder of Rantum, a senior data science &amp;amp; ML studio that turns messy, fragmented, and adversarial data into models, APIs, and products that ship. Recent work: ClearTrace (neutral on-chain execution intelligence) and AddressIntel (predictive real-estate intelligence). More at&lt;/em&gt; &lt;a href="https://andrewmaury.com" rel="noopener noreferrer"&gt;&lt;em&gt;andrewmaury.com&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>ethereum</category>
      <category>blockchain</category>
      <category>defi</category>
    </item>
    <item>
      <title>Designing a Point-in-Time Backtest You Can Actually Trust</title>
      <dc:creator>Andrew Maury</dc:creator>
      <pubDate>Mon, 20 Jul 2026 22:06:24 +0000</pubDate>
      <link>https://dev.to/andrewmaury/designing-a-point-in-time-backtest-you-can-actually-trust-10ek</link>
      <guid>https://dev.to/andrewmaury/designing-a-point-in-time-backtest-you-can-actually-trust-10ek</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://rantum.xyz/case-studies/point-in-time-backtest.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;, which is the canonical source.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Almost any predictive model can be made to look good on historical data. The hard part is a backtest that reports what was actually knowable at decision time, so the number survives contact with production. Here is the discipline we hold every model to at Rantum, and the worked example where it walked a flattering 5.3x result down to an honest 2x.&lt;/p&gt;

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

&lt;p&gt;Every predictive product is sold on a backtest: "our model would have caught X% of these events in advance." The trouble is that a backtest is the easiest thing in machine learning to fake, usually by accident. Read a feature that was only knowable after the fact, shuffle your train and test sets at random, or report accuracy on an event that almost never happens, and you can produce an impressive number that means nothing. The model then ships, and the edge that looked real on paper quietly disappears in production.&lt;/p&gt;

&lt;p&gt;The gap between a flattering backtest and a defensible one is not a modeling trick. It is a set of disciplines about &lt;em&gt;time&lt;/em&gt;: what was knowable, when, and whether the test respects that. Below is the checklist we run on every predictive engagement, and a worked example, a teardown-probability model inside &lt;a href="https://addressintel.co" rel="noopener noreferrer"&gt;AddressIntel&lt;/a&gt;, our real-estate intelligence platform, where following it changed the answer.&lt;/p&gt;

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

&lt;p&gt;A repeatable point-in-time backtesting discipline, applied here to predicting which aging houses on the San Francisco Peninsula get demolished and rebuilt. Teardowns are rare, well under 1% of parcels a year. The outcome is only visible in fragmented municipal permit records, and the features tempt you to cheat at every turn, which makes it a good stress test for a method meant to travel to any domain with rare events and messy history. Five rules do the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Score as-of T: features from data dated at or before T, nothing else
&lt;/h3&gt;

&lt;p&gt;Pick a set of historical scoring dates T, and at each one, build every feature using only information that existed on or before that date. This sounds obvious and is constantly violated, because the most predictive-looking features are exactly the ones that leak. A teardown &lt;em&gt;changes the structure&lt;/em&gt;: score a parcel as of 2018 but read its &lt;code&gt;year_built&lt;/code&gt;, square footage, or listing photos as they stand today, and every one of those attributes describes the &lt;em&gt;new&lt;/em&gt; house that replaced the old one. The feature would silently encode the answer. Only dated history, assessment rolls, sales, and permits, each stamped with the year it was true, reconstructs cleanly as-of T. Enforcing this disqualifies most of the obvious features before a single metric is computed.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Time-based folds, never random cross-validation
&lt;/h3&gt;

&lt;p&gt;Standard k-fold cross-validation shuffles rows at random into train and test sets. For anything with a time dimension, that is leakage by construction: the model trains on rows from 2020 and is tested on rows from 2018, learning from a future it would not have had. Pool observations across several as-of dates instead, and split on time: fit on T at or before 2018, test on T = 2020. Use enough distinct dates, we like six or more, that no single lucky window can carry the result, and report the metrics per fold so temporal instability shows up instead of hiding in an average.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Lead with the base rate; measure lift, not accuracy
&lt;/h3&gt;

&lt;p&gt;When positives are rare, accuracy is worse than useless: a model that predicts "no teardown, ever" is 99%+ accurate and completely worthless. The honest frame is the base rate first, then &lt;strong&gt;lift&lt;/strong&gt; (how much richer the top-ranked slice is than chance), alongside precision-at-k, capture (recall) at k, and the area under the precision-recall curve. Because positives are few, bootstrap a confidence interval on every figure so you don't over-read noise.&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;# Rare positives (teardowns run &amp;lt;1% of parcels): accuracy is a trap —
# "never a teardown" scores 99%+. Lead with base rate, then lift.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# observations: (features, label), each built as-of T
&lt;/span&gt;    &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;observations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# e.g. 0.0072
&lt;/span&gt;    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observations&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;o&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&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="n"&gt;reverse&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="n"&gt;k&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;ranked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;                                         &lt;span class="c1"&gt;# top decile
&lt;/span&gt;    &lt;span class="n"&gt;precision_at_k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;k&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;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;precision_at_k&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;                           &lt;span class="c1"&gt;# lift is the headline
&lt;/span&gt;
&lt;span class="c1"&gt;# Pool across as-of dates T with TIME-based folds — fit on T &amp;lt;= 2018,
# test on T = 2020. Random k-fold shuffles future rows into training: leakage.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. The label must be independent of the feature it validates
&lt;/h3&gt;

&lt;p&gt;This is the rule that saved the model from shipping a lie. The first teardown label was a cheap proxy: California's Proposition 13 caps assessed-value growth except on new construction, so a teardown-rebuild shows up as a sharp jump in a parcel's &lt;em&gt;building&lt;/em&gt; value. Scored against that label, a single heuristic (sort parcels by their land-to-improvement value ratio) looked spectacular: a 5.3x lift at the top decile, capturing over half of all "teardowns," on 17,589 observations at a 0.72% base rate. It read as an undefeated baseline.&lt;/p&gt;

&lt;p&gt;It was an artifact. The proxy label &lt;em&gt;is&lt;/em&gt; a building-value spike, and the winning feature &lt;em&gt;is&lt;/em&gt; land divided by building value. Both are functions of the same number, so scoring by the ratio was, in effect, scoring against a shadow of the label it was supposed to predict. We caught it two ways. First, a direct cross-validation: of 140 proxy-flagged rebuilds, exactly one had a confirming demolition or new-build permit at the same parcel. Second, rebuilding the label from real, independent data (actual San Jose demolition and new-single-family permits, keyed to the parcel number across a 36,896-parcel universe). On that clean, non-circular test the ratio's edge as a standalone score evaporated to no signal at all, though the underlying lot-size economics still carried a smaller, defensible signal (about 2x lift), which is the number the shipped model stands on.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;5.3x&lt;/strong&gt; lift on the circular proxy label, then &lt;strong&gt;0x&lt;/strong&gt; for the same feature against an independent label.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule generalizes past this one case: whenever a label is derived from the same source as a feature (a churn label inferred from the activity you also feed the model, a fraud label built from the rules you're scoring against), the backtest measures arithmetic, not prediction. Build the label from a source the features can't see, or the number is a mirror.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Pre-register the go/no-go, and build a gate that lets you fail cheap
&lt;/h3&gt;

&lt;p&gt;Write down the bar for success before you look at any results, so you can't move the goalposts to fit the number you got. Ours here was concrete: on out-of-sample dates, a top-decile lift of at least 5x, &lt;em&gt;and&lt;/em&gt; a material margin over the best single heuristic (not within noise), &lt;em&gt;and&lt;/em&gt; a positive median lead time so the call is early enough to act on. Just as important is a cheap feasibility gate up front. Before running any metrics, we counted teardown positives in the candidate windows; the pre-set rule was that fewer than roughly 30 to 50 positives is dead on arrival. The first pass returned zero strict positives against a ceiling of 19, so the metrics were never run on noise, and the real work, assembling an independent permit label, was correctly identified as the project rather than a detour.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backtest is not the same as the live model
&lt;/h3&gt;

&lt;p&gt;The final piece of the discipline is knowing what the backtest does and does not license. The leakage that disqualifies &lt;code&gt;year_built&lt;/code&gt;, square footage, and photos is a property of scoring a parcel that was &lt;em&gt;already rebuilt&lt;/em&gt; in the historical window. Score a house that is still standing today, and those same attributes describe the actual candidate structure: no leak, and they are strong features. So the backtest validates the economic and assessment layer under strict point-in-time rules, while the live product can honestly be richer than the backtest could ever prove. Conflating the two is how teams either cripple a live model with backtest-only caution or ship a backtest number the production features quietly violate.&lt;/p&gt;

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

&lt;p&gt;The unglamorous core of trustworthy predictive ML: reconstructing features as-of a decision date, splitting on time rather than at random, measuring rare events by lift instead of accuracy, keeping the label independent of what it validates, and pre-committing to a bar. None of it is exotic. All of it is routinely skipped, because skipping it produces better-looking numbers. It is the same through-line as the rest of Rantum's work (finding real signal in messy data) turned inward on the measurement itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it proves to a client
&lt;/h2&gt;

&lt;p&gt;That we will tell you whether a model works before you bet a roadmap on it. This case study is the proof: a 5.3x result that looked ready to ship as a "proprietary teardown score" got walked back to a defensible 2x lot-size signal once a clean test was possible, and we would rather deliver the smaller true number than the larger false one. Anyone can produce a flattering backtest by leaking the future into the features; the value is in the rigor that refuses to. For a business sitting on fragmented, underused data, that is the difference between a predictive product you can defend to a customer and a demo that falls apart in production. The method transfers directly to any domain with rare outcomes and messy history: insurance, lending, fraud, marketplaces, logistics, churn.&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. Read the full case study at &lt;a href="https://rantum.xyz/case-studies/point-in-time-backtest.html" rel="noopener noreferrer"&gt;rantum.xyz&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>datascience</category>
      <category>python</category>
      <category>dataengineering</category>
    </item>
  </channel>
</rss>
