BTC$68,420▲ 0.82% / ETH$3,841▼ 0.31% / LTC$94.05▲ 1.14% / SOL$221.60▲ 3.02% / USDT·TRC20$1.0000 / USDC·ERC20$0.9999 / XMR$168.42▼ 0.18% / TON$6.02▲ 0.47% / XRP$2.41▼ 0.09% / GAS·ETH18 gwei / Indicative prices · illustrative ticker / No-KYC · non-pooled · 0.4% flat / BTC$68,420▲ 0.82% / ETH$3,841▼ 0.31% / LTC$94.05▲ 1.14% / SOL$221.60▲ 3.02% / USDT·TRC20$1.0000 / USDC·ERC20$0.9999 / XMR$168.42▼ 0.18% / TON$6.02▲ 0.47% / XRP$2.41▼ 0.09% / GAS·ETH18 gwei / Indicative prices · illustrative ticker / No-KYC · non-pooled · 0.4% flat
OrbChain
UTC 18:47:02 Get started →
§ Postmortem · 2026-04-21

The Polygon rebrand bug
that survived a year of CI.

A one-line entry in our CoinGecko ID map pointed at an identifier that quietly stopped returning a price. Our stale-cache fallback, built for a different failure mode, masked it. Here's what happened, how we noticed, and the cheap regression guard we shipped to catch the whole class.

The map

OrbChain quotes payments in fiat using CoinGecko's free /simple/price endpoint. Every coin we accept maps to one string identifier in a small hand-written table:

const COINGECKO_IDS = {
  BTC: "bitcoin",
  ETH: "ethereum",
  POL: "matic-network",  // ← this line
  ...
};

That matic-network id was right when we wrote it. Polygon's native token was called MATIC. In 2024 the chain rebranded to POL and CoinGecko retired the matic-network id for quoting purposes. It still resolves — but the response body is literally {} with no usd field.

What masked it

We have a deliberate stale-cache window on getFiatPrice(). When CoinGecko returns an error (which is common on the free tier — 429s daily), we serve the last cached price for up to 10 minutes, and fall through to the most recent persisted Price row if the cache is empty. This is the right behavior when the upstream is transiently down. It's the wrong behavior when the id is permanently wrong — we'd serve the last good POL price for 10 minutes, then serve the most recent persisted USD value forever.

From the outside: everything looked fine. Our /v1/common/currencies endpoint returned POL at a plausible number. Invoices priced themselves. No exception log fired. The only signal was the persisted price never changed.

How we caught it

Not from monitoring. We caught it during a routine audit after a different class-of-bug (wrong ChangeNOW ticker for USDT on TON) surfaced a pair_is_inactive response. Both had the same shape: a silent failure masked by a graceful fallback. So we wrote a one-pager that probed every coin ↔ external-API identifier we ship:

for id in $(our_coingecko_ids); do
  curl -s ".../simple/price?ids=$id&vs_currencies=usd" \
    | jq -r "select(.[\"$id\"].usd)" \
    | grep -q . || echo "MISSING: $id"
done

Two MISSING lines came back. matic-network was one of them. The fix was a single-line diff: POL → "polygon-ecosystem-token". Live-verified: $0.09322.

The regression guard

A CI job hitting CoinGecko on every run would be flaky (rate limits) and expensive. The right level to pin is the source: a file-read test that fails if anyone reverts the mapping.

it("POL uses polygon-ecosystem-token, not the retired matic-network", () => {
  const src = fs.readFileSync(prices_ts, "utf-8");
  expect(src).toMatch(/POL:\s*"polygon-ecosystem-token"/);
  expect(src).not.toMatch(/POL:\s*"matic-network"/);
});

Same pattern for all three at-risk mappings. No rate limits, no flakiness, fails loudly if someone swaps the wrong id back in. Four lines of test per mapping.

Takeaway

Graceful degradation isn't free — it hides the failures you built it to survive. If your fallback is "serve the last value we ever got," you need an external probe that checks freshness, not just availability. We didn't have one. Now we have a static regression guard that's cheaper than monitoring, faster than CI, and specific enough that the next person to touch the file understands why it's there.

Commit: 5fe818c. Diff below if you want to read it.

§ Keep reading

Related posts.

Hand-picked
Same rails, next questions