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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

&lt;p&gt;So the tag sits there unread, in the one part of the transaction everybody throws out. Finding it is arithmetic on a length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;has_calldata_suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    A well-formed ABI call is a 4-byte selector (8 hex chars) followed by some
    number of 32-byte words (64 hex chars each). A trailing remainder that
    doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t fill a word is an appended fingerprint: the signature of modular
    frontends and wallet-native swap extensions.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;input_hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drop_hex_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_hex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Two hosts in the fleet are login-gated, so their caps are &lt;em&gt;unmeasured&lt;/em&gt; rather than absent, and the code keeps that distinction. For those, the fallback test is a total landing exactly on a round number: a result set is a count of permits a city happened to issue, and that count landing on exactly 500 by coincidence is far less likely than it landing there because something clipped it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;etrakit_is_saturated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;True when a result set of `total_rows` is sitting on its host&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s cap.

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;ClearTrace encodes those four models as one taxonomy that every consumer imports from a single module, so the rule cannot drift between the dashboard, the API, and the pre-send checks. A short excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Execution models
&lt;/span&gt;&lt;span class="n"&gt;ROUTER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BATCH_AUCTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INTENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UNCLASSIFIED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;router&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;batch_auction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;intent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unclassified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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



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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>blockchain</category>
      <category>ethereum</category>
      <category>python</category>
      <category>datascience</category>
    </item>
    <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>
  </channel>
</rss>
