BchainPay logoBchainPay
EngineeringStablecoinsRiskMonitoringAPI

Stablecoin Depeg Circuit-Breakers: Protecting Payment Gateways

How to detect a stablecoin depeg in real time with Pyth and Chainlink, trigger automatic circuit-breakers, handle in-flight payments safely, and route to a fallback asset.

By Cipher · Founding engineer, BchainPay7 min read
Illustration for Stablecoin Depeg Circuit-Breakers: Protecting Payment Gateways

On 10 March 2023, USDC's peg broke. At the worst point it traded at $0.878 on some venues. The cause was $3.3 billion of Circle's reserves sitting at Silicon Valley Bank when regulators seized it. Within 24 hours the peg recovered — but in those 24 hours, payment gateways that accepted USDC for settlement had a real problem: what was the right response?

  • Accept a USDC payment at the quoted $1.00 rate when the USDC received was worth $0.90 at market price?
  • Silently under-settle merchants who had already delivered goods?
  • Reject all USDC payments and lose revenue for the entire event window?

There is no clean answer, but there is a set of engineering patterns that make the outcome less bad. This post covers those patterns: price oracle selection, a four-state circuit-breaker machine, in-flight payment handling, and fallback stablecoin routing.

How fast a depeg moves#

USDC's 2023 depeg went from $1.00 to $0.97 in roughly four hours. The next dip to $0.878 happened over a two-hour window on Curve. By the time most on-call engineers read the news, the peg had already broken by several percent.

The practical constraint this sets: any detection system polling more than five minutes apart is too slow. You need:

  • A target detection latency of 60 seconds or less.
  • An alert fired before the depeg hits a material threshold — say, $0.985 for USDC — not after.
  • An automated response that does not wait for human acknowledgment.

Choosing a price oracle#

Chainlink's USDC/USD feed updates on-chain every heartbeat (1 hour) or when the price deviation exceeds 0.1%. During a fast-moving depeg you will typically see the Chainlink feed lag behind centralized-exchange prices by 5 to 15 minutes.

Chainlink is reliable and manipulation-resistant. Use it as the confirmation oracle — the authoritative last word once you have already reacted, not the trigger for the initial alert.

import { createPublicClient, http, parseAbi } from "viem";
import { mainnet } from "viem/chains";
 
const USDC_USD_FEED = "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6";
 
const FEED_ABI = parseAbi([
  "function latestRoundData() external view returns " +
    "(uint80 roundId, int256 answer, uint256 startedAt, " +
    " uint256 updatedAt, uint80 answeredInRound)",
]);
 
export async function chainlinkUsdcPrice(rpcUrl: string): Promise<number> {
  const client = createPublicClient({ chain: mainnet, transport: http(rpcUrl) });
  const [, answer, , updatedAt] = await client.readContract({
    address: USDC_USD_FEED,
    abi: FEED_ABI,
    functionName: "latestRoundData",
  });
  const ageSeconds = Math.floor(Date.now() / 1000) - Number(updatedAt);
  if (ageSeconds > 3600) throw new Error(`stale feed: ${ageSeconds}s old`);
  return Number(answer) / 1e8; // 8-decimal answer
}

Pyth Network#

Pyth aggregates first-party prices from exchanges and market makers and publishes every ~400 ms. For off-chain monitoring, pull from the Hermes HTTP service — no transaction required:

const PYTH_USDC_ID =
  "0xeaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a";
 
async function pythUsdcPrice(): Promise<number> {
  const res = await fetch(
    `https://hermes.pyth.network/v2/updates/price/latest?ids[]=${PYTH_USDC_ID}`
  );
  const data = await res.json();
  const p = data.parsed[0].price;
  return Number(p.price) * 10 ** p.expo;
}

Pyth is your detection oracle — fast enough to catch a depeg before it is priced into Chainlink. Use both: Pyth to trigger state transitions, Chainlink to confirm that a recovery is real before re-enabling payments.

The four-state circuit-breaker#

Define four states per stablecoin:

NORMAL → WATCH → PAUSED → RECOVERY → NORMAL
                 ↑___________________________↓ (if price falls again during RECOVERY)
  • NORMAL: Price at or above alert_threshold (e.g., 0.990). Accept payments.
  • WATCH: Price below alert_threshold but above pause_threshold. Fire an alert, log the event, keep accepting. Resolve within 15 minutes.
  • PAUSED: Price at or below pause_threshold (e.g., 0.985). Reject new payment intents for this stablecoin; surface fallback options.
  • RECOVERY: Price has climbed above resume_threshold (e.g., 0.995) and held there for resume_duration_s seconds. Re-enable after the cooldown elapses.
type DepegState = "NORMAL" | "WATCH" | "PAUSED" | "RECOVERY";
 
interface DepegPolicy {
  alertThreshold:  number;  // e.g. 0.990
  pauseThreshold:  number;  // e.g. 0.985
  resumeThreshold: number;  // e.g. 0.995
  resumeDurationMs: number; // e.g. 30 * 60 * 1000
}
 
interface DepegStatus {
  state:              DepegState;
  enteredAt:          number; // epoch ms
  recoveryStartedAt?: number;
}
 
function nextState(
  current: DepegStatus,
  price: number,
  policy: DepegPolicy,
  now: number,
): DepegState {
  switch (current.state) {
    case "NORMAL":
      return price < policy.alertThreshold ? "WATCH" : "NORMAL";
    case "WATCH":
      if (price >= policy.alertThreshold) return "NORMAL";
      if (price <= policy.pauseThreshold)  return "PAUSED";
      return "WATCH";
    case "PAUSED":
      return price >= policy.resumeThreshold ? "RECOVERY" : "PAUSED";
    case "RECOVERY": {
      if (price < policy.resumeThreshold) return "PAUSED";
      const held = now - (current.recoveryStartedAt ?? now);
      return held >= policy.resumeDurationMs ? "NORMAL" : "RECOVERY";
    }
  }
}

The DepegStatus record is persisted in Redis with a TTL longer than resumeDurationMs so that a worker restart cannot reset a live PAUSED state to NORMAL accidentally.

Handling in-flight payment intents#

When a circuit-breaker trips you have two categories of existing intents that need different handling:

Unpaid intents — created but no on-chain activity yet. These are safe to expire immediately. Return HTTP 402 with code: "stablecoin_paused" and include the ordered fallback list so the client can re-present payment options without a round-trip to your backend.

In-flight intents — a transaction has been detected in the mempool or included in a block but not yet marked confirmed. You cannot unilaterally reverse this. Apply a grace period (30 minutes by default) during which you continue to monitor and finalize at the oracle price locked at detection time.

BchainPay exposes both controls via the depeg_policy object on each merchant credential:

{
  "depeg_policy": {
    "USDC": {
      "alert_threshold":  0.990,
      "pause_threshold":  0.985,
      "resume_threshold": 0.995,
      "resume_duration_s": 1800,
      "grace_period_s":   1800,
      "fallback":         ["USDT", "DAI"],
      "settlement_rate":  "oracle_at_detection"
    }
  }
}

The settlement_rate: "oracle_at_detection" field is the critical one. It tells the settlement engine to lock the Pyth price at the moment the on-chain transaction was first seen in the mempool — not at block inclusion. If USDC was at $0.993 when the customer broadcast the transaction but at $0.971 by the time it was mined, the merchant is settled at $0.993.

Fallback stablecoin routing#

When USDC enters PAUSED, GET /v1/checkout-sessions/:id returns the fallback list in priority order:

{
  "id": "cs_01HX...",
  "status": "requires_payment",
  "amount_usd": "49.99",
  "stablecoin_options": [
    {
      "token": "USDT",
      "chain": "ethereum",
      "address": "0x742d...",
      "status": "active"
    },
    {
      "token": "DAI",
      "chain": "ethereum",
      "address": "0x742d...",
      "status": "active"
    },
    {
      "token": "USDC",
      "chain": "ethereum",
      "address": "0x742d...",
      "status": "paused",
      "pause_reason": "depeg_watch"
    }
  ]
}

Clients that render a "Pay with USDC" button should degrade gracefully to the first active entry in stablecoin_options. Always surface pause_reason so the customer understands they are not personally blocked.

A PAUSED circuit-breaker that exposes no fallback will drive your conversion rate to zero for the duration of the event. That outcome is worse than the depeg itself for most merchants.

Stablecoin risk profiles#

Not all depeg events look the same. Calibrate your thresholds per-stablecoin:

Stablecoin Depeg type Typical speed Severity
USDC Reserve risk / bank run Hours Moderate (<15%)
USDT Transparency / FUD Days Mild (<5%)
DAI Collateral liquidation Hours Moderate, recovers
FRAX Algorithmic component Minutes Potentially severe

For USDC and USDT a 0.985 pause threshold has been sufficient against all historical events. For algorithmic stablecoins or those with opaque reserves, widen to 0.990: the faster and steeper the potential fall, the earlier you want the circuit-breaker to fire.

Monitoring and alerting#

The depeg poller runs every 30 seconds and emits a stablecoin.depeg_state_changed webhook on every state transition:

{
  "event": "stablecoin.depeg_state_changed",
  "stablecoin": "USDC",
  "prev_state": "NORMAL",
  "new_state":  "WATCH",
  "price":      0.9882,
  "oracle":     "pyth",
  "ts":         "2026-04-27T08:30:00Z"
}

Route this event to your Slack/PagerDuty. Also subscribe your treasury team if they run FX hedges against stablecoin exposure — a WATCH→PAUSED transition is the signal to execute any hedging workflow before the circuit-breaker fully closes.

You can also configure per-stablecoin alert channels in the BchainPay dashboard under Settings → Depeg Alerts. The same webhook endpoint that receives payment_intent.confirmed can also receive stablecoin.depeg_state_changed if you register it for the risk.* event class.

Key takeaways#

  • Pyth for detection, Chainlink for confirmation. Chainlink's deviation threshold and heartbeat are too slow to catch a fast-moving depeg; use it to verify recovery is real.
  • Four-state machine with cooldown. NORMAL → WATCH → PAUSED → RECOVERY prevents flapping; the resume cooldown requires the peg to hold, not just touch, the recovery threshold.
  • Split in-flight intents into two buckets. Unpaid intents expire immediately; on-chain intents ride out a grace period settled at the oracle price locked at mempool detection time.
  • Never pause without a fallback. A circuit-breaker with no fallback array configured will kill conversion for the whole event window.
  • Fire stablecoin.depeg_state_changed webhooks. Merchants and treasury teams need the signal independently of whatever the gateway UI shows.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading