BchainPay logoBchainPay
EngineeringOraclesEVMCheckout

On-chain price oracles for crypto checkout: Chainlink, Pyth, TWAP

How to pick and configure price oracles for crypto checkout: Chainlink, Pyth, and Uniswap V3 TWAP with staleness checks and decimal normalization at each layer.

By Cipher · Founding engineer, BchainPay9 min read
Illustration for On-chain price oracles for crypto checkout: Chainlink, Pyth, TWAP

When a customer checks out with ETH or USDC, the product price is a fiat amount — $49.99, say. Converting that to the correct on-chain token quantity requires a price reference. Use the wrong one, or use the right one without staleness guards, and you either overcharge customers, accept less than invoiced, or open a price-manipulation window that attackers will find before you do.

This post covers the three oracle patterns BchainPay uses across its supported chains: Chainlink Data Feeds for EVM L1/L2s, Pyth Network for Solana and fast-finality EVM chains, and Uniswap V3 TWAP as a fallback for long-tail token pairs. We look at staleness detection, manipulation resistance, and the decimal normalization trap that catches most production bugs.

Why oracle choice matters#

The core arithmetic is simple:

token_amount = (fiat_amount / token_price_usd) * 10^token_decimals

Three things can go wrong:

  1. Stale price. The oracle's last update was 3 hours ago during a market spike. The customer pays ETH priced at $2,900 but the current price is $3,400 — you accept $500 less than invoiced.
  2. Flash-loan manipulation. If your oracle can be moved by a single block trade, attackers can submit a payment and a price-suppressing swap in the same bundle, receiving goods at a discount.
  3. Decimal mismatch. Token decimals (6 for USDC, 18 for ETH, 8 for WBTC) and price-feed decimals (8 for most Chainlink feeds) are independent axes. Getting the relationship wrong produces amounts off by 10^10.

Chainlink aggregates multiple professional data providers per feed via a threshold-signature committee, making individual-provider manipulation economically infeasible. The aggregator exposes a standard interface:

interface AggregatorV3Interface {
    function latestRoundData() external view returns (
        uint80  roundId,
        int256  answer,       // price × 10^decimals
        uint256 startedAt,
        uint256 updatedAt,
        uint80  answeredInRound
    );
    function decimals() external view returns (uint8);
}

A production-grade read with staleness and sanity checks:

import { createPublicClient, http, parseAbi } from 'viem'
 
const ETH_USD_FEED = '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419' as const
 
const AGGREGATOR_ABI = parseAbi([
  'function latestRoundData() view returns (uint80, int256, uint256, uint256, uint80)',
  'function decimals() view returns (uint8)',
])
 
// ETH/USD Ethereum mainnet heartbeat: 1 hour or 0.5% price deviation.
// MATIC/USD Polygon heartbeat: 24 hours. Set MAX_STALENESS_SECONDS per feed.
const MAX_STALENESS_SECONDS = 3_600
 
async function getChainlinkPrice18(
  client: ReturnType<typeof createPublicClient>,
  feed: `0x${string}`,
  maxStaleness = MAX_STALENESS_SECONDS,
): Promise<bigint> {
  const [roundData, decimals] = await Promise.all([
    client.readContract({ address: feed, abi: AGGREGATOR_ABI, functionName: 'latestRoundData' }),
    client.readContract({ address: feed, abi: AGGREGATOR_ABI, functionName: 'decimals' }),
  ])
 
  const [, answer, , updatedAt] = roundData
  const age = Math.floor(Date.now() / 1000) - Number(updatedAt)
 
  if (age > maxStaleness) throw new Error(`Chainlink feed stale: ${age}s`)
  if (answer <= 0n)        throw new Error(`Chainlink feed invalid: ${answer}`)
 
  // Normalize to 18-decimal price. BigInt only — no floating-point.
  return answer * 10n ** (18n - BigInt(decimals))
}

Feed addresses for common pairs#

{
  "ETH/USD": {
    "ethereum": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
    "arbitrum": "0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612",
    "optimism": "0x13e3ee699d1909e989722e753853ae30b17e08c5",
    "polygon":  "0xF9680D99D6C9589e2a93a78A04A279e509205945",
    "base":     "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70"
  },
  "USDC/USD": {
    "ethereum": "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6"
  },
  "BTC/USD": {
    "ethereum": "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c",
    "arbitrum": "0x6ce185860a4963106506C203335A2910413708e9"
  }
}

The critical mistake is applying a uniform 1-hour staleness threshold to every feed. MATIC/USD on Polygon has a 24-hour heartbeat; a 1-hour threshold will throw constantly on weekends. Store the heartbeat alongside each feed address in your config and use it as maxStaleness.

Pyth Network: pull model, sub-second freshness#

Chainlink is push-based: the aggregator is updated on-chain at each heartbeat or deviation event. For applications that need fresher prices — common on Solana and on high-throughput EVM chains like Avalanche or BNB Chain — Pyth uses a pull oracle model. Price attestations live off-chain; consumers pull them in on demand.

import { PriceServiceConnection } from '@pythnetwork/price-service-client'
 
const ETH_USD_PRICE_ID =
  '0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace'
 
const pyth = new PriceServiceConnection('https://hermes.pyth.network')
 
async function getPythPrice18(priceId: string): Promise<bigint> {
  const [feed] = await pyth.getLatestPriceFeeds([priceId])
  const p = feed.getPriceUnchecked()
 
  const age = Math.floor(Date.now() / 1000) - p.publishTime
  if (age > 60) throw new Error(`Pyth price stale: ${age}s`)
 
  // Reject if confidence interval exceeds 0.5% of mid-price.
  // A wide conf during high volatility signals you should pause acceptance.
  const relConf = Number(p.conf) / Math.abs(Number(p.price))
  if (relConf > 0.005) throw new Error(`Pyth confidence too wide: ${relConf}`)
 
  // p.expo is typically -8; scale to 18 decimals.
  // price18 = p.price × 10^(18 + p.expo)  →  e.g. 18 + (-8) = 10
  const scale = 18n + BigInt(p.expo)
  return BigInt(p.price) * 10n ** scale
}

Two fields Pyth exposes that Chainlink does not:

  • Confidence interval (conf). The spread across contributing publishers. A wide conf during high volatility is a real signal: widen your invoice tolerance or temporarily pause acceptance of the affected token.
  • EMA price. An exponential moving average that dampens short-term spikes. For checkout, the spot price is usually what you want, but comparing spot to EMA gives a cheap manipulation-signal detector. If spot deviates more than 3% from EMA, treat it as suspicious.

On Solana, Pyth is the dominant oracle and the integration uses on-chain price accounts via @pythnetwork/client's Solana SDK rather than the HTTP API above.

Uniswap V3 TWAP: long-tail token pairs#

Neither Chainlink nor Pyth covers every ERC-20 a merchant might want to accept. For long-tail tokens — a loyalty point, a project token, any asset that has a Uniswap V3 pool but no aggregated price feed — the TWAP is the credible on-chain source.

A TWAP over a multi-minute window resists manipulation because an attacker must sustain an artificial price across every block in the window, not just one. The cost scales with window length and pool depth.

interface IUniswapV3Pool {
    function observe(uint32[] calldata secondsAgos)
        external view
        returns (
            int56[]  memory tickCumulatives,
            uint160[] memory secondsPerLiquidityCumulativeX128s
        );
}
 
/// @return arithmeticMeanTick  Average tick over `window` seconds.
function getTWAPTick(address pool, uint32 window)
    external view returns (int24 arithmeticMeanTick)
{
    uint32[] memory secondsAgos = new uint32[](2);
    secondsAgos[0] = window;
    secondsAgos[1] = 0;
 
    (int56[] memory tickCumulatives,) =
        IUniswapV3Pool(pool).observe(secondsAgos);
 
    int56 delta = tickCumulatives[1] - tickCumulatives[0];
    arithmeticMeanTick = int24(delta / int56(int32(window)));
}

Convert the mean tick to a price using TickMath.getSqrtRatioAtTick and FullMath.mulDiv from the Uniswap V3 core library, then normalise to 18 decimals as with any other source.

TWAP constraints for production checkout#

  • Minimum window: 10 minutes on pools with at least $1M in concentrated liquidity. A 5-minute TWAP on a shallow pool can be profitably moved for less than a single checkout amount. 30 minutes is safer for low-liquidity pairs.
  • Pool tier selection. Prefer the 0.05% fee tier for major pairs (USDC/ETH) where it exists and has the deepest liquidity. For exotic tokens, only the 1% pool may exist — verify depth via liquidity and sqrtPriceX96 before relying on it.
  • Cross-oracle circuit breaker. If the TWAP deviates more than 5% from a Chainlink or Pyth price for the same base token, something is wrong. Pause acceptance rather than proceeding with a potentially manipulated rate.

Decimal normalization#

The same $3,400 ETH price has completely different bit representations across each source:

Source Raw value Interpretation
Chainlink feed 340000000000 8-decimal fixed-point
Pyth (expo=-8) 340000000 price × 10^8
WETH on-chain 18-decimal token
USDC on-chain 6-decimal token

The safest approach is to normalise every oracle output to a single 18-decimal price18 — a bigint representing price × 10^18 — then perform all arithmetic in that space:

function computeCheckoutAmount(
  invoiceUsd: number,     // e.g. 49.99
  price18: bigint,        // oracle price normalised to 18 decimals
  tokenDecimals: number,  // 18 for ETH, 6 for USDC, 8 for WBTC
): bigint {
  // Convert invoiceUsd to a 18-decimal bigint without floating-point error.
  // Multiply by 10^6 first (safe for dollar amounts up to 10^12), then pad.
  const invoiceWad = BigInt(Math.round(invoiceUsd * 1e6)) * 10n ** 12n
 
  // token_amount_18 = invoiceWad × 10^18 / price18
  const tokenAmount18 = (invoiceWad * 10n ** 18n) / price18
 
  if (tokenDecimals === 18) return tokenAmount18
  if (tokenDecimals < 18)   return tokenAmount18 / 10n ** BigInt(18 - tokenDecimals)
  return tokenAmount18 * 10n ** BigInt(tokenDecimals - 18)
}

Never pass oracle output through parseFloat or Number. At 18 decimal places, IEEE-754 doubles drop the last 3-4 digits of precision — which, for a $10,000 invoice settled in USDC, is a $0.01-$0.10 rounding error per payment that compounds into material discrepancies across thousands of transactions.

Putting it together in the BchainPay API#

When a merchant creates a payment intent with a fiat amount, BchainPay resolves the on-chain quantity for each accepted token at intent creation time:

POST /v1/payment-intents
Authorization: Bearer sk_live_...
Content-Type: application/json
 
{
  "amount":     { "value": 49.99, "currency": "USD" },
  "accept":     ["ETH.ethereum", "USDC.polygon"],
  "expires_in": 1800
}
{
  "id": "pi_01HXV...",
  "status": "awaiting_payment",
  "accepted_amounts": [
    {
      "token":      "ETH.ethereum",
      "address":    "0x4a4ceb...",
      "amount":     "0.014702",
      "raw":        "14702000000000000",
      "oracle":     "chainlink",
      "price_usd":  "3400.12",
      "expires_at": 1735000000
    },
    {
      "token":      "USDC.polygon",
      "address":    "0x7f1d2c...",
      "amount":     "49.990000",
      "raw":        "49990000",
      "oracle":     "chainlink",
      "price_usd":  "1.0001",
      "expires_at": 1735000000
    }
  ]
}

The price is locked at intent creation and not re-evaluated when the on-chain transfer is detected. Re-evaluating on receipt creates a manipulation window: an attacker can submit the payment in the same block as a price-suppressing swap, receive credit for the suppressed amount, and still receive the goods. Locking at creation eliminates that window entirely.

Fallback hierarchy#

BchainPay's oracle priority order per chain:

  1. Chainlink — preferred on Ethereum mainnet, Polygon, Arbitrum, BNB Chain
  2. Pyth — preferred on Solana; fallback on Avalanche and Base
  3. Uniswap V3 TWAP — long-tail tokens only; never used as primary for ETH or BTC
  4. Oracle-degraded freeze — if all sources are stale or unreachable, new intent creation for the affected token is paused and merchants receive a webhook:
{
  "type":          "oracle.degraded",
  "chain":         "ethereum",
  "token":         "ETH",
  "reason":        "chainlink_stale",
  "stale_seconds": 4320,
  "fallback":      null,
  "action":        "intents_paused"
}

This webhook fires before any customer-facing error, giving merchants a chance to update their checkout UI before orders start failing.

Key takeaways#

  • Use Chainlink Data Feeds for ETH, BTC, and major stablecoin pairs on EVM chains. Match your staleness threshold to each feed's documented heartbeat, not a universal one-hour default.
  • Use Pyth Network for Solana and for contexts where Chainlink's heartbeat is too slow. Monitor the conf interval as a live volatility signal: a wide confidence is a meaningful reason to pause acceptance.
  • Use Uniswap V3 TWAP only for long-tail token pairs with no Chainlink or Pyth coverage. Enforce a 10-minute minimum window and a cross-oracle circuit breaker to catch manipulated rates.
  • Normalise all oracle outputs to 18-decimal bigint before arithmetic. Never let oracle values touch floating-point.
  • Lock the price at intent creation. Re-evaluating the oracle on payment receipt creates a flash-loan manipulation window.
  • Implement an oracle-degraded webhook so merchants know when a token's checkout has been paused before their customers hit an error.

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