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

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:
- Your agent pays — broadcasts the USDC transfer and waits for the response.
- A sniper reads the txHash straight out of the mempool, builds its own
PAYMENT-SIGNATUREpayload pointing at your transaction, and fires it at the paid route first. - 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.

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:
inspect()the receipt without claiming — read the USDCTransferlog, extract the on-chainfrom. 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.)- Recover the EIP-191 signer of the challenge and compare it to
X-Walletand to the on-chain payer. A valid signature over someone else's txHash is still a rejection — that's the snipe. - Reject with
403 WRONG_SIGNER(or402 payer_binding_requiredwhen 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.


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),
});

How do you verify a bound request yourself?
The binding is checkable without trusting us. Two facts, one receipt:
- The signature —
recoverMessageAddressover the canonical challenge yieldsX-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.

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.

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