Skip to content
· 6 min

A txHash Is a Bearer Token — We Bound Ours to the Wallet That Paid

On Arc's self-settle x402 rail the txHash is a bearer credential — public in the mempool, spendable by whoever presents it first (Attack I-B). agentbadge-pay:v1 binds the payment to the payer's wallet: an EIP-191 signature over wallet/method/path/payref/timestamp, checked against the on-chain Transfer.from before the replay slot is ever touched.

AB
AgentBadge Team
Agency for the Agentic Web
A txHash Is a Bearer Token — We Bound Ours to the Wallet That Paid

Payer binding (agentbadge-pay:v1) prevents payment sniping on self-settle x402: the payer signs a canonical challenge naming their wallet and the txHash; the server recovers the signer and compares it to the on-chain Transfer.from BEFORE claiming the replay slot — so a sniper's rejection never consumes the victim's payment.

On Arc's self-settle payment rail the buyer broadcasts the USDC transferWithAuthorization themselves and hands the server the transaction hash as proof. Convenient — and a bearer credential: the txHash sits in the public mempool before it confirms, and whoever presents it to the paid endpoint first gets the content. This week we closed that hole on every AgentBadge paid surface with agentbadge-pay:v1 — a one-line signature that binds a payment transaction to the wallet that made it, verified before the replay slot is ever touched.

This is article C10 in the Arc Campaign series. It completes the payment-hardening arc started by C4 (wallet allowances) and C13 (owner controls): those decide what a wallet may spend — this one decides who is allowed to present a payment.

A transaction hash being pulled out of a public mempool by a second bot — the paid request that wasn't yours

Can someone else spend your agent's payment?

On the self-settle rail, yes — if the endpoint only looks at the txHash. The attack is Attack I-B from the x402 threat analysis (arXiv:2605.11781) and it's mechanical:

  1. Your agent pays — broadcasts the USDC transfer and waits for the response.
  2. A sniper reads the txHash straight out of the mempool, builds its own PAYMENT-SIGNATURE payload pointing at your transaction, and fires it at the paid route first.
  3. The server sees a valid, unspent txHash, consumes the replay slot, and serves the sniper. When your agent's real request arrives, it's a "replay" — your money paid for someone else's answer.

The txHash is proof a payment happened. It is not proof that the request came from the payer.

Attack flow: victim broadcast → mempool → sniper races the request with the foreign txHash → the first presenter wins the replay slot

What does payer binding actually check?

The fix adds three headers to a payment request — X-Wallet, X-Sig, X-Timestamp — carrying an EIP-191 signature over a canonical challenge string:

agentbadge-pay:v1
wallet:<payer address, lowercase>
method:POST
path:/api/eaas/verdicts
payref:<txHash, lowercase>
timestamp:<unix seconds, ±300s drift>

Server-side, the check happens before payment verification — order matters, because verification is what burns the replay slot:

  1. inspect() the receipt without claiming — read the USDC Transfer log, extract the on-chain from. The dedup store is untouched, so a rejection never poisons the payment. (Peek and verify share a 30-second receipt cache — the whole check costs one RPC call.)
  2. Recover the EIP-191 signer of the challenge and compare it to X-Wallet and to the on-chain payer. A valid signature over someone else's txHash is still a rejection — that's the snipe.
  3. Reject with 403 WRONG_SIGNER (or 402 payer_binding_required when headers are absent on a txHash payload). Gateway and exact-rail payments carry no txHash, so they skip binding entirely.

The sniper can still see your txHash — it just can't turn it into a ticket anymore. Presenting it now requires your signature, and a rejected attempt leaves the slot intact for your real request. The e2e snipe test proves the ordering: attacker's foreign-txHash request gets 403 with seenTxHashes empty; the legit request then goes through 200; a third call hits replay-402 — the slot lifecycle, intact.

Bound flow: sign agentbadge-pay:v1 → X-Wallet/X-Sig/X-Timestamp → claim-free receipt peek → signer==payer compare → verify, claim, settle

Challenge string anatomy — the five canonical fields of agentbadge-pay:v1

How does an agent pay with binding enabled?

When the gate is on, every 402 advertises it — extensions.payerBinding and each accepts[].extra.payerBinding carry {required: true, challenge: "agentbadge-pay:v1", headers: [...]}. Full spec lives at https://agentbadge.xyz/payer-binding.md.

Client-side it's three extra lines — buildPayerChallenge builds the string, signPayerChallenge signs it:

import {
  buildPayerChallenge,
  signPayerChallenge,
} from "@agentbadge/circle-payments";

const timestamp = Math.floor(Date.now() / 1000);
const signature = await signPayerChallenge(account, {
  wallet: account.address,
  method: "POST",
  path: "/api/eaas/verdicts",
  payRef: txHash,      // the tx you just broadcast
  timestamp,
});

fetch("https://agentbadge.xyz/api/eaas/verdicts", {
  method: "POST",
  headers: {
    "payment-signature": paymentB64, // x402 payload with txHash
    "x-wallet": account.address,
    "x-sig": signature,
    "x-timestamp": String(timestamp),
  },
  body: JSON.stringify(payload),
});

Request anatomy — PAYMENT-SIGNATURE plus the three binding headers

How do you verify a bound request yourself?

The binding is checkable without trusting us. Two facts, one receipt:

  • The signature — recoverMessageAddress over the canonical challenge yields X-Wallet. Anyone can rebuild the exact string — the spec is public and the format is five lines.
  • The payer — the txHash's USDC Transfer(from, to, value) log gives the on-chain payer. If the two don't match, the request is a snipe attempt — no matter how valid the signature looks.

That's the decoder story: no registry, no allowlist, no server state — a signature, a receipt, and a comparison anyone can re-run.

Peek vs claim — the receipt is read without burning the replay slot

Why a signature and not a redeem token?

The same hole is closed elsewhere by a server-issued redeem_token — a two-phase quote → pay → redeem where only the quote holder can present the payment. That works, but it's a second round-trip and a server-side token store per payment.

Ours is stateless. The replay dedup (txHashStore) already existed; the binding rides inside the same request as the payment proof. A sniper that grabbed the txHash still can't produce a signature over a challenge that names its own wallet as payer of that tx — the on-chain from won't match. Stateless, one round-trip, and the challenge format is stable enough for agents to cache.

Comparison card — stateless payer binding vs two-phase redeem_token

Honest status

Shipped across five paid surfaces — EaaS, marketplace, scan-packs, keeperhub, bstock — behind PAYER_BIND_ENABLED (off until production dogfood) with a per-group kill-switch PAYER_BIND_DISABLED_GROUPS. The snipe lifecycle is proven in an e2e test on the real stack — real createArcSelfSettleHandle, real router, real EIP-191 recovery — and a live dogfood script (scripts/payer-bind-dogfood.mts) replays the attack on testnet: funded PAYER_KEY + a stranger ATTACKER_KEY, and it asserts 403 → 200 → replay-402 in order.

The remaining gap is deliberate: the gate ships off by default until a funded testnet run confirms third-party clients bind correctly — the dogfood script is the last check before flipping it on.

Try it

# the spec (machine-readable, for agents)
curl -s https://agentbadge.xyz/payer-binding.md

# the live snipe test (needs Arc testnet USDC on PAYER_KEY)
ENDPOINT=https://agentbadge.xyz \
PAYER_KEY=0x... ATTACKER_KEY=0x... \
bun run scripts/payer-bind-dogfood.mts
  • Binding spec + helpers: packages/circle-payments/src/payer-bind.ts — buildPayerChallenge, signPayerChallenge
  • Claim-free peek: arc-self-settle.ts handle.inspect()
  • E2E proof: hackathon/server/tests/payer-binding-e2e.test.ts

This is C10 in the Arc Campaign series. Earlier: C15 turned every 402 into a self-declaring catalog entry; C4 and C13 built the wallet controls this binding sits behind.

Don't certify. Measure. — and now, don't just measure: bind the payment to the wallet that made it.