<?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: Jiangw2718i</title>
    <description>The latest articles on DEV Community by Jiangw2718i (@jiangw2718i).</description>
    <link>https://dev.to/jiangw2718i</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%2F3995012%2Fac092618-8dd1-4e7f-8495-50a538d3a837.png</url>
      <title>DEV Community: Jiangw2718i</title>
      <link>https://dev.to/jiangw2718i</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jiangw2718i"/>
    <language>en</language>
    <item>
      <title>The median MCP server installs 94 packages, and 88% pull an HTTP framework into a stdio process</title>
      <dc:creator>Jiangw2718i</dc:creator>
      <pubDate>Fri, 31 Jul 2026 13:19:26 +0000</pubDate>
      <link>https://dev.to/jiangw2718i/the-median-mcp-server-installs-94-packages-and-88-pull-an-http-framework-into-a-stdio-process-1mdi</link>
      <guid>https://dev.to/jiangw2718i/the-median-mcp-server-installs-94-packages-and-88-pull-an-http-framework-into-a-stdio-process-1mdi</guid>
      <description>&lt;p&gt;I built an MCP server this week. Before publishing it I did the thing I would want someone else to do: I looked at what installing it actually puts on your machine.&lt;/p&gt;

&lt;p&gt;Installing it pulled down 95 packages. My server is two files and 145 lines. It exposes one read-only tool. It speaks JSON-RPC over its own stdin and stdout, and it never opens a socket.&lt;/p&gt;

&lt;p&gt;Inside those 95 packages were &lt;code&gt;express&lt;/code&gt;, &lt;code&gt;hono&lt;/code&gt;, and &lt;code&gt;@hono/node-server&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That bothered me enough to ask whether it was just me. It was not. I measured every npm-packaged stdio server in the official MCP registry — 6,139 of them, 6,030 resolvable — and the median install is &lt;strong&gt;94 packages&lt;/strong&gt;. For the 88% built on the v1 SDK the median is &lt;strong&gt;95&lt;/strong&gt;, which is exactly where mine landed, without trying.&lt;/p&gt;

&lt;p&gt;The interesting part turned out not to be the number. It is that almost nobody chose it, and that you cannot see it in anything they wrote down.&lt;/p&gt;

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

&lt;p&gt;The &lt;a href="https://registry.modelcontextprotocol.io" rel="noopener noreferrer"&gt;official MCP registry&lt;/a&gt; held &lt;strong&gt;18,990 servers&lt;/strong&gt; when I pulled it, 18,788 marked active. Filtering to entries that ship an npm package &lt;em&gt;and&lt;/em&gt; declare &lt;code&gt;stdio&lt;/code&gt; transport gives &lt;strong&gt;6,139 distinct npm packages&lt;/strong&gt;. That is the population.&lt;/p&gt;

&lt;p&gt;For each one I asked npm what a production install resolves to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &amp;lt;package&amp;gt;@&amp;lt;version&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev &lt;span class="nt"&gt;--package-lock-only&lt;/span&gt; &lt;span class="nt"&gt;--no-audit&lt;/span&gt; &lt;span class="nt"&gt;--ignore-scripts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--package-lock-only&lt;/code&gt; resolves the full tree without downloading a single tarball, which is the only reason 6,139 packages is feasible at all.&lt;/p&gt;

&lt;p&gt;The scripts and the measured data are in the repo, at &lt;a href="https://github.com/Jiangw2718i/frisk/tree/main/research/mcp-dependency-census" rel="noopener noreferrer"&gt;&lt;code&gt;research/mcp-dependency-census&lt;/code&gt;&lt;/a&gt;. This post makes claims about its own instrument, so the instrument ships with it. Running &lt;code&gt;article_numbers.py&lt;/code&gt; reprints the population, distribution, framework and SDK figures below, each labelled as it appears here. The figures that need the network — the sampled dependency declarations, the SDK-version table, and the sizes — are not in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6,030 resolved. 109 did not&lt;/strong&gt;, and it is worth saying why: 58 declare a version that is not published on npm, 29 name a package that is not on npm at all, 19 have an identifier npm cannot parse as a spec, and 3 failed otherwise — one refused this platform, one timed out, one exited non-zero. Those 109 are excluded from every number below.&lt;/p&gt;

&lt;p&gt;One wording note I will use consistently: each row is an npm &lt;strong&gt;package&lt;/strong&gt;, not a server. 166 packages appear in the registry under two different server names, and I count those once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the ruler, twice, because the first check was worthless
&lt;/h2&gt;

&lt;p&gt;I got a result that could not be true. A server package occupies one slot plus whatever the SDK brings, so a server depending on the SDK had to be &lt;em&gt;larger&lt;/em&gt; than the SDK alone. My data said the median server was &lt;strong&gt;smaller&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I had two counting rules and had not noticed. &lt;strong&gt;Install locations&lt;/strong&gt; counts every &lt;code&gt;node_modules/…&lt;/code&gt; entry — &lt;code&gt;content-type&lt;/code&gt; appears three times in the SDK's tree, at two versions, because of a conflict deeper down. &lt;strong&gt;Distinct package names&lt;/strong&gt; collapses those three into one. My validation measured install locations and agreed with a real &lt;code&gt;npm install&lt;/code&gt;. My measurement counted distinct names. &lt;strong&gt;I validated one ruler and then measured with a different one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I fixed it, re-ran, and validated six packages against real installs. Six out of six agreed, and I nearly shipped that sentence as my proof of rigour.&lt;/p&gt;

&lt;p&gt;It was not proof of anything. All six of those packages have &lt;strong&gt;zero&lt;/strong&gt; platform-specific optional dependencies — and that is the one failure mode this instrument has. &lt;code&gt;--package-lock-only&lt;/code&gt; writes a lockfile entry for every platform variant of an &lt;code&gt;optionalDependency&lt;/code&gt;; a real install unpacks only the ones matching your OS and CPU. For &lt;code&gt;kubernetes-mcp-server@0.0.65&lt;/code&gt; the lockfile lists 7 and a real install creates 2. My validation set could not have detected that if it tried.&lt;/p&gt;

&lt;p&gt;Two instruments later — &lt;code&gt;npm install --dry-run --json&lt;/code&gt; looked authoritative and then undercounted &lt;code&gt;appium-mcp&lt;/code&gt; by 43% against its own real install — I settled on gating optional entries by &lt;code&gt;os&lt;/code&gt;/&lt;code&gt;cpu&lt;/code&gt;, and validated it the way it should have been validated the first time: &lt;strong&gt;real installs on 150 randomly chosen packages from the actual population&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;packages&lt;/th&gt;
&lt;th&gt;exact match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no optional entries&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;128 (100%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;has optional entries&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;18 (86%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;all&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;149&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;146 (98.0%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three overcounts, by 1.5% to 2.0%. &lt;strong&gt;Not one undercount.&lt;/strong&gt; Sample median: 94 measured, 94 real. So the numbers below were exact for 98% of a random sample, never low in it, and at worst 2% high on trees carrying platform-specific binaries. The median is untouched, because the trees sitting at the median contain no optional entries at all.&lt;/p&gt;

&lt;p&gt;I am spelling this out because the first version of this post contained a section congratulating itself for checking its instrument, using a check that was structurally incapable of finding the bug it still had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distribution
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;packages installed&lt;/th&gt;
&lt;th&gt;packages&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;7.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2–5&lt;/td&gt;
&lt;td&gt;118&lt;/td&gt;
&lt;td&gt;2.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6–20&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;1.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21–50&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;1.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;51–100&lt;/td&gt;
&lt;td&gt;3,281&lt;/td&gt;
&lt;td&gt;54.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;101–250&lt;/td&gt;
&lt;td&gt;1,846&lt;/td&gt;
&lt;td&gt;30.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;251+&lt;/td&gt;
&lt;td&gt;210&lt;/td&gt;
&lt;td&gt;3.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Median 94. First quartile also 94 — &lt;strong&gt;2,356 packages resolve to exactly 94&lt;/strong&gt;, and 52.6% of the population sits within five packages of it. The heaviest resolves 2,052.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5,280 of 6,030 — 87.6% — install an HTTP server framework:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;framework&lt;/th&gt;
&lt;th&gt;packages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;express&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5,276&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hono&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5,203&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@hono/node-server&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5,202&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;koa&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fastify&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;connect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of these declares stdio transport in the registry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the wall at 94 comes from
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;87.8% of these packages have &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt;, the v1 SDK, in their production tree.&lt;/strong&gt; On its own that package resolves to 93 install locations. &lt;code&gt;express&lt;/code&gt;, &lt;code&gt;hono&lt;/code&gt;, &lt;code&gt;@hono/node-server&lt;/code&gt;, &lt;code&gt;cors&lt;/code&gt;, &lt;code&gt;jose&lt;/code&gt;, &lt;code&gt;ajv&lt;/code&gt;, and &lt;code&gt;express-rate-limit&lt;/code&gt; are all in its &lt;code&gt;dependencies&lt;/code&gt; — not optional, not peer. They install regardless of which transport you use.&lt;/p&gt;

&lt;p&gt;Of the 4,992 packages resolving SDK v1 at its current version, &lt;strong&gt;45.9% land on exactly 94: themselves, plus the SDK's 93, and nothing else that npm has to unpack.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I want to be careful about what that does and does not mean, because in the first draft of this post I got it wrong. It does &lt;strong&gt;not&lt;/strong&gt; mean those authors declared one dependency. I sampled 400 of them and read their published &lt;code&gt;dependencies&lt;/code&gt;: &lt;strong&gt;35.8% declared exactly one&lt;/strong&gt;, most declared two, and some declared four. A server can list &lt;code&gt;zod&lt;/code&gt;, &lt;code&gt;cross-spawn&lt;/code&gt; and &lt;code&gt;zod-to-json-schema&lt;/code&gt; and still land on 94, because all three are already inside the SDK's tree. "Adds nothing" is a fact about deduplication, not about authorship. My first draft quietly promoted one into the other, in the sentence carrying the whole moral argument.&lt;/p&gt;

&lt;p&gt;What survives is narrower and still worth saying: of the 5,280 packages carrying an HTTP framework, &lt;strong&gt;99.1% have the v1 SDK&lt;/strong&gt;. Almost no one typed &lt;code&gt;express&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number nobody chose
&lt;/h2&gt;

&lt;p&gt;Here is the part I missed entirely on the first pass, and it is the most important thing in this post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;94 is not what any of these authors pinned. It is what today's npm floats their ranges to.&lt;/strong&gt; In 400 sampled packages, &lt;strong&gt;400 declared a caret range&lt;/strong&gt; on the SDK. Not one pinned a version. And the SDK's weight has grown a great deal:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SDK version&lt;/th&gt;
&lt;th&gt;published&lt;/th&gt;
&lt;th&gt;install locations&lt;/th&gt;
&lt;th&gt;&lt;code&gt;express&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;hono&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.0.0&lt;/td&gt;
&lt;td&gt;2024-11-25&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.10.0&lt;/td&gt;
&lt;td&gt;2025-04-17&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.20.0&lt;/td&gt;
&lt;td&gt;2025-10-09&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.25.0&lt;/td&gt;
&lt;td&gt;2025-12-15&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.30.0&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A server published against &lt;code&gt;^1.0.0&lt;/code&gt; in late 2024 installed about 14 packages the day it shipped. The same unchanged &lt;code&gt;package.json&lt;/code&gt; installs 94 today. &lt;code&gt;express&lt;/code&gt; arrived in the SDK's dependencies around April 2025 and &lt;code&gt;hono&lt;/code&gt; in December 2025 — neither is ancient, and neither required anything of the 5,203 servers that now carry them.&lt;/p&gt;

&lt;p&gt;So my first draft's line about "what that one line used to cost" had the arrow backwards. Nothing these authors wrote got heavier. A caret range did what caret ranges do, upstream added an HTTP stack, and 5,295 dependency trees grew without a single commit in any of them. The wall at 94 is a photograph of one afternoon's npm, not a property of anybody's code.&lt;/p&gt;

&lt;p&gt;That also means this post has a shelf life, and that re-running it after the next SDK release will not reproduce these numbers. I could not demonstrate drift at the scale of a day — I re-resolved 413 random rows hours later and every one matched — but it follows mechanically from 400 caret ranges out of 400.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed three days ago
&lt;/h2&gt;

&lt;p&gt;On 2026-07-27 the TypeScript SDK shipped v2, splitting the monolith: &lt;code&gt;@modelcontextprotocol/server&lt;/code&gt;, &lt;code&gt;@modelcontextprotocol/core&lt;/code&gt; beneath it, and HTTP framework adapters (&lt;code&gt;/express&lt;/code&gt;, &lt;code&gt;/fastify&lt;/code&gt;, &lt;code&gt;/hono&lt;/code&gt;) as separate packages you install only if you serve HTTP.&lt;/p&gt;

&lt;p&gt;I migrated. My server went from &lt;strong&gt;95 packages to 5&lt;/strong&gt;. &lt;code&gt;npm install frisk-mcp&lt;/code&gt; now pulls the package itself plus &lt;code&gt;@modelcontextprotocol/server&lt;/code&gt;, &lt;code&gt;@modelcontextprotocol/core&lt;/code&gt;, &lt;code&gt;zod&lt;/code&gt;, and my own screening library. No web framework. Both numbers count the same way every number above does: the package itself plus everything npm unpacks for it. The code change was two import lines; the rest was fixing what the codemod did to my &lt;code&gt;dependencies&lt;/code&gt; — it put the MCP &lt;em&gt;client&lt;/em&gt; package into runtime deps, where a server does not need it.&lt;/p&gt;

&lt;p&gt;At the time of measurement &lt;strong&gt;27 of 6,030 — 0.4% — had v2 in the tree&lt;/strong&gt;, three days after release. And that 0.4% is softer than it sounds: &lt;strong&gt;9 of the 27 also still carry v1 and the full express-plus-hono stack&lt;/strong&gt;, resolving between 101 and 361 packages. A third of the v2 cohort has shed nothing yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not claiming
&lt;/h2&gt;

&lt;p&gt;A dependency is not a vulnerability. Ninety-four packages is not ninety-four problems, &lt;code&gt;express&lt;/code&gt; is not unsafe, and nothing here says any of these servers is compromised or badly written. I measured package counts, not risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On stdio versus HTTP:&lt;/strong&gt; the registry declares stdio for every entry I counted, and the framework is a hard dependency of the v1 SDK, so it lands in the tree either way. But some of these packages do serve HTTP as well, and rather than say "I did not audit that" — which is what the first draft said, and which was the more flattering choice — I went and counted: &lt;strong&gt;176 of the 6,139 also ship a &lt;code&gt;streamable-http&lt;/code&gt; package entry&lt;/strong&gt;, and 759 active registry entries pair an npm stdio package with a remote HTTP endpoint. For those, the framework is not dead weight. That is under 3% of the population, and it does not rescue the other 97%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the comparison group:&lt;/strong&gt; it is tempting to point at the 717 packages with no MCP SDK in the tree, note their median install is 1, and call that the counterfactual. It is not clean. All &lt;strong&gt;443&lt;/strong&gt; single-package rows are in that group — 62% of it — and I say below that I would not vouch for a quarter of those. The median of 1 is produced by exactly the rows I am least willing to defend, so I am not resting the causal claim on it. The claim rests on the SDK's own dependency list and on 45.9% landing on 94 to the package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the single-package rows:&lt;/strong&gt; median unpacked size 38 KB; 33% over 100 KB and plausibly bundling their dependencies at build time; 25% under 10 KB, small enough that I would not assume there is a working server inside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On versions:&lt;/strong&gt; I measured the version each registry entry declares. &lt;code&gt;npx -y &amp;lt;package&amp;gt;&lt;/code&gt; installs whatever is latest on npm instead, which can resolve to a different tree than the one I counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I care about this one
&lt;/h2&gt;

&lt;p&gt;I work on payment screening for agents. What I am paid to worry about is what an autonomous agent is about to hand money to. An MCP server sits closer to that decision than almost anything else on the machine: it runs with your agent's privileges, on your credentials, in the same process tree.&lt;/p&gt;

&lt;p&gt;Which is why it is worth stating plainly that my own server — the one whose job is to be suspicious of things — was carrying a web framework it has no code path to reach, and that I only know because I looked. And then that my first attempt at looking was wrong twice: once about which ruler I was using, once about what the ruler could not see.&lt;/p&gt;

&lt;p&gt;If you maintain an MCP server, the check takes about ten seconds. Do it as a real install in an empty directory, not with &lt;code&gt;--dry-run&lt;/code&gt; — that is one of the two instruments I caught being wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; /tmp/dep-check &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/dep-check &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &amp;lt;your-package&amp;gt; &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line npm prints is &lt;code&gt;added N packages&lt;/code&gt;. If that number surprises you, the migration guide is &lt;a href="https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md" rel="noopener noreferrer"&gt;upgrade-to-v2.md&lt;/a&gt;, and there is a codemod. Read what it does to your &lt;code&gt;dependencies&lt;/code&gt; block before you commit it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Frisk is an MIT-licensed screening library for agent payments. The MCP server described here is &lt;a href="https://github.com/Jiangw2718i/frisk/tree/main/mcp" rel="noopener noreferrer"&gt;&lt;code&gt;frisk-mcp&lt;/code&gt;&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>security</category>
      <category>node</category>
    </item>
    <item>
      <title>A changed payTo address is not a honeypot: 272 payTo changes, 13 lookalikes, zero proof</title>
      <dc:creator>Jiangw2718i</dc:creator>
      <pubDate>Sun, 26 Jul 2026 05:17:44 +0000</pubDate>
      <link>https://dev.to/jiangw2718i/a-changed-payto-address-is-not-a-honeypot-272-payto-changes-13-lookalikes-zero-proof-1jb</link>
      <guid>https://dev.to/jiangw2718i/a-changed-payto-address-is-not-a-honeypot-272-payto-changes-13-lookalikes-zero-proof-1jb</guid>
      <description>&lt;p&gt;In my &lt;a href="https://tryfrisk.dev/blog/screening-x402-payments" rel="noopener noreferrer"&gt;last post&lt;/a&gt; I listed the ways an x402 payment can go wrong. Vector number one was the &lt;strong&gt;dynamic &lt;code&gt;payTo&lt;/code&gt; swap&lt;/strong&gt;: in x402 the destination address can change per request, so a seller can quote you address A and then take payment to address B. I said it was one of the checkable ones.&lt;/p&gt;

&lt;p&gt;So I built the check, wired it into &lt;a href="https://github.com/Jiangw2718i/frisk" rel="noopener noreferrer"&gt;Frisk&lt;/a&gt;'s hosted reputation side, and pointed it at the x402 Bazaar — the public directory of paid endpoints — on a daily crawl. The idea was simple: remember each endpoint's advertised &lt;code&gt;payTo&lt;/code&gt;, and if it changes, that is a swap, so flag the counterparty.&lt;/p&gt;

&lt;p&gt;Over about a month it recorded &lt;strong&gt;272 payTo changes&lt;/strong&gt; across 246 endpoints, and promoted them into &lt;strong&gt;57 &lt;code&gt;honeypot:payto_swap&lt;/code&gt; reputation labels&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then I looked at what it had actually flagged. The endpoint with the most flags in the entire dataset was &lt;code&gt;x402.browserbase.com&lt;/code&gt;, with 9. Second was &lt;code&gt;x402.tavily.com&lt;/code&gt;, with 8. Browserbase and Tavily are real, funded infrastructure companies. My detector had scored them 0 and told callers they were honeypots.&lt;/p&gt;

&lt;p&gt;Those flags were wrong. So were the rest. What took longer to admit is that they were wrong in five separate ways, and only one of them was the mistake I thought I was making.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the label actually said
&lt;/h2&gt;

&lt;p&gt;Before the excuses, the exact damage. A &lt;code&gt;honeypot:payto_swap&lt;/code&gt; label drives the trust score to 0 and raises a &lt;code&gt;high&lt;/code&gt; severity signal. At the default strictness of 0.3 that returns a &lt;strong&gt;&lt;code&gt;review&lt;/code&gt;&lt;/strong&gt; verdict, escalating to &lt;code&gt;block&lt;/code&gt; for a strict caller or any payment over 1000. So Frisk did not literally say "do not pay" — it said "stop and look at this one," which is bad enough when the counterparty is a legitimate vendor rotating a key.&lt;/p&gt;

&lt;p&gt;Worse is the reason string that came with it, which was hardcoded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;observed in active probing: payto_swap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing had probed them. These labels came from a passive daily crawl of a public directory. The snapshot job was borrowing the active probe's language, so the API was telling callers it had tested something it never touched. That is the part I find hardest to write down.&lt;/p&gt;

&lt;p&gt;Two things keep this from being a disaster rather than an embarrassment: the hosted API has three keys issued and one active, so almost nobody could have read those labels — and the local, MIT-licensed screening library that most people actually use never had this bug, because it never sees other people's addresses at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a rotating crawl can actually see
&lt;/h2&gt;

&lt;p&gt;Here is the whole flawed idea in one line: &lt;em&gt;the address changed, therefore something is wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Start with what the crawler can see. It runs once a day and walks a rotating window of 8,000 listings. I had described this as a "daily snapshot," which was wrong twice over. The registry is not a fixed size — my table has accumulated 41,286 endpoint URLs since I started, and never deletes, while the live directory reports a total of about &lt;strong&gt;14,345&lt;/strong&gt; today, drifting by a few between pulls. Against a registry that size an endpoint comes back around every &lt;strong&gt;three to five days&lt;/strong&gt;, not daily, and the tail goes longer.&lt;/p&gt;

&lt;p&gt;It is worse than that. While writing this I checked the crawl cursor and found it had been &lt;strong&gt;stuck since 2026-07-23&lt;/strong&gt;. The registry shrank below the saved offset, so every run fetched an empty window and returned early without rewinding the cursor. Three days of no data collection, silently, because the resume logic could not handle a registry that gets smaller. That is fixed now — an empty window rewinds to zero — but a detector whose evidence depends on visit cadence had been quietly not visiting.&lt;/p&gt;

&lt;p&gt;So when the crawler sees address A on one visit and address B on the next, all it knows is &lt;code&gt;A -&amp;gt; B&lt;/code&gt;, somewhere inside a multi-day gap. That fact is &lt;strong&gt;identical&lt;/strong&gt; whether:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an honest operator rotated its receiving wallet on a schedule, or migrated its whole fleet to a new treasury address, or&lt;/li&gt;
&lt;li&gt;an attacker quietly swapped in a wallet they control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing in &lt;code&gt;A -&amp;gt; B&lt;/code&gt;, observed days apart, distinguishes those. The address change is not evidence of fraud. It is evidence of an address change.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;payTo changes recorded&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;272&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;distinct endpoints affected&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;distinct addresses labeled&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;accounted for by a single fleet migration&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;109&lt;/strong&gt; (40%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;matched the address-poisoning &lt;em&gt;lookalike&lt;/em&gt; shape&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;that the crawl could prove were an attack&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The vendors were not rotating nine times. I was sampling nine times.
&lt;/h2&gt;

&lt;p&gt;The first thing that fell apart was my own headline number.&lt;/p&gt;

&lt;p&gt;Browserbase's endpoint produced 9 recorded changes, Tavily's 8. But when I lined the rows up, &lt;strong&gt;no address ever appeared twice&lt;/strong&gt;. Browserbase's chain runs through ten distinct addresses across those nine changes; Tavily's through nine. Not one repeat, and not one return to a previous address.&lt;/p&gt;

&lt;p&gt;That is not a vendor who rotated nine times. That is a vendor whose address changes at least as often as I look, measured by a crawler that happened to look nine times. My "rotation count" was a fact about my cron schedule. And "the two worst offenders" was wrong in its own right — by raw flag count they rank fifth and sixth, behind hosts that contributed 109, 28, 21 and 15 rows. They only top the list &lt;em&gt;per endpoint&lt;/em&gt;: one endpoint each, changing every single visit.&lt;/p&gt;

&lt;p&gt;You can watch it happen live. Pulling the public discovery API three times over about three hours while writing this, each vendor advertised a different Base address every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;x402.tavily.com        0x2a09d4fd…fa09a  -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;0x68459d07…091af  -&amp;gt;  0x043d2e40…29c7
&lt;span class="gp"&gt;x402.browserbase.com   0x61ce240e…0633e  -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;0x100f307A…13834  -&amp;gt;  0xC09B2303…be12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two pulls seventy seconds apart returned identical addresses, so this is not per-request minting visible from the directory — but it is comfortably faster than a crawler that comes back every three to five days.&lt;/p&gt;

&lt;p&gt;Tavily also publishes something worth reading carefully. Its listing quotes two rails, and the second one is not an address at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;eip155&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;8453   0x043d2e40ced48199415f3b2463d0bfab4fc029c7&lt;/span&gt;
&lt;span class="py"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;base      urn:x402:agent-pay:see-quote&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A placeholder meaning &lt;em&gt;the address is decided when you ask for a quote&lt;/em&gt;. I want to be precise here, because I got this wrong in an earlier draft of this post: Tavily does advertise a concrete Base address on the standard EVM rail, and that address is the one my pipeline reads. The placeholder sits on a separate rail. But it is still the operator saying out loud that the payment address is not a stable identifier.&lt;/p&gt;

&lt;p&gt;Fresh-address-per-payment is a deliberate design — whatever the motive, it is the opposite of negligent — and a reputation system built on "the address should stay the same" declares the most carefully built endpoints to be the most dangerous ones. That is the failure mode exactly backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forty percent of my evidence was one event
&lt;/h2&gt;

&lt;p&gt;Of the 272 changes, &lt;strong&gt;109 came from a single host on a single day&lt;/strong&gt;, every one of them the same old address moving to the same new address. One operator moved its treasury and my detector recorded it 109 times, then wrote a reputation label as if 109 endpoints had independently gone bad. The next largest cluster is 27, also one host, also one day.&lt;/p&gt;

&lt;p&gt;Counting one business decision 109 times is how a scary-looking number gets built. It is also trivially fixable: three or more endpoints under one host making the identical move now collapse into a single observation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 13 that looked like an attack
&lt;/h2&gt;

&lt;p&gt;I do not get to claim the whole dataset was innocent, because 13 of the changes were not ordinary rotation. They belonged to one operator — ten endpoints spread across four subdomains of a single gateway — and each moved between two addresses that are identical except for &lt;strong&gt;one character&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;0xb3c2776ce3f99…942ab6
0xb3c2776ce4f99…942ab6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forty-two characters counting the &lt;code&gt;0x&lt;/code&gt;; forty hex digits, of which thirty-nine match. Same leading characters, same trailing characters, one digit different in between. That is the copy-paste-poisoning shape — a near-twin address swapped in so a human eyeballing the ends signs off on it — and it is exactly what my lookalike class was built to catch.&lt;/p&gt;

&lt;p&gt;It is also, on inspection, impossible to be that.&lt;/p&gt;

&lt;p&gt;Address poisoning works by mining a vanity address: you generate keypairs until one lands on an address whose first and last few characters match your target. Matching six characters is cheap. Matching &lt;strong&gt;thirty-nine of forty&lt;/strong&gt; is not — you would be searching on the order of 2^156 keys for the privilege. Nobody mined the second address to look like the first. For the same reason, this cannot be one operator deliberately alternating between two near-twin wallets it controls: nobody can generate that pair on purpose either. Both are plain externally-owned accounts with no contract code, so no CREATE2 salt-grinding produced them either — and that grind costs the same 2^156.&lt;/p&gt;

&lt;p&gt;The rows do not describe a swap, either. Ten endpoints moved from &lt;code&gt;…ce3…&lt;/code&gt; to &lt;code&gt;…ce4…&lt;/code&gt;, and then three moved back. Right now the gateway serves both addresses side by side: on one page of a thousand listings I count 22 pointing at the first and 6 at the second. That is two deployment configurations being rolled in and out, one of which almost certainly contains a mistyped character — one finger landing on the 4 instead of the 3.&lt;/p&gt;

&lt;p&gt;On Base, the first address holds 102.19 USDC and the second holds 1.32, and neither has ever originated a transaction. That last part proves less than it sounds: x402's &lt;code&gt;exact&lt;/code&gt; scheme settles USDC through EIP-3009 &lt;code&gt;transferWithAuthorization&lt;/code&gt;, so a receiving address never needs to send anything, and either balance could be swept by a relayed signature without the nonce ever moving. Whether anyone holds the key to the second address is not something I can see from outside.&lt;/p&gt;

&lt;p&gt;So the scariest-looking flag in the batch is not a honeypot, not a poisoning attempt, and probably not adversarial at all. My detector's most alarming class fired hardest on what looks like a config typo — and if I had shipped that label, I would have published an accusation of fraud against an operator whose actual problem is a stuck digit.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one of the false positives was mine alone
&lt;/h2&gt;

&lt;p&gt;The fourth failure was not in the data. It was in my parser.&lt;/p&gt;

&lt;p&gt;An x402 listing quotes the same resource on several chains, and each quote carries its own &lt;code&gt;payTo&lt;/code&gt;. On the page I sampled, &lt;strong&gt;443 of 1,000 listings advertise more than one distinct payment address at the same time&lt;/strong&gt; — the proportion drifts between pulls, and it is closer to a third across the first five pages. Almost always that is an EVM address plus a Solana one; three distinct EVM addresses on one listing showed up twice in a thousand. I was storing a single &lt;code&gt;counterparty&lt;/code&gt; per endpoint, taken from &lt;code&gt;accepts[0]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So "the endpoint's payTo" was never a well-defined value, and the one I recorded depended on the order the registry happened to return. A listing that reordered its array — changing nothing about who gets paid — would appear in my database as a payment address swap.&lt;/p&gt;

&lt;p&gt;Here I have to correct the correction. My first instinct was to say I could not tell how many of the 272 this produced. I can, roughly, and the answer is &lt;em&gt;almost none&lt;/em&gt;: every one of the 272 changes is EVM-to-EVM, and a reordering between the two common rails would show up as an EVM-to-Solana flip, of which there are zero. Only three listings in a thousand carry two distinct EVM addresses. It was a latent landmine, not a contributor — and I only found it by reading the code instead of the counts, which is its own lesson.&lt;/p&gt;

&lt;p&gt;The fix is boring: pick deterministically instead of positionally.&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="c1"&gt;// A listing quoted on several chains carries several payTo values. Taking&lt;/span&gt;
&lt;span class="c1"&gt;// accepts[0] makes the address we record depend on the order the registry&lt;/span&gt;
&lt;span class="c1"&gt;// returns, so a reordering upstream reads as a payTo change. Pick the lowest&lt;/span&gt;
&lt;span class="c1"&gt;// payTo instead: any deterministic rule removes the artifact.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quoted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payTo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quoted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;quoted&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="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;accepts&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="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;quoted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;lowest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accept&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="nx"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payTo&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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="nx"&gt;lowest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payTo&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;accept&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lowest&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;h2&gt;
  
  
  The signal I confused for a swap — and how it was also broken
&lt;/h2&gt;

&lt;p&gt;There &lt;strong&gt;is&lt;/strong&gt; a real dynamic-payTo attack, and it is genuinely catchable before you pay. I had aimed the label at the wrong observation.&lt;/p&gt;

&lt;p&gt;The real attack lives &lt;em&gt;inside a single burst of requests&lt;/em&gt;. It is when the endpoint hands different addresses to different callers at the same moment — quote address A to your discovery call, then return address B in the payment requirement a second later. You catch it by probing the same endpoint several times in one burst and checking whether it ever offers two addresses for the same rail.&lt;/p&gt;

&lt;p&gt;That last qualifier is doing a lot of work, and until this week my code did not implement it. The active probe unioned every &lt;code&gt;payTo&lt;/code&gt; across probes with no notion of which chain each quote was for, and read &lt;code&gt;accepts[0]&lt;/code&gt; exactly like the snapshot job did — on the &lt;em&gt;only&lt;/em&gt; code path allowed to write a reputation label. So a multi-chain endpoint whose &lt;code&gt;accepts&lt;/code&gt; came back in a different order across two probes would have promoted a public honeypot label. The bug I was writing a post about was still live in the more dangerous place.&lt;/p&gt;

&lt;p&gt;Worse, the check as written could re-flag the very vendors this post exists to exonerate. If an endpoint mints a fresh address often enough, a "burst" loose enough to straddle one rotation sees two addresses and promotes. So the check now requires both qualifiers — same rail, and inside a window tight enough that ordinary rotation cannot explain it:&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="c1"&gt;// The dynamic-payTo attack is two addresses offered in one breath. Honest&lt;/span&gt;
&lt;span class="c1"&gt;// operators that mint a fresh address per payment also show two addresses if&lt;/span&gt;
&lt;span class="c1"&gt;// you watch them long enough — the busiest listings in the Bazaar advertised a&lt;/span&gt;
&lt;span class="c1"&gt;// different address on every pull hours apart, and never repeated one across a&lt;/span&gt;
&lt;span class="c1"&gt;// month of crawling — so the burst has to be tight enough that ordinary&lt;/span&gt;
&lt;span class="c1"&gt;// rotation cannot explain it.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BURST_WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A change between two crawl visits is the opposite kind of observation. It needs context that a multi-day cadence does not have, so it must &lt;strong&gt;not&lt;/strong&gt; promote:&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="c1"&gt;// Longitudinal drift: address differs from the prior snapshot of this endpoint.&lt;/span&gt;
&lt;span class="c1"&gt;// At crawl cadence this looks identical whether it is a scheduled key rotation&lt;/span&gt;
&lt;span class="c1"&gt;// by an honest vendor or a rug, so we record it as an observation and never&lt;/span&gt;
&lt;span class="c1"&gt;// write a reputation label from it.&lt;/span&gt;
&lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payto_swap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;isLookalike&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`payTo changed to a lookalike of the prior address: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`payTo changed since prior snapshot: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// recorded, never a reputation label&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I should be clear about what this remedy has proven: nothing yet. There is no cron for the active probe — it runs by hand — and &lt;code&gt;honeypot_observations&lt;/code&gt; contains &lt;strong&gt;zero&lt;/strong&gt; intra-burst findings to date. The signal I am elevating as the real one has never fired in production. It is the right shape, not a track record.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;Five changes, all in the direction of claiming less:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Only intra-burst rotation promotes.&lt;/strong&gt; Snapshot-to-snapshot drift is recorded with &lt;code&gt;promote: false&lt;/code&gt;. It stays in the database so I can study it; it never becomes a public label.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk migrations collapse.&lt;/strong&gt; Three or more endpoints under one host making the identical move become one observation instead of a pile of 109.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lookalikes are their own class, and survive the collapse.&lt;/strong&gt; A move to a near-twin of the previous address is tagged even when it is part of a fleet migration — which is exactly where the naive version would have hidden it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The recorded payTo is chosen deterministically&lt;/strong&gt; on both the snapshot path and the probe path, so a reordered listing stops looking like a swap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The intra-burst check is rail-aware and time-bounded&lt;/strong&gt;, so multi-chain listings and per-payment rotation stop looking like attacks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then I deleted the damage: the 57 reputation rows the old logic had written, including the ones covering Browserbase and Tavily, purged from production.&lt;/p&gt;

&lt;p&gt;One honest caveat about the guard I put on this. A data-review check counts reputation rows carrying &lt;code&gt;honeypot:payto_swap&lt;/code&gt; and expects zero. It does not assert, and it &lt;em&gt;cannot&lt;/em&gt; tell a drift-written label from a genuine probe-written one, because the reputation row keeps no provenance. A real intra-burst catch would trip it too. Today it reads zero, which today means nothing has re-polluted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am telling you this
&lt;/h2&gt;

&lt;p&gt;Frisk is meant to be the neutral, auditable place an agent checks before it pays. That only means something if I am willing to audit it out loud — including the times it was wrong. A closed reputation API can quietly score Browserbase at zero and nobody outside ever knows.&lt;/p&gt;

&lt;p&gt;So here is the work: a plausible-sounding heuristic fired 272 times, scored two legitimate companies at zero for doing something &lt;em&gt;better&lt;/em&gt; than the standard, told callers it had actively probed addresses it had never touched, inflated its own evidence 109-to-1, and produced exactly one cluster that looked like a real attack — which turned out to be a typo. Zero of the 272 supported the claim the label made. And the check I had been holding up as the correct one had the same parser bug, on the only path that can write a label.&lt;/p&gt;

&lt;p&gt;The lesson is narrow and I think correct: &lt;strong&gt;do not promote an observation to a reputation claim unless the observation, by itself, supports the claim.&lt;/strong&gt; A payTo change seen once every few days does not — whether it looks innocent or looks like poisoning. An endpoint quoting two addresses for the same rail in the same breath does.&lt;/p&gt;

&lt;p&gt;The local, deterministic checks from the &lt;a href="https://tryfrisk.dev/blog/screening-x402-payments" rel="noopener noreferrer"&gt;first post&lt;/a&gt; are untouched by all of this — none of it changes what runs on your machine before a payment. What changed is what I am willing to say &lt;em&gt;about someone else's&lt;/em&gt; address, and how much evidence I now require before saying it.&lt;/p&gt;

&lt;p&gt;Frisk is MIT-licensed and the screening runs locally with no API key: &lt;a href="https://github.com/Jiangw2718i/frisk" rel="noopener noreferrer"&gt;github.com/Jiangw2718i/frisk&lt;/a&gt;. If you find a case where it is still wrong, that is the most useful bug report I can get — this post is what happens when someone does.&lt;/p&gt;

</description>
      <category>x402</category>
      <category>ai</category>
      <category>security</category>
      <category>web3</category>
    </item>
    <item>
      <title>Five ways an x402 payment can go wrong — and which ones you can catch before your agent pays</title>
      <dc:creator>Jiangw2718i</dc:creator>
      <pubDate>Sun, 21 Jun 2026 07:58:03 +0000</pubDate>
      <link>https://dev.to/jiangw2718i/five-ways-an-x402-payment-can-go-wrong-and-which-ones-you-can-catch-before-your-agent-pays-ob5</link>
      <guid>https://dev.to/jiangw2718i/five-ways-an-x402-payment-can-go-wrong-and-which-ones-you-can-catch-before-your-agent-pays-ob5</guid>
      <description>&lt;p&gt;x402 turns an HTTP &lt;code&gt;402 Payment Required&lt;/code&gt; into something an autonomous agent can actually act on: the server quotes a price, your agent pays on-chain (typically USDC on Base), the request goes through. No human in the loop. That is the whole point — and also the whole problem.&lt;/p&gt;

&lt;p&gt;The moment a program can move money without you watching, "discovery" and "payment" collapse into a single step. Your agent finds an endpoint and pays it in the same breath. If anything about that endpoint is wrong — the address, the price, the destination — the money is already gone by the time you would have noticed.&lt;/p&gt;

&lt;p&gt;There is now a small but real body of work on how this gets exploited. &lt;em&gt;&lt;a href="https://arxiv.org/abs/2605.11781" rel="noopener noreferrer"&gt;Five Attacks on x402&lt;/a&gt;&lt;/em&gt; and &lt;em&gt;A402&lt;/em&gt; (both on arXiv), Halborn's writeup, and AgentLISA's position paper each cover a different slice of the problem; taken together they sketch the surface. I read through them while building &lt;a href="https://github.com/Jiangw2718i/frisk" rel="noopener noreferrer"&gt;Frisk&lt;/a&gt;, an open-source pre-transaction screening library, and I want to do something specific in this post: walk the documented attacks, and for each one, be honest about whether you can catch it &lt;strong&gt;locally, before the payment, in your own code&lt;/strong&gt; — or whether it fundamentally requires data you don't have on your machine.&lt;/p&gt;

&lt;p&gt;That line — local-and-deterministic vs. needs-reputation-data — turns out to be the most useful way to think about agent payment safety. So let's draw it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attack surface
&lt;/h2&gt;

&lt;p&gt;Here is my own taxonomy, assembled from those sources plus the V2 spec. Two of the five — the &lt;code&gt;payTo&lt;/code&gt; swap and insecure transport — are extrapolated from what V2 makes possible rather than enumerated as attacks in any of the papers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic &lt;code&gt;payTo&lt;/code&gt; swap.&lt;/strong&gt; In x402 V2 the destination address can change per request. A seller (or a man-in-the-middle) quotes you address A, then returns address B in the actual payment requirement. Your agent pays B.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Malicious 402 / overcharging.&lt;/strong&gt; The endpoint quotes an absurd price, or a price that drifts upward across calls, and a naive agent just pays whatever the &lt;code&gt;402&lt;/code&gt; says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Insecure transport.&lt;/strong&gt; The quote — including the address you're about to pay — arrives over plaintext HTTP, where anyone on the path can rewrite it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sybil-induced discovery.&lt;/strong&gt; An attacker floods a discovery surface with fake, high-usage-looking endpoints to steer your agent toward a wallet they control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt-injection-to-payment.&lt;/strong&gt; Content the agent reads convinces it to send funds somewhere it shouldn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the part nobody says out loud: &lt;strong&gt;some of these are checkable with pure local logic, and some are not.&lt;/strong&gt; Conflating them is why "agent payment security" sounds harder than it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can catch locally, before the payment
&lt;/h2&gt;

&lt;p&gt;Vectors 1, 2, and 3 are &lt;em&gt;structural&lt;/em&gt;. You don't need a reputation graph or a threat feed to catch them — you need a few deterministic checks run against the request the instant before your agent signs it. No network call, and no runtime dependency on my service.&lt;/p&gt;

&lt;p&gt;This is exactly the slice Frisk's &lt;code&gt;lite&lt;/code&gt; mode handles. It runs entirely on your machine, ships with zero runtime dependencies, and returns a verdict — &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;review&lt;/code&gt;, or &lt;code&gt;block&lt;/code&gt; — with reasons. Here's the whole thing in use:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;frisk-screen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// lite mode, no API key&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0x9a3f1b2c3d4e5f60718293a4b5c6d7e8f9a0bc12&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.seller.x402/quote&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USDC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;observedPayTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// what the endpoint actually told us to pay&lt;/span&gt;
  &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxPerCall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;allowedAssets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USDC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// "review", ["payTo differs from the expected counterparty"]&lt;/span&gt;
  &lt;span class="c1"&gt;// (trustScore 40 — lite starts at 70 and takes 30 off for the swap;&lt;/span&gt;
  &lt;span class="c1"&gt;//  `block` needs a score under 20, so lite escalates rather than decides)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(There's a Python package with the same checks — &lt;code&gt;pip install frisk-screen&lt;/code&gt; — but not the same signature: it is synchronous and snake_cased, &lt;code&gt;client.screen(addr, observed_pay_to=..., policy=Policy(max_per_call=5.0))&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;Now map each check back to an attack:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic &lt;code&gt;payTo&lt;/code&gt; swap → catch it.&lt;/strong&gt; You know the counterparty you &lt;em&gt;intended&lt;/em&gt; to pay. You also have the &lt;code&gt;payTo&lt;/code&gt; the endpoint actually returned. If they differ, that's the V2 swap attack, and it's a string comparison. V2 makes a per-request &lt;code&gt;payTo&lt;/code&gt; legitimate for marketplaces and multi-tenant APIs, so the useful question is not "is this address dynamic" but "did it move away from the address I pinned" — which only works if you have an intended counterparty of your own:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;observedPayTo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;observedPayTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;counterparty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// the address moved between quote and payment — don't pay&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most valuable local check, because the swap is invisible to a human reviewing code — it only happens at runtime, per request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overcharging → catch it with policy.&lt;/strong&gt; You can't know the "fair" price of an arbitrary endpoint without market data, but you absolutely know &lt;em&gt;your own&lt;/em&gt; limits. A per-call ceiling and an asset allowlist are deterministic and offline:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxPerCall&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxPerCall&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* review */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedAssets&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedAssets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* review */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This won't tell you a $2 call should cost $0.05. It &lt;em&gt;will&lt;/em&gt; stop your agent from silently paying $400 because a malicious &lt;code&gt;402&lt;/code&gt; said so. Most overcharging damage is just the absence of a spending limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Insecure transport → catch it.&lt;/strong&gt; If the quote that carries the payment address came over &lt;code&gt;http://&lt;/code&gt;, the address is untrustworthy on arrival. Refuse to act on it:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* downgrade */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus the obvious hygiene: is the counterparty even a well-formed address? A malformed counterparty is either a bug or a probe, and either way you shouldn't pay it. (Lite also has a seed-blocklist hook — an offline check against known-bad addresses. The list bundled in the published packages is empty today, so this check currently never fires; the live, continuously updated list is the part that belongs to the hosted service.)&lt;/p&gt;

&lt;p&gt;That's a handful of deterministic checks, all running before a single token moves, all in code you can read in &lt;a href="https://github.com/Jiangw2718i/frisk/blob/main/typescript/src/lite.ts" rel="noopener noreferrer"&gt;one file&lt;/a&gt;. No service to trust. This is the floor every x402 agent should have, and it's the part I made free and MIT precisely because it shouldn't be behind anyone's API — including mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you cannot catch locally — and where I'll be honest
&lt;/h2&gt;

&lt;p&gt;Vectors 4 and 5 — &lt;strong&gt;Sybil discovery&lt;/strong&gt; and &lt;strong&gt;prompt-injection-to-payment&lt;/strong&gt; — are different in kind. A locally-running function genuinely cannot know that an address belongs to a Sybil cluster, or that an endpoint with a clean-looking history has been quietly draining wallets for a week. That requires &lt;em&gt;reputation data&lt;/em&gt;: a graph of who-paid-whom across many agents, accumulated over time. No amount of clever offline code substitutes for it.&lt;/p&gt;

&lt;p&gt;And there's a third category the papers above actually spend most of their pages on, which no screening library — lite &lt;em&gt;or&lt;/em&gt; hosted — should claim to fix: the &lt;strong&gt;protocol- and settlement-layer attacks&lt;/strong&gt;. Payment replay; the settlement races where a server delivers before payment finalizes (the "paid-but-denied" and "unpaid-service" outcomes the &lt;em&gt;Five Attacks&lt;/em&gt; paper centers on); and facilitator trust. Those live in the x402 spec, the facilitator, and the on-chain settlement path — not in the request your agent is about to sign. Frisk screens the &lt;em&gt;counterparty and the shape of the transaction&lt;/em&gt;; it does not, and cannot, repair the protocol underneath it. So this post is deliberately scoped to the vectors a pre-payment check can actually touch — pretending a screening call closes a replay or atomicity hole would be the other half of how agent-payment security gets oversold.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;lite&lt;/code&gt; mode is upfront about this: it always reports &lt;code&gt;"low"&lt;/code&gt; confidence and it does not claim to detect Sybil attacks. Pretending a local check can catch a reputation problem is how you ship false confidence, which is worse than no check at all.&lt;/p&gt;

&lt;p&gt;This is the line between the open-source library and the hosted service — and I'd rather state it plainly than blur it for a pitch. The hosted side of Frisk is where reputation history and threat intelligence would live, and it is early; the part I'm comfortable telling every x402 developer to install &lt;em&gt;today&lt;/em&gt; is the deterministic floor above. If you're shipping an agent that pays, start there. The five checks cost you nothing and close the attacks that are actually closeable in your own process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Agent payment safety isn't one problem. It's three: structural checks you can do before paying, reputation you have to source from data, and protocol/settlement holes that sit below any screening call. Solve them separately — and don't let a tool for one pretend to cover the others.&lt;/li&gt;
&lt;li&gt;The deterministic floor — payTo-swap detection, spending policy, transport and address sanity — covers three of the five failure modes in my own list above — none of which are the protocol- and settlement-layer attacks &lt;em&gt;Five Attacks&lt;/em&gt; enumerates — with no network call and no runtime dependency on my service. Ship it.&lt;/li&gt;
&lt;li&gt;Be skeptical of anything claiming to detect Sybil/reputation attacks with purely local logic. That category needs data, and honesty about the boundary is the whole game.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Frisk is MIT and the lite engine is dependency-free: &lt;a href="https://www.npmjs.com/package/frisk-screen" rel="noopener noreferrer"&gt;&lt;code&gt;npm i frisk-screen&lt;/code&gt;&lt;/a&gt; / &lt;a href="https://pypi.org/project/frisk-screen/" rel="noopener noreferrer"&gt;&lt;code&gt;pip install frisk-screen&lt;/code&gt;&lt;/a&gt;. The source and the (short, readable) check logic are on &lt;a href="https://github.com/Jiangw2718i/frisk" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you're building on x402 and you catch a vector I missed, open an issue — that's exactly the kind of thing this should accrete.&lt;/p&gt;

&lt;p&gt;The reputation-backed hosted tier is in early access. If that's the part you need, email &lt;a href="mailto:support@tryfrisk.dev"&gt;support@tryfrisk.dev&lt;/a&gt; — but the deterministic floor above is free, MIT, and yours to ship today regardless.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources referenced: "Five Attacks on x402" and "A402" (arXiv); Halborn's x402 security writeup; AgentLISA x402 security position paper.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>x402</category>
      <category>ai</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
