Back to News
OneShotInfrastructurex402USDCAI-AgentsCybersecurity

Hardening Agent Commerce: Rate Limits, Replay Protection, and Abuse Prevention

J NicolasJ Nicolas
··7 min read
Hardening Agent Commerce: Rate Limits, Replay Protection, and Abuse Prevention

Agent commerce has a security problem that most teams discover too late. When a human clicks "buy," there's friction built into the flow: a checkout page, a confirmation step, maybe a 3D Secure challenge. When an agent pays for a tool call, none of that friction exists. The agent receives a payment request, signs a USDC transfer, and the transaction settles in milliseconds. That speed is the whole point, but it's also the attack surface.

This article covers the concrete threats that show up when AI agents transact at machine speed, and the specific mechanisms OneShot uses to defend against them: nonce-based replay protection, tiered rate limiting, quote expiry, and circuit breakers on payment settlement.

The Threat Model for Agent Commerce

Before looking at defenses, it's worth being precise about what can go wrong. The threats in agent commerce are different from standard API abuse because the attacker often controls a signed payment credential, not just an API key.

Replay attacks. An attacker intercepts a valid signed payment authorization and resubmits it. Because the original signature is cryptographically valid, a naive server will process it again. In a system where agents sign USDC transfers, a replayed authorization could drain a wallet or double-bill a service.

Overspending from runaway agents. A misconfigured agent loop calls a paid tool repeatedly without a termination condition. This isn't malicious, but it produces the same effect as a DDoS: thousands of billable requests in seconds. Without spending caps per wallet per time window, a single buggy agent can exhaust a budget before any human notices.

Price staleness exploitation. A server quotes a price for a tool call. The agent holds that quote and waits until market conditions shift, then submits payment against the stale quote. If the server accepts expired quotes, it's selling services below cost.

Settlement-layer DDoS. An attacker floods the payment verification endpoint with payment claims that look valid but fail on-chain verification. Each claim requires a blockchain RPC call to verify. If those calls are unbounded, the verification service collapses under load.

These threats map to several items in the OWASP API Security Top 10, particularly unrestricted resource consumption and broken object-level authorization. But they have a payment dimension that standard API security guidance doesn't fully address.

Nonce-Based Replay Protection

Nonce-based replay protection in x402 signatures

The x402 protocol handles machine-to-machine payments by having the agent sign a payment authorization that the server verifies before serving a response. The signature covers the amount, recipient, and a nonce. That nonce is the replay protection mechanism, and getting it right matters.

x402 builds on EIP-3009, which defines transferWithAuthorization for ERC-20 tokens. EIP-3009 includes a nonce field that must be unique per authorization. The token contract tracks which nonces have been consumed, so a replayed authorization with the same nonce will revert on-chain.

Here's what a valid x402 payment header looks like on the wire:

X-PAYMENT: eyJzY2hlbWFWZXJzaW9uIjoxLCJzY2hlbWUiOiJleGFjdCIsIm5ldHdvcmsiOiJiYXNlIiwicGF5bG9hZCI6eyJmcm9tIjoiMHhhZ...

// Decoded payload
{
  "schemaVersion": 1,
  "scheme": "exact",
  "network": "base",
  "payload": {
    "from": "0xagentWalletAddress",
    "to": "0xserviceRecipientAddress",
    "value": "1000000",        // 1 USDC (6 decimals)
    "validAfter": 1718000000,
    "validBefore": 1718000300, // 5-minute window
    "nonce": "0x7f3a...b2c1",  // 32-byte random nonce
    "v": 27,
    "r": "0x4f2a...",
    "s": "0x8b1c..."
  }
}

The validBefore timestamp is your first line of defense against stale quotes. If the server sets a tight window (300 seconds is reasonable for most tool calls), an attacker holding a captured authorization has a narrow window to replay it. After expiry, the on-chain contract rejects the transfer.

But on-chain rejection is expensive. You want to catch replays before spending an RPC call. OneShot's payment verification layer checks a Redis-backed nonce cache before hitting the blockchain:

async function verifyPayment(paymentHeader: string): Promise<VerificationResult> {
  const authorization = decodePaymentHeader(paymentHeader);

  // 1. Check timestamp bounds first (cheap)
  const now = Math.floor(Date.now() / 1000);
  if (now < authorization.payload.validAfter) {
    return { valid: false, reason: "payment_not_yet_valid" };
  }
  if (now >= authorization.payload.validBefore) {
    return { valid: false, reason: "payment_expired" };
  }

  // 2. Check nonce cache before on-chain verification (medium cost)
  const nonceKey = `nonce:${authorization.payload.from}:${authorization.payload.nonce}`;
  const alreadySeen = await redis.get(nonceKey);
  if (alreadySeen) {
    return { valid: false, reason: "nonce_already_used" };
  }

  // 3. Verify signature and settle on-chain (expensive)
  const settlementResult = await settleOnChain(authorization);
  if (!settlementResult.success) {
    return { valid: false, reason: "settlement_failed", txHash: null };
  }

  // 4. Record nonce with TTL matching validBefore window
  const ttl = authorization.payload.validBefore - now + 60; // 60s buffer
  await redis.setex(nonceKey, ttl, settlementResult.txHash);

  return { valid: true, txHash: settlementResult.txHash };
}

The nonce key TTL is set to expire slightly after the authorization's validBefore. Once an authorization can no longer be submitted on-chain (because it's expired), the nonce record can be safely evicted from cache. This keeps the nonce store bounded rather than growing indefinitely.

Tiered Rate Limiting

Replay protection handles the "same payment twice" problem. Rate limiting handles the "too many payments too fast" problem. These require different data structures and different enforcement points.

OneShot applies rate limits at three levels: per-wallet address, per-tool, and per-time-window. Each level catches a different failure mode.

Per-wallet limits catch runaway agents. If a single wallet address makes 200 calls to the research tool in 60 seconds, something is wrong regardless of whether each payment is valid. A hard cap per wallet per minute stops budget exhaustion before it becomes catastrophic.

Per-tool limits protect expensive backend operations. The voice tool triggers a phone call, which has real-world latency and cost. The rate limit for voice is much lower than for the verification tool, which is cheap to run. Treating all tools identically would either over-restrict fast tools or under-protect slow ones.

Per-time-window limits let you implement burst tolerance. An agent might legitimately need 20 email sends in 10 seconds during a campaign, then nothing for an hour. A sliding window counter handles this better than a fixed-window counter, because fixed windows reset sharply at the boundary, which creates a "double spend" window at the boundary edge.

// Sliding window rate limiter using Redis sorted sets
async function checkRateLimit(
  walletAddress: string,
  toolId: string,
  limits: { perWalletPerMinute: number; perToolPerMinute: number }
): Promise<{ allowed: boolean; retryAfter?: number }> {
  const now = Date.now();
  const windowMs = 60_000;
  const windowStart = now - windowMs;

  const walletKey = `rl:wallet:${walletAddress}`;
  const toolKey = `rl:tool:${toolId}:${walletAddress}`;

  const pipeline = redis.pipeline();

  // Remove expired entries from both sorted sets
  pipeline.zremrangebyscore(walletKey, 0, windowStart);
  pipeline.zremrangebyscore(toolKey, 0, windowStart);

  // Count current entries
  pipeline.zcard(walletKey);
  pipeline.zcard(toolKey);

  const results = await pipeline.exec();
  const walletCount = results[2][1] as number;
  const toolCount = results[3][1] as number;

  if (walletCount >= limits.perWalletPerMinute) {
    // Find when the oldest entry expires
    const oldest = await redis.zrange(walletKey, 0, 0, "WITHSCORES");
    const retryAfter = Math.ceil((Number(oldest[1]) + windowMs - now) / 1000);
    return { allowed: false, retryAfter };
  }

  if (toolCount >= limits.perToolPerMinute) {
    const oldest = await redis.zrange(toolKey, 0, 0, "WITHSCORES");
    const retryAfter = Math.ceil((Number(oldest[1]) + windowMs - now) / 1000);
    return { allowed: false, retryAfter };
  }

  // Record this request in both sets
  const requestId = `${now}:${Math.random()}`;
  await redis.pipeline()
    .zadd(walletKey, now, requestId)
    .zadd(toolKey, now, requestId)
    .expire(walletKey, 120)
    .expire(toolKey, 120)
    .exec();

  return { allowed: true };
}

When a rate limit is hit, the response includes a Retry-After header with the number of seconds until the window clears. Well-behaved agents (including those built on the OneShot SDK) respect this header and back off automatically. Agents that ignore it get blocked at the connection level after repeated violations.

Quote Expiry and Price Staleness

Monitoring and circuit breakers for payment settlement

Before an agent pays for a tool call, it receives a quote: the price in USDC for that specific operation. The quote is signed by the server and includes an expiry timestamp. The agent must submit payment before that expiry, or the server rejects the payment claim.

The expiry window needs to be long enough for the agent's signing process to complete, but short enough to prevent price staleness attacks. For most OneShot tools, the window is 60 seconds. For voice calls (which require more setup), it's 120 seconds.

The quote includes the tool ID, the specific parameters (so an agent can't get a quote for a cheap operation and submit it against an expensive one), and the recipient address. The server verifies all three fields match when the payment arrives:

// Quote structure
{
  "quoteId": "q_7f3ab2c1",
  "toolId": "voice",
  "params": {
    "to": "+14155552671",
    "script": "sha256:a3f1b2c4..."  // hash of the script, not the script itself
  },
  "price": "5000000",  // 5 USDC
  "recipient": "0xserviceAddress",
  "validUntil": 1718000120,
  "signature": "0x..."  // server signs the whole quote
}

Hashing the script rather than including it directly keeps the quote compact while still binding the payment to a specific operation. If the agent tries to change the script after getting the quote, the hash won't match and the payment is rejected.

Circuit Breakers on Payment Settlement

The most expensive operation in the payment flow is on-chain settlement verification. Each verification requires an RPC call to a Base node, and RPC calls have latency and cost. Under normal load, this is fine. Under attack, it becomes the bottleneck.

A circuit breaker pattern protects the settlement service from cascading failure. If the error rate on settlement calls exceeds a threshold, the circuit opens and new payment claims are queued rather than immediately processed. This prevents the RPC layer from being overwhelmed while still allowing legitimate payments to eventually settle.

class SettlementCircuitBreaker {
  private failures = 0;
  private lastFailureTime = 0;
  private state: "closed" | "open" | "half-open" = "closed";

  private readonly threshold = 10;       // failures before opening
  private readonly timeout = 30_000;     // ms before trying half-open
  private readonly successThreshold = 3; // successes to close from half-open
  private halfOpenSuccesses = 0;

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === "open") {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        this.state = "half-open";
        this.halfOpenSuccesses = 0;
      } else {
        throw new Error("circuit_open: settlement service unavailable");
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  private onSuccess() {
    if (this.state === "half-open") {
      this.halfOpenSuccesses++;
      if (this.halfOpenSuccesses >= this.successThreshold) {
        this.state = "closed";
        this.failures = 0;
      }
    } else {
      this.failures = 0;
    }
  }

  private onFailure() {
    this.failures++;
    this.lastFailureTime = Date.now();
    if (this.failures >= this.threshold || this.state === "half-open") {
      this.state = "open";
    }
  }
}

When the circuit is open, the server returns a 503 with a Retry-After header. Agents that follow the protocol retry after the specified delay. Agents that hammer the endpoint regardless get rate-limited at the connection level by the upstream load balancer.

Monitoring What Matters

Security mechanisms are only useful if you can observe them working. For agent commerce, the metrics that matter most are different from standard API monitoring.

Watch the ratio of payment claims to successful settlements. Under normal operation, this should be close to 1.0. A ratio below 0.8 suggests either a bug in client-side signing or an active replay attack. A ratio above 1.0 is impossible by definition, so if your monitoring shows it, something is wrong with your counters.

Track nonce collision rate separately from payment failure rate. A spike in n