Repository: interledger/web-monetization-extension
Pull request #1022 (2025-04-21 12:36:52)

fix(pages): don't crash when not using 3-letter currencies
<!--
Pull request titles should follow conventional commit format.
https://www.conventionalcommits.org/en/v1.0.0/
-->

## Context

Fixes https://github.com/interledger/web-monetization-extension/issues/1021

## Changes proposed in this pull request

- Handle the case of not-3-letter currency by using uppercase-currency as code to prevent crash (in `formatCurrency`, `getCurrencySymbol`).
- Add unit tests


github-actions[bot]: <!-- __WM_EXTENSION_BUILD_PREVIEWS__ -->
<h2>Extension builds preview</h2>

<table role="table">
<thead>
  <tr>
    <th>Name</th>
    <th>Link</th>
  </tr>
</thead>
<tbody>
  <tr>
    <td align="center">Latest commit</td>
    <td align="center">0e1ca8d1335f634062a969ea7202ad7e576443af</td>
  </tr>
  <tr>
    <td align="center">Latest job logs</td>
    <td align="center"><a href="https://github.com/interledger/web-monetization-extension/actions/runs/14573584872">Run #14573584872</a></td>
  </tr>
  <tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Chrome (990.7KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/37449887355/artifacts/2979068185">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Firefox (990.73KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/37449887355/artifacts/2979068254">Download</a></td></tr>
</tbody>
</table>

Patch:
diff --git a/src/pages/shared/lib/utils.ts b/src/pages/shared/lib/utils.ts
index 818266f..3399664 100644
--- a/src/pages/shared/lib/utils.ts
+++ b/src/pages/shared/lib/utils.ts
@@ -2,6 +2,9 @@ import { cx, type CxOptions } from 'class-variance-authority';
 import { twMerge } from 'tailwind-merge';
 
 export const getCurrencySymbol = (assetCode: string): string => {
+  if (!isISO4217Code(assetCode)) {
+    return assetCode.toUpperCase();
+  }
   return new Intl.NumberFormat('en-US', {
     currency: assetCode,
     style: 'currency',
@@ -20,11 +23,30 @@ export const formatCurrency = (
   maximumFractionDigits = 2,
   locale?: string,
 ): string => {
-  return new Intl.NumberFormat(locale, {
+  const isCurrencyCode = isISO4217Code(currency);
+  const PLACEHOLDER_CURRENCY_CODE = 'ABC';
+  const fmt = new Intl.NumberFormat(locale, {
     style: 'currency',
-    currency,
+    currency: isCurrencyCode ? currency : PLACEHOLDER_CURRENCY_CODE,
     maximumFractionDigits,
-  }).format(Number(value));
+  });
+
+  if (isCurrencyCode) {
+    return fmt.format(Number(value));
+  }
+  return fmt
+    .formatToParts(Number(value))
+    .map((e) => (e.type === 'currency' ? currency.toUpperCase() : e.value))
+    .join('');
+};
+
+/**
+ * Currency identifiers are three-letter uppercase codes defined in ISO 4217.
+ * Intl.NumberFormat API allows any 3-letter code to be used without throwing a RangeError.
+ * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/supportedValuesOf#supported_currency_identifiers
+ */
+export const isISO4217Code = (code: string): boolean => {
+  return code.length === 3;
 };
 
 export function roundWithPrecision(num: number, precision: number) {


Test patch:
diff --git a/src/pages/shared/lib/utils.test.ts b/src/pages/shared/lib/utils.test.ts
index 55b94ae..ad2926c 100644
--- a/src/pages/shared/lib/utils.test.ts
+++ b/src/pages/shared/lib/utils.test.ts
@@ -1,4 +1,8 @@
-import { formatNumber } from '@/pages/shared/lib/utils';
+import {
+  formatNumber,
+  formatCurrency,
+  getCurrencySymbol,
+} from '@/pages/shared/lib/utils';
 
 describe('formatNumber', () => {
   it('should display right format for integers', () => {
@@ -43,3 +47,52 @@ describe('formatNumber', () => {
     expect(formatNumber(0.000100009, 9)).toEqual('0.000100009');
   });
 });
+
+describe('getCurrencySymbol', () => {
+  it('should return currency symbol for common cases', () => {
+    expect(getCurrencySymbol('USD')).toEqual('$');
+    expect(getCurrencySymbol('EUR')).toEqual('€');
+    expect(getCurrencySymbol('MXN')).toEqual('MX$');
+  });
+
+  it('should return currency symbol for non-standard currencies', () => {
+    expect(getCurrencySymbol('abc')).toEqual('ABC');
+    expect(getCurrencySymbol('ZZZ')).toEqual('ZZZ');
+    expect(getCurrencySymbol('AB')).toEqual('AB');
+    expect(getCurrencySymbol('ABCD')).toEqual('ABCD');
+    expect(getCurrencySymbol('ABcDe')).toEqual('ABCDE');
+  });
+});
+
+describe('formatCurrency', () => {
+  it('should display right format for common cases', () => {
+    expect(formatCurrency(5, 'USD')).toEqual('$5.00');
+    expect(formatCurrency(0.5, 'USD')).toEqual('$0.50');
+    expect(formatCurrency(5.34, 'USD')).toEqual('$5.34');
+    expect(formatCurrency(5.34, 'EUR')).toEqual('€5.34');
+    expect(formatCurrency(5.34, 'MXN')).toEqual('MX$5.34');
+  });
+
+  it('should support custom precision', () => {
+    expect(formatCurrency(5.12, 'USD', 2)).toEqual('$5.12');
+    expect(formatCurrency(5.12, 'USD', 4)).toEqual('$5.12');
+    expect(formatCurrency(5.19, 'EUR', 1)).toEqual('€5.2');
+    expect(formatCurrency(5.12058, 'USD', 4)).toEqual('$5.1206');
+  });
+
+  it('should display right format in different locales', () => {
+    expect(formatCurrency(5.12, 'USD', 2, 'en-US')).toEqual('$5.12');
+    expect(formatCurrency(5.12, 'USD', 2, 'en-IN')).toEqual('$5.12');
+    expect(formatCurrency(5.34, 'USD', 2, 'fr')).toEqual('5,34\xa0$US'); // '\xa0' is a normal non-breaking space
+    expect(formatCurrency(5.45, 'EUR', 2, 'en-US')).toEqual('€5.45');
+    expect(formatCurrency(5.67, 'EUR', 2, 'fr-FR')).toEqual('5,67\xa0€');
+    expect(formatCurrency(5.89, 'MXN', 2, 'es-MX')).toEqual('$5.89');
+  });
+
+  it('should support non-standard currencies', () => {
+    expect(formatCurrency(5.12, 'ABC')).toEqual('ABC\xa05.12');
+    expect(formatCurrency(5.12, 'ZZZ')).toEqual('ZZZ\xa05.12');
+    expect(formatCurrency(5.12, 'AB')).toEqual('AB\xa05.12');
+    expect(formatCurrency(5.12, 'ABCDE')).toEqual('ABCDE\xa05.12');
+  });
+});