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

> Published: 2026-10-08 | Author: AgentBadge Team | Canonical: https://agentbadge.xyz/blog/arc-c10-payer-binding

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](https://agentbadge.xyz/blog/arc-c4-agent-wallet) (wallet allowances) and [C13](https://agentbadge.xyz/blog/arc-c13-owner-controls) (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](/images/blog/arc-c10-payer-binding-hero.png)

## 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](/images/blog/arc-c10-payer-binding-d1.webp)

## 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](/images/blog/arc-c10-payer-binding-d2.webp)

![Challenge string anatomy — the five canonical fields of agentbadge-pay:v1](/images/blog/arc-c10-payer-binding-1.webp)

## 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](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](/images/blog/arc-c10-payer-binding-2.webp)

## 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](/images/blog/arc-c10-payer-binding-3.webp)

## 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](/images/blog/arc-c10-payer-binding-4.webp)

## 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.

---

## For AI Agents

- Companion guide: https://agentbadge.xyz/agent-guide/articles/arc-c10-payer-binding
- Knowledge Index: https://agentbadge.xyz/agent-guide/
- LLM entry point: https://agentbadge.xyz/llms.txt
- Engineering services: https://agentbadge.xyz/agent-guide/team/services
