Portreeve
how-to · 7 Sept 202613 min read

x402 middleware fraud: screening the payer wallet in Hono

x402 middleware fraud screening in Hono: decode the payer wallet from PAYMENT-SIGNATURE, score velocity, and block bad payments before they settle on Base.

By the end of this you will have a Hono seller endpoint that pulls the payer wallet out of an incoming x402 payment, scores it against velocity and cluster history, and refuses the request before your facilitator settles anything on chain. About forty lines of middleware. x402 middleware fraud screening has one structural rule: the check sits in front of paymentMiddleware, never behind it, because once an EIP-3009 authorization has settled there is nobody to call.

You need Node 18.17+, a Hono app on @x402/hono v2 (@x402/core, @x402/evm, a facilitator URL, a payTo address), and a Portreeve secret key. Everything below is the exact scheme on EVM. Solana payloads carry a different authorization shape and the wallet extraction is not the same.

The two places you can stand

x402 v2 is three headers. Your server answers an unpaid request with 402 and a base64 PAYMENT-REQUIRED header describing what it accepts. The client signs an authorization and retries with PAYMENT-SIGNATURE. Your server verifies, settles, and returns the resource plus PAYMENT-RESPONSE. The seller quickstart has the full wiring.

Two hooks can hold a decision. Your own Hono middleware ahead of paymentMiddleware, or onBeforeSettle on the x402ResourceServer, which runs after the facilitator verifies the signature and stops the transfer by returning { abort: true, reason }.

Start with the first. onBeforeSettle gets a settle context, not the HTTP request, so no IP and no headers, and it fires only after a facilitator round trip you have already paid for in latency. Standing in front costs you one thing: the from address in PAYMENT-SIGNATURE is a claim, not a proven fact, until verification runs. The last section deals with that.

Step 1: the middleware skeleton

Order is the design. Hono runs middleware in registration order, so the screen has to be registered before the payment middleware or it will never see an unsettled payment.

// src/index.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { paymentMiddleware, x402ResourceServer } from "@x402/hono";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { screenPayment } from "./screen.js";

const app = new Hono();

const resourceServer = new x402ResourceServer(
  new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL! }),
).register("eip155:84532", new ExactEvmScheme());

app.use(screenPayment); // must be registered first

app.use(
  paymentMiddleware(
    {
      "GET /extract": {
        accepts: [
          {
            scheme: "exact",
            price: "$0.05",
            network: "eip155:84532",
            payTo: process.env.PAY_TO_ADDRESS!,
          },
        ],
        description: "Structured extraction",
        mimeType: "application/json",
      },
    },
    resourceServer,
  ),
);

app.get("/extract", (c) => c.json({ fields: { total: 431.22 } }));

serve({ fetch: app.fetch, port: 4021 });

When this works, curl -i localhost:4021/extract still returns 402 with a payment-required header and your screen does nothing at all. Invisible on the challenge leg is the correct behaviour.

Step 2: get the wallet out of the header

PAYMENT-SIGNATURE is base64 JSON. In v2 the envelope is { x402Version, resource, accepted, payload }, where accepted is the PaymentRequirements the client picked and payload is scheme-specific. For exact on EVM the payload is an EIP-3009 signature plus an authorization: from, to, value, validAfter, validBefore, nonce.

Two of those fields carry the post. from is the payer wallet. nonce is unique per authorization and sits inside the signed message, which makes it a free deduplication key.

// src/decode.ts
export function decodePaymentSignature(header: string | undefined) {
  if (!header) return null;
  try {
    const body = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
    const auth = body?.payload?.authorization;
    const accepted = body?.accepted;
    if (!auth?.from || !auth?.nonce) return null;
    if (accepted?.scheme !== "exact") return null;
    if (!String(accepted?.network ?? "").startsWith("eip155:")) return null;
    return {
      wallet: String(auth.from).toLowerCase(),
      value: String(auth.value ?? "0"),
      nonce: String(auth.nonce),
    };
  } catch {
    return null;
  }
}

Return null rather than throwing. This is attacker-controlled input that nothing has signature-checked yet, and a malformed header is not yours to diagnose: the payment middleware behind you rejects it a millisecond later with a proper protocol error.

The IP needs its own function, because the obvious version is wrong.

// src/ip.ts
import type { Context } from "hono";
import { getConnInfo } from "@hono/node-server/conninfo";

export function clientIp(c: Context) {
  // Behind exactly one trusted proxy, the hop your proxy appended is the right-most entry.
  const xff = c.req.header("x-forwarded-for");
  if (xff) return xff.split(",").pop()!.trim();
  return getConnInfo(c).remote.address ?? "";
}

Take the right-most x-forwarded-for entry. The left-most is whatever the caller typed.

Step 3: screen x402 payments with the wallet as identity

There is no Portreeve x402 package, in beta or shipping next week, and I would rather say so than wrap one function call in a package name. What exists is identity.payer_wallet as a hard identity key on the ordinary verdict() call: EVM addresses are normalized and hashed like every other key, a wallet seen across two accounts links them, and a cluster confirmed as abusive blocks that wallet on sight. The integration is a checkout screen with a wallet where the card would be.

// src/screen.ts
import { createMiddleware } from "hono/factory";
import { Portreeve } from "portreeve";
import { decodePaymentSignature } from "./decode.js";
import { clientIp } from "./ip.js";

const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);

export const screenPayment = createMiddleware(async (c, next) => {
  const payment = decodePaymentSignature(c.req.header("payment-signature"));

  // No payment yet: let the x402 middleware issue its 402 challenge.
  if (!payment) return next();

  const result = await portreeve.verdict({
    event_type: "checkout_attempt",
    external_user_id: payment.wallet, // no account exists; the wallet is the account
    ip: clientIp(c),
    identity: { payer_wallet: payment.wallet },
    payment: {
      // USDC carries six decimals, so atomic units divide by 10,000 to reach cents.
      amount: Math.round(Number(payment.value) / 10_000),
      currency: "USD",
    },
    dedupeKey: `x402:${payment.nonce}`,
  });

  // ... decide, next section
});

Stripe's own x402 sample runs the identical conversion before recording a settled transfer as a PaymentIntent, then skips anything under a cent. Below a cent the amount stops carrying information. A $0.001 call rounds to 0, and at that price the wallet and the rate are the whole decision.

The dedupeKey is what the nonce is for. x402 clients retry, facilitators time out, and your own error path may screen the same authorization twice. A repeat with the same dedupeKey returns the original verdict instead of a 409, so one signed authorization counts once against velocity however many times it arrives. An attacker who wants a second count has to sign a second authorization with a fresh nonce, which is a different payment and should count. Reach for idempotencyKey only when you want raw per-attempt semantics. The event payload reference lists the rest of the fields.

Step 4: act before settlement

  if (result.verdict === "block") {
    return c.json({ error: "payment_rejected" }, 403);
  }

  c.set("portreeveEventId", result.id);
  await next();

Return 403, not 402. A 402 tells a well-behaved agent client to read the payment requirements and pay again, which turns your block into a retry loop with a wallet you already convicted. Keep the body generic: payment_rejected says nothing about which counter tripped, and your reason codes stay in the dashboard.

A review verdict proceeds. The request is served, the payment settles, the event lands in a queue, and a later denial arrives on your server as a signed webhook. x402 has no clawback: settlement is a push transfer of USDC with no dispute rail behind it, so holding a paying request on suspicion costs you a real sale and buys nothing. What the denial buys you is the next request.

app.post("/webhooks/portreeve", async (c) => {
  const raw = await c.req.text();
  const event = Portreeve.verifyWebhook(
    raw,
    c.req.header("portreeve-signature") ?? "",
    process.env.PORTREEVE_WEBHOOK_SECRET!,
  );

  if (event.type === "review.resolved" && event.resolution === "denied") {
    await killList.add(event.external_user_id); // the wallet
  }
  return c.body(null, 204);
});

Deliveries retry until they get a 2xx, so keep the handler idempotent. Adding a wallet to a set twice already is. A denial says revoke, and for a caller who is only a wallet, revoking means refusing it at your own edge. Handling verdicts covers the full pattern.

After settlement there is no decision left, only a row to append. What settlement changes is the identity: the wallet stops being a claim and becomes a fact, which is when feedback is safe to send. When one wallet turns out to have enumerated your entire corpus at five cents a call, address the feedback to the account rather than a single event, which marks the whole linked cluster:

await portreeve.feedback(
  { kind: "external_user_id", value: wallet },
  "confirmed_abuse",
  "x402: full-corpus enumeration",
);

Step 5: verify it in test mode

Point PORTREEVE_SECRET_KEY at your sk_test_ key and FACILITATOR_URL at https://x402.org/facilitator, with eip155:84532 for Base Sepolia. Test mode is fully isolated from live, so nothing here touches your production graph.

  1. curl -i http://localhost:4021/extract. Expect 402 and a payment-required header, and no event in the dashboard. There was no payment to screen.
  2. Run a paying client with a funded Sepolia test wallet. Expect 200 and a checkout_attempt event in the test feed carrying the hashed wallet, the amount in minor units, and reason codes.
  3. Fire twenty calls in a minute from that wallet and watch the reason codes as the cluster's velocity counters climb.
  4. Convict it: await portreeve.feedback({ kind: "external_user_id", value: wallet }, "confirmed_abuse").
  5. Repeat the paid request. Expect 403, no facilitator call, no on-chain transfer. That last part is the real test. If a transaction hash exists, your middleware is in the wrong order.

Do not expect step 3 to reach block. The two block-strength card rules read a device-keyed counter and have nothing to do with a wallet payment, so a fresh wallet tops out at review. Step 4 is how you exercise the block path deterministically.

Payer wallet identity is a speed bump

Know the size of the bump before you lean on it. Generating an EVM keypair is free and offline. The entire cost of a fresh identity against your endpoint is one funding transfer, so a per-wallet cap of five requests becomes two hundred wallets doing five each. Counting the thing the attacker mints for nothing is the same failure mode as rate-limiting by email address.

IP will not rescue you, and this is where x402 differs from a signup form. Your buyers are agents. They run in datacenters, behind a small number of cloud egress ranges, which makes "datacenter IP" the population rather than the signal. Per-IP counting is untrustworthy for the same reason it is behind a corporate NAT, and it should never link two payers on its own. Velocity checks only bite once a linkage exists.

What forms that linkage is the wallet turning up next to another identity key elsewhere in your product: the same address at a signup with an email, or at a card checkout on your web tier. Then the graph has an edge and a conviction on one side reaches the other. So the one change worth making, when it applies: if the caller also holds an account or an API key, pass your account id as external_user_id and leave the wallet in identity.payer_wallet. Two hundred wallets under one account collapse into one cluster.

Sellers are already up against this. Describing an agent-to-agent marketplace on Hacker News, mt2user put the model plainly: "Registration is permissionless: POST an agent card with an endpoint and a payout address and you're live," with a score "weighted by real payments and distinct payers" standing in for reputation. Distinct payers is the right instinct and the exact quantity a wallet farm is cheapest to fake.

What to change for production, and what people get wrong

Switch to eip155:8453 and an authenticated facilitator. Coinbase's CDP facilitator covers Base mainnet, and Stripe's integration settles through it before recording the transfer as a PaymentIntent in transaction_verification mode, which puts machine revenue in the same ledger as your card revenue.

The mistake that matters most is treating the decoded from as authenticated. It is not. Your middleware runs before verification, so anyone can hand you a base64 blob naming any address. The signature fails and nothing settles, so they gain nothing on the payment, but they can attach attempts to a wallet they do not control. That is noise in your counters and, at volume, a way to push a stranger's clean address toward review. Use both mitigations: send confirmed_abuse only for payments that actually settled, and if you can live without the IP, move the screen to onBeforeSettle, where the facilitator has already proven the signature recovers to from.

The next one is screening the unpaid probe. The first request carries no PAYMENT-SIGNATURE at all, and screening it burns quota on something that cannot cost you money. The if (!payment) return next() guard is load-bearing.

The last is going fail-closed by reflex. The SDK fails open by default: on a timeout or an outage it returns allow with degraded: true and a *_failopen reason code inside a 400 ms budget. For a five-cent endpoint that is correct: an outage that rejects paying agents costs you revenue on every request. Flip the client-side fail-closed option only when serving the resource unscreened is worse than not selling it, which is a real position if a call costs you a GPU-minute.

The ecosystem itself is younger than the marketing suggests. A July 2026 study of 15 x402 facilitators serving more than 60,000 sellers derived four attack vectors, including Free Shopping and Gas Abuse, from violations of the eight facilitator security rules its authors define, and found violations in every facilitator evaluated. Screening the payer is the layer you control. Picking a facilitator whose verification you trust is the layer you do not.

What is missing today

On-chain wallet history is not read. The wallet is an identity key and a velocity anchor and nothing else. Funding source, address age, prior counterparties: none of those are signals yet, so do not plan around them. Ask any vendor who claims them which chain state they query and how recently.

The protocol side is moving too. An open proposal, issue #2299 from vdineshk, would add a trust-provider extension that queries external scoring through onBeforeSettle, and it opens on an accurate observation: "x402 settles payments between agents, but has no mechanism to evaluate whether the paying agent should be trusted before settlement occurs." Until something like that lands in the spec, the middleware above is the mechanism. The longer argument for why wallets belong in an identity graph is in the x402 post.

Portreeve is in open beta with no uptime SLA yet. The free tier screens 1,000 events a month with no card, which covers a testnet integration and a quiet launch: create a key and run the test-mode loop above.

← Back to all posts