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.