A txHash Is a Bearer Token — We Bound Ours to the Wallet That Paid
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. We closed
that hole on every AgentBadge paid surface with agentbadge-pay:v1 —
a one-line signature binding a payment transaction to the wallet that
made it, verified before the replay slot is ever touched.
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, a sniper
reads the txHash out of the mempool, builds its own
PAYMENT-SIGNATURE pointing at your transaction, and races you to the
server. First presenter wins the replay slot — your money buys their
answer.
The txHash is proof a payment happened. It is not proof the request
came from the payer.
What does payer binding actually check?
Three headers — X-Wallet, X-Sig, X-Timestamp — carrying an
EIP-191 signature over a canonical challenge:
agentbadge-pay:v1
wallet:<payer, lowercase>
method:POST
path:/api/eaas/verdicts
payref:<txHash, lowercase>
timestamp:<unix seconds, ±300s>
The check runs before payment verification — order matters,
because verification is what burns the replay slot:
-
inspect()the receipt claim-free — read the USDCTransferlog, extract the on-chainfrom. A rejection never poisons the payment. - Recover the challenge signer; compare to
X-Walletand the on-chain payer. A valid signature over someone else's txHash is still a rejection — that's the snipe. -
403 WRONG_SIGNER(or402 payer_binding_requiredwhen headers are absent on a txHash payload). Gateway/exact payments carry no txHash and skip binding.
The e2e proves the ordering: attacker's request → 403, seenTxHashes
empty; legit request → 200; third call → replay-402. Slot lifecycle
intact.
How does an agent pay with binding enabled?
Every 402 advertises it — extensions.payerBinding and
accepts[].extra.payerBinding carry {required: true, challenge:. Spec:
"agentbadge-pay:v1"}https://agentbadge.xyz/payer-binding.md.
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, timestamp,
});
// + headers: x-wallet, x-sig, x-timestamp next to payment-signature
How do you verify a bound request yourself?
No trust in us required. recoverMessageAddress over the canonical
challenge yields X-Wallet; the txHash's Transfer(from,to,value)
log gives the on-chain payer. Mismatch = snipe attempt, valid
signature or not.
Why a signature and not a redeem token?
The redeem_token pattern (two-phase quote → pay → redeem) closes the
same hole but costs a second round-trip plus a server-side token
store. Ours is stateless — replay dedup already existed, the binding
rides inside the same request. A sniper holding your txHash can't
produce a signature naming itself payer of that tx — the on-chain
from won't match.
Honest status
Live behind PAYER_BIND_ENABLED (off until funded dogfood) across
five paid surfaces — EaaS, marketplace, scan-packs, keeperhub, bstock
— with per-group kill-switch PAYER_BIND_DISABLED_GROUPS.
scripts/payer-bind-dogfood.mts replays the full attack on testnet:
victim pays, attacker replays the txHash → 403, victim's bound request
→ 200, retry → 402 replay.
ENDPOINT=https://agentbadge.xyz \
PAYER_KEY=0x... ATTACKER_KEY=0x... \
bun run scripts/payer-bind-dogfood.mts
C10 in the Arc Campaign series. Earlier: C15 made every 402
self-declaring; C4/C13 built the wallet controls this binding sits
behind.
Originally published at agentbadge.xyz.
Top comments (0)