In my last post I listed the ways an x402 payment can go wrong. Vector number one was the dynamic payTo swap: 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.
So I built the check, wired it into Frisk'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 payTo, and if it changes, that is a swap, so flag the counterparty.
Over about a month it recorded 272 payTo changes across 246 endpoints, and promoted them into 57 honeypot:payto_swap reputation labels.
Then I looked at what it had actually flagged. The endpoint with the most flags in the entire dataset was x402.browserbase.com, with 9. Second was x402.tavily.com, with 8. Browserbase and Tavily are real, funded infrastructure companies. My detector had scored them 0 and told callers they were honeypots.
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.
What the label actually said
Before the excuses, the exact damage. A honeypot:payto_swap label drives the trust score to 0 and raises a high severity signal. At the default strictness of 0.3 that returns a review verdict, escalating to block 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.
Worse is the reason string that came with it, which was hardcoded:
observed in active probing: payto_swap
Enter fullscreen mode Exit fullscreen mode
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.
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.
What a rotating crawl can actually see
Here is the whole flawed idea in one line: the address changed, therefore something is wrong.
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 14,345 today, drifting by a few between pulls. Against a registry that size an endpoint comes back around every three to five days, not daily, and the tail goes longer.
It is worse than that. While writing this I checked the crawl cursor and found it had been stuck since 2026-07-23. 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.
So when the crawler sees address A on one visit and address B on the next, all it knows is A -> B, somewhere inside a multi-day gap. That fact is identical whether:
- an honest operator rotated its receiving wallet on a schedule, or migrated its whole fleet to a new treasury address, or
- an attacker quietly swapped in a wallet they control.
Nothing in A -> B, observed days apart, distinguishes those. The address change is not evidence of fraud. It is evidence of an address change.
| count | |
|---|---|
| payTo changes recorded | 272 |
| distinct endpoints affected | 246 |
| distinct addresses labeled | 57 |
| accounted for by a single fleet migration | 109 (40%) |
| matched the address-poisoning lookalike shape | 13 |
| that the crawl could prove were an attack | 0 |
The vendors were not rotating nine times. I was sampling nine times.
The first thing that fell apart was my own headline number.
Browserbase's endpoint produced 9 recorded changes, Tavily's 8. But when I lined the rows up, no address ever appeared twice. 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.
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 per endpoint: one endpoint each, changing every single visit.
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:
x402.tavily.com 0x2a09d4fd…fa09a -> 0x68459d07…091af -> 0x043d2e40…29c7
x402.browserbase.com 0x61ce240e…0633e -> 0x100f307A…13834 -> 0xC09B2303…be12
Enter fullscreen mode Exit fullscreen mode
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.
Tavily also publishes something worth reading carefully. Its listing quotes two rails, and the second one is not an address at all:
eip155:8453 0x043d2e40ced48199415f3b2463d0bfab4fc029c7
aws:base urn:x402:agent-pay:see-quote
Enter fullscreen mode Exit fullscreen mode
A placeholder meaning the address is decided when you ask for a quote. 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.
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.
Forty percent of my evidence was one event
Of the 272 changes, 109 came from a single host on a single day, 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.
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.
The 13 that looked like an attack
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 one character:
0xb3c2776ce3f99…942ab6
0xb3c2776ce4f99…942ab6
Enter fullscreen mode Exit fullscreen mode
Forty-two characters counting the 0x; 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.
It is also, on inspection, impossible to be that.
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 thirty-nine of forty 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.
The rows do not describe a swap, either. Ten endpoints moved from …ce3… to …ce4…, 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.
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 exact scheme settles USDC through EIP-3009 transferWithAuthorization, 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.
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.
And one of the false positives was mine alone
The fourth failure was not in the data. It was in my parser.
An x402 listing quotes the same resource on several chains, and each quote carries its own payTo. On the page I sampled, 443 of 1,000 listings advertise more than one distinct payment address at the same time — 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 counterparty per endpoint, taken from accepts[0].
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.
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 almost none: 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.
The fix is boring: pick deterministically instead of positionally.
// A listing quoted on several chains carries several payTo values. Taking
// accepts[0] makes the address we record depend on the order the registry
// returns, so a reordering upstream reads as a payTo change. Pick the lowest
// payTo instead: any deterministic rule removes the artifact.
const quoted = accepts.filter((accept) => accept.payTo);
if (quoted.length <= 1) {
return quoted[0] ?? accepts[0];
}
return quoted.reduce((lowest, accept) =>
(accept.payTo ?? "").toLowerCase() < (lowest.payTo ?? "").toLowerCase()
? accept
: lowest,
);
Enter fullscreen mode Exit fullscreen mode
The signal I confused for a swap — and how it was also broken
There is a real dynamic-payTo attack, and it is genuinely catchable before you pay. I had aimed the label at the wrong observation.
The real attack lives inside a single burst of requests. 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.
That last qualifier is doing a lot of work, and until this week my code did not implement it. The active probe unioned every payTo across probes with no notion of which chain each quote was for, and read accepts[0] exactly like the snapshot job did — on the only code path allowed to write a reputation label. So a multi-chain endpoint whose accepts 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.
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:
// The dynamic-payTo attack is two addresses offered in one breath. Honest
// operators that mint a fresh address per payment also show two addresses if
// you watch them long enough — the busiest listings in the Bazaar advertised a
// different address on every pull hours apart, and never repeated one across a
// month of crawling — so the burst has to be tight enough that ordinary
// rotation cannot explain it.
const BURST_WINDOW_MS = 10_000;
Enter fullscreen mode Exit fullscreen mode
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 not promote:
// Longitudinal drift: address differs from the prior snapshot of this endpoint.
// At crawl cadence this looks identical whether it is a scheduled key rotation
// by an honest vendor or a rug, so we record it as an observation and never
// write a reputation label from it.
findings.push({
behavior: "payto_swap",
detail: isLookalike(from, to)
? `payTo changed to a lookalike of the prior address: ${from} -> ${to}`
: `payTo changed since prior snapshot: ${from} -> ${to}`,
promote: false, // recorded, never a reputation label
});
Enter fullscreen mode Exit fullscreen mode
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 honeypot_observations contains zero 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.
What changed
Five changes, all in the direction of claiming less:
-
Only intra-burst rotation promotes. Snapshot-to-snapshot drift is recorded with
promote: false. It stays in the database so I can study it; it never becomes a public label. - Bulk migrations collapse. Three or more endpoints under one host making the identical move become one observation instead of a pile of 109.
- Lookalikes are their own class, and survive the collapse. 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.
- The recorded payTo is chosen deterministically on both the snapshot path and the probe path, so a reordered listing stops looking like a swap.
- The intra-burst check is rail-aware and time-bounded, so multi-chain listings and per-payment rotation stop looking like attacks.
Then I deleted the damage: the 57 reputation rows the old logic had written, including the ones covering Browserbase and Tavily, purged from production.
One honest caveat about the guard I put on this. A data-review check counts reputation rows carrying honeypot:payto_swap and expects zero. It does not assert, and it cannot 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.
Why I am telling you this
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.
So here is the work: a plausible-sounding heuristic fired 272 times, scored two legitimate companies at zero for doing something better 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.
The lesson is narrow and I think correct: do not promote an observation to a reputation claim unless the observation, by itself, supports the claim. 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.
The local, deterministic checks from the first post 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 about someone else's address, and how much evidence I now require before saying it.
Frisk is MIT-licensed and the screening runs locally with no API key: github.com/Jiangw2718i/frisk. 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.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.