diff --git a/docs/b20/stablecoin/currency-validation.md b/docs/b20/stablecoin/currency-validation.md
new file mode 100644
index 0000000..7866e44
--- /dev/null
+++ b/docs/b20/stablecoin/currency-validation.md
@@ -0,0 +1,223 @@
+# Currency Validation
+
+How the `B20Stablecoin` variant constrains its `currency` field at creation, and why the chosen filter is scoped narrower than "stablecoin" colloquially suggests.
+
+## Problem
+
+The `B20Stablecoin` variant declares an immutable `currency` identifier at creation. Without a constraint on what issuers can pass:
+
+- Two issuers might use `"USD"` and `"usd"` for the same currency, breaking any consumer that groups by the field.
+- An issuer might pass their token's symbol (`"USDC"`) instead of a currency code.
+- Tokens claiming non-currency assets (gold, crypto, governance tokens) would all coexist under the same variant, polluting any tooling that categorizes by `currency()`.
+
+The factory needs a deterministic, machine-readable filter rather than a free-form string.
+
+## Solution
+
+Validate `currency` at creation against a hardcoded allowlist of active **ISO 4217 alphabetic codes** for circulating national fiat currencies, implemented in [`test/lib/ISO4217.sol`](../../../test/lib/ISO4217.sol). Anything off the list reverts with `ITokenFactory.InvalidCurrency(code)` carrying the offending string verbatim.
+
+Scope aligns with **MiCA E-Money Tokens** and **MAS Single-Currency Stablecoins** — narrower than the broader **FSB** (Financial Stability Board) and **BIS** (Bank for International Settlements) definition that includes commodities, baskets, and crypto pegs.
+
+Key properties:
+
+- Set at creation by the factory; immutable thereafter.
+- Self-declared — the filter gates format and membership, not truthfulness.
+- Any consumer using `currency()` for authorization or routing MUST add its own issuer/contract allowlist on top.
+
+## Specification
+
+| Category | Status | Description | Codes |
+| --- | --- | --- | --- |
+| G10 + SGD | Included | Most-traded reserve currencies; MAS-anchored set | USD, EUR, JPY, GBP, AUD, NZD, CAD, CHF, NOK, SEK, SGD |
+| Multi-country X-prefix fiat | Included | Real circulating fiat issued by supranational central banks (BCEAO, BEAC, ECCB, IEOM) | XOF, XAF, XCD, XPF |
+| Precious metals | Excluded | Commodities, not means of payment — commodity-backed tokens belong on `B20Security` | XAU, XAG, XPT, XPD |
+| European composite units | Excluded | Defunct supranational accounting units retained for historical reconciliation | XBA, XBB, XBC, XBD |
+| Other supranational synthetics | Excluded | Reserve assets and regional units of account, not circulating currencies | XDR, XSU, XUA |
+| Sentinels | Excluded | Reserved markers ("no currency" / test code), not currencies | XXX, XTS |
+| Funds codes / indexing units | Excluded | Inflation-indexing devices, complementary currencies, and forex settlement conventions — not things one can hold or settle in | BOV, CHE, CHW, CLF, COU, MXV, USN, UYI, UYW |
+
+- Crypto tickers and arbitrary strings are rejected by virtue of being off the ISO 4217 active list (no explicit entry needed).
+- Per-entry rationale for each blocklist code lives inline in `ISO4217.excludedAt`.
+- Any future Rust precompile implementation must mirror this allowlist and blocklist exactly.
+
+## Risks and mitigations
+
+| Concern | Mitigation |
+| --- | --- |
+| Commodity-backed tokens marketed as stablecoins (PAXG, XAUT, AABBG) will not be admitted here | These are structurally claims on a vault — securities-shaped instruments. They belong on the `B20Security` variant. |
+| Crypto-collateralized stablecoins (DAI, LUSD, crvUSD) appear to be excluded | They fit this variant fine. The backing mechanism (custodial reserves, on-chain collateral, T-bills) is irrelevant to `currency()`; what matters is the peg target. If a token pegs to USD, declare `"USD"`. |
+| Basket-pegged tokens (historically Libra/Diem) and algorithmic non-pegged stable assets (Ampleforth, historically Terra UST) have no current B-20 home | Accepted trade-off. A future basket / ART variant or use of the `B20` Default variant with custom monetary policy would be the path, not relaxation of this variant. |
+| The variant name "Stablecoin" carries broader industry connotations than its admitted set | Anchored in regulatory precedent — MiCA EMT, MAS SCS, and US payment-stablecoin legislative proposals all draw the same line. |
+| The allowlist is self-declared, not a trust signal — an issuer can declare `currency = "USD"` without backing reserves | The factory enforces format and membership only. Any protocol consuming `currency()` for an authorization or routing decision MUST layer its own issuer/contract allowlist on top. The standardized identifier is what those consumer-side allowlists organize around, not a substitute for them. |
+| Adding or removing an ISO 4217 code requires a contract change | Real but rare — ISO 4217 registrations happen on the order of once per year. Both the Solidity reference and any Rust precompile implementation must be updated in lockstep when changes do occur. |
+
+## Alternatives considered
+
+| Option | Pros | Cons |
+| --- | --- | --- |
+| **No validation**
Accept any non-empty
string for `currency` | • Simplest impl
• Zero maintenance
• Max issuer flexibility | • Typos (`"usd"`, `"USDC"`) pollute value space
• No on-chain categorization
• Admits arbitrary strings |
+| **Format-only check**
Length 3 + uppercase
ASCII; no allowlist | • Cheap
• No allowlist to maintain
• Catches obvious garbage | • Admits `"ZZZ"`, `"BTC"`, `"ETH"`, etc.
• No semantic gate |
+| **Full ISO 4217 active list**
Every alphabetic code,
incl. X-prefix metals,
supranational synthetics,
funds codes
(TIP-20 broad-scope
precedent) | • Matches the official standard literally
• Broadest legitimate value space
• Familiar to FX-adjacent tooling | • Includes commodities (belong on `B20Security`)
• Includes funds codes (CLF, USN — not holdable)
• Breaks regulatory alignment with MiCA EMT / MAS SCS |
+| **Narrow ISO 4217 fiat allowlist** *(chosen)*
Circulating national
fiat only;
MiCA EMT / MAS SCS
aligned | • Standardized value space
• Rejects typos at creation
• Regulatory-category alignment
• Commodities pushed to `B20Security` | • Requires allowlist maintenance (~1/year)
• ISO 4217 updates need lockstep Rust impl change |
+
+## Supported currencies
+
+All 155 codes on the allowlist, alphabetical by code.
+
+| Code | Currency | Region / issuer |
+| --- | --- | --- |
+| AED | UAE Dirham | United Arab Emirates |
+| AFN | Afghan Afghani | Afghanistan |
+| ALL | Albanian Lek | Albania |
+| AMD | Armenian Dram | Armenia |
+| ANG | Netherlands Antillean Guilder | Curaçao, Sint Maarten |
+| AOA | Angolan Kwanza | Angola |
+| ARS | Argentine Peso | Argentina |
+| AUD | Australian Dollar | Australia |
+| AWG | Aruban Florin | Aruba |
+| AZN | Azerbaijani Manat | Azerbaijan |
+| BAM | Bosnia and Herzegovina Convertible Mark | Bosnia and Herzegovina |
+| BBD | Barbadian Dollar | Barbados |
+| BDT | Bangladeshi Taka | Bangladesh |
+| BGN | Bulgarian Lev | Bulgaria |
+| BHD | Bahraini Dinar | Bahrain |
+| BIF | Burundian Franc | Burundi |
+| BMD | Bermudian Dollar | Bermuda |
+| BND | Brunei Dollar | Brunei |
+| BOB | Bolivian Boliviano | Bolivia |
+| BRL | Brazilian Real | Brazil |
+| BSD | Bahamian Dollar | Bahamas |
+| BTN | Bhutanese Ngultrum | Bhutan |
+| BWP | Botswana Pula | Botswana |
+| BYN | Belarusian Ruble | Belarus |
+| BZD | Belize Dollar | Belize |
+| CAD | Canadian Dollar | Canada |
+| CDF | Congolese Franc | DR Congo |
+| CHF | Swiss Franc | Switzerland, Liechtenstein |
+| CNY | Chinese Yuan Renminbi | China |
+| COP | Colombian Peso | Colombia |
+| CRC | Costa Rican Colón | Costa Rica |
+| CUP | Cuban Peso | Cuba |
+| CVE | Cape Verdean Escudo | Cape Verde |
+| CZK | Czech Koruna | Czech Republic |
+| DJF | Djiboutian Franc | Djibouti |
+| DKK | Danish Krone | Denmark, Greenland, Faroe Islands |
+| DOP | Dominican Peso | Dominican Republic |
+| DZD | Algerian Dinar | Algeria |
+| EGP | Egyptian Pound | Egypt |
+| ERN | Eritrean Nakfa | Eritrea |
+| ETB | Ethiopian Birr | Ethiopia |
+| EUR | Euro | Eurozone |
+| FJD | Fijian Dollar | Fiji |
+| FKP | Falkland Islands Pound | Falkland Islands |
+| GBP | British Pound Sterling | United Kingdom |
+| GEL | Georgian Lari | Georgia |
+| GHS | Ghanaian Cedi | Ghana |
+| GIP | Gibraltar Pound | Gibraltar |
+| GMD | Gambian Dalasi | The Gambia |
+| GNF | Guinean Franc | Guinea |
+| GTQ | Guatemalan Quetzal | Guatemala |
+| GYD | Guyanese Dollar | Guyana |
+| HKD | Hong Kong Dollar | Hong Kong |
+| HNL | Honduran Lempira | Honduras |
+| HTG | Haitian Gourde | Haiti |
+| HUF | Hungarian Forint | Hungary |
+| IDR | Indonesian Rupiah | Indonesia |
+| ILS | Israeli New Shekel | Israel |
+| INR | Indian Rupee | India, Bhutan |
+| IQD | Iraqi Dinar | Iraq |
+| IRR | Iranian Rial | Iran |
+| ISK | Icelandic Króna | Iceland |
+| JMD | Jamaican Dollar | Jamaica |
+| JOD | Jordanian Dinar | Jordan |
+| JPY | Japanese Yen | Japan |
+| KES | Kenyan Shilling | Kenya |
+| KGS | Kyrgyzstani Som | Kyrgyzstan |
+| KHR | Cambodian Riel | Cambodia |
+| KMF | Comorian Franc | Comoros |
+| KPW | North Korean Won | North Korea |
+| KRW | South Korean Won | South Korea |
+| KWD | Kuwaiti Dinar | Kuwait |
+| KYD | Cayman Islands Dollar | Cayman Islands |
+| KZT | Kazakhstani Tenge | Kazakhstan |
+| LAK | Lao Kip | Laos |
+| LBP | Lebanese Pound | Lebanon |
+| LKR | Sri Lankan Rupee | Sri Lanka |
+| LRD | Liberian Dollar | Liberia |
+| LSL | Lesotho Loti | Lesotho |
+| LYD | Libyan Dinar | Libya |
+| MAD | Moroccan Dirham | Morocco |
+| MDL | Moldovan Leu | Moldova |
+| MGA | Malagasy Ariary | Madagascar |
+| MKD | Macedonian Denar | North Macedonia |
+| MMK | Burmese Kyat | Myanmar |
+| MNT | Mongolian Tögrög | Mongolia |
+| MOP | Macanese Pataca | Macau |
+| MRU | Mauritanian Ouguiya | Mauritania |
+| MUR | Mauritian Rupee | Mauritius |
+| MVR | Maldivian Rufiyaa | Maldives |
+| MWK | Malawian Kwacha | Malawi |
+| MXN | Mexican Peso | Mexico |
+| MYR | Malaysian Ringgit | Malaysia |
+| MZN | Mozambican Metical | Mozambique |
+| NAD | Namibian Dollar | Namibia |
+| NGN | Nigerian Naira | Nigeria |
+| NIO | Nicaraguan Córdoba | Nicaragua |
+| NOK | Norwegian Krone | Norway |
+| NPR | Nepalese Rupee | Nepal |
+| NZD | New Zealand Dollar | New Zealand |
+| OMR | Omani Rial | Oman |
+| PAB | Panamanian Balboa | Panama |
+| PEN | Peruvian Sol | Peru |
+| PGK | Papua New Guinean Kina | Papua New Guinea |
+| PHP | Philippine Peso | Philippines |
+| PKR | Pakistani Rupee | Pakistan |
+| PLN | Polish Złoty | Poland |
+| PYG | Paraguayan Guaraní | Paraguay |
+| QAR | Qatari Riyal | Qatar |
+| RON | Romanian Leu | Romania |
+| RSD | Serbian Dinar | Serbia |
+| RUB | Russian Ruble | Russia |
+| RWF | Rwandan Franc | Rwanda |
+| SAR | Saudi Riyal | Saudi Arabia |
+| SBD | Solomon Islands Dollar | Solomon Islands |
+| SCR | Seychellois Rupee | Seychelles |
+| SDG | Sudanese Pound | Sudan |
+| SEK | Swedish Krona | Sweden |
+| SGD | Singapore Dollar | Singapore |
+| SHP | Saint Helena Pound | Saint Helena, Ascension |
+| SLE | Sierra Leonean Leone | Sierra Leone |
+| SOS | Somali Shilling | Somalia |
+| SRD | Surinamese Dollar | Suriname |
+| SSP | South Sudanese Pound | South Sudan |
+| STN | São Tomé and Príncipe Dobra | São Tomé and Príncipe |
+| SVC | Salvadoran Colón | El Salvador |
+| SYP | Syrian Pound | Syria |
+| SZL | Eswatini Lilangeni | Eswatini |
+| THB | Thai Baht | Thailand |
+| TJS | Tajikistani Somoni | Tajikistan |
+| TMT | Turkmenistani Manat | Turkmenistan |
+| TND | Tunisian Dinar | Tunisia |
+| TOP | Tongan Paʻanga | Tonga |
+| TRY | Turkish Lira | Turkey |
+| TTD | Trinidad and Tobago Dollar | Trinidad and Tobago |
+| TWD | New Taiwan Dollar | Taiwan |
+| TZS | Tanzanian Shilling | Tanzania |
+| UAH | Ukrainian Hryvnia | Ukraine |
+| UGX | Ugandan Shilling | Uganda |
+| USD | United States Dollar | United States (and El Salvador, Ecuador, Panama, others) |
+| UYU | Uruguayan Peso | Uruguay |
+| UZS | Uzbekistani Som | Uzbekistan |
+| VED | Venezuelan Bolívar Digital | Venezuela |
+| VES | Venezuelan Bolívar Soberano | Venezuela |
+| VND | Vietnamese Đồng | Vietnam |
+| VUV | Vanuatu Vatu | Vanuatu |
+| WST | Samoan Tālā | Samoa |
+| XAF | Central African CFA Franc | BEAC members (Cameroon, CAR, Chad, Congo, Equatorial Guinea, Gabon) |
+| XCD | East Caribbean Dollar | ECCB members (Anguilla, Antigua, Dominica, Grenada, Montserrat, Saint Kitts and Nevis, Saint Lucia, Saint Vincent and the Grenadines) |
+| XOF | West African CFA Franc | BCEAO members (Benin, Burkina Faso, Côte d'Ivoire, Guinea-Bissau, Mali, Niger, Senegal, Togo) |
+| XPF | CFP Franc | French Pacific (French Polynesia, New Caledonia, Wallis and Futuna) |
+| YER | Yemeni Rial | Yemen |
+| ZAR | South African Rand | South Africa (and CMA: Eswatini, Lesotho, Namibia) |
+| ZMW | Zambian Kwacha | Zambia |
+| ZWG | Zimbabwe Gold | Zimbabwe |
diff --git a/src/interfaces/IB20Stablecoin.sol b/src/interfaces/IB20Stablecoin.sol
index f6084c2..b2f5e6b 100644
--- a/src/interfaces/IB20Stablecoin.sol
+++ b/src/interfaces/IB20Stablecoin.sol
@@ -3,27 +3,18 @@ pragma solidity >=0.8.20 <0.9.0;
import {IB20} from "./IB20.sol";
-/// @title IB20Stablecoin
-/// @notice A B-20 token variant for value-pegged tokens (USD, EUR, XAU, etc.).
-/// Inherits the full `IB20` surface and adds a single
-/// immutable `currency()` identifier for routing, categorization,
-/// and wallet display.
-///
+/// @title IB20Stablecoin
+/// @notice B-20 variant for fiat-pegged stablecoins. Inherits the full
+/// `IB20` surface and adds one immutable `currency()` identifier.
+/// @dev Scope is fiat-only; commodity-backed and basket tokens belong
+/// elsewhere. See `docs/b20/stablecoin/currency-validation.md` for the inclusion /
+/// exclusion lists, regulatory framing, and trust model.
interface IB20Stablecoin is IB20 {
- /*//////////////////////////////////////////////////////////////
- CURRENCY IDENTIFIER
- //////////////////////////////////////////////////////////////*/
-
- /// @notice The reference asset this stablecoin is designed to track.
- /// Set at creation by the factory; immutable thereafter.
- /// @dev Two stablecoins tracking the same asset return the same
- /// identifier. Conventions:
- /// - ISO-4217 codes for fiat / commodity references: "USD",
- /// "EUR", "JPY", "XAU" (gold), "XAG" (silver).
- /// - Symbol for non-ISO references: "BTC", "ETH" (for tokens
- /// tracking the price of those assets).
- /// - The token's own symbol if it tracks no external reference
- /// (governance, utility tokens that nonetheless want the
- /// stablecoin variant for the operational surface).
+ /// @notice The ISO 4217 fiat code this stablecoin tracks
+ /// (e.g. `"USD"`, `"EUR"`, `"JPY"`). Set at creation,
+ /// immutable thereafter.
+ /// @dev Self-declared and not verified by the contract. See
+ /// `docs/b20/stablecoin/currency-validation.md` for the validated value space
+ /// and what consumers must layer on top.
function currency() external view returns (string memory);
}
diff --git a/src/interfaces/ITokenFactory.sol b/src/interfaces/ITokenFactory.sol
index a07974d..ca6f664 100644
--- a/src/interfaces/ITokenFactory.sol
+++ b/src/interfaces/ITokenFactory.sol
@@ -112,12 +112,15 @@ interface ITokenFactory {
/// @param name ERC-20 token name.
/// @param symbol ERC-20 token symbol.
/// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`.
- /// @param currency Immutable currency identifier (e.g. "USD",
- /// "EUR", "XAU"). Required: empty string
- /// reverts. See `IB20Stablecoin.currency` for
- /// the convention.
- /// @dev Decimals are fixed at `6` (the SPL stablecoin convention).
- /// There is no decimals field on this struct.
+ /// @param currency Immutable ISO 4217 fiat code this stablecoin
+ /// tracks (e.g. `"USD"`, `"EUR"`). Validated
+ /// against the allowlist in `ISO4217.sol`;
+ /// anything off the list reverts with
+ /// `InvalidCurrency(code)`. See
+ /// `docs/b20/stablecoin/currency-validation.md`.
+ /// @dev Decimals are fixed at `6`. There is no decimals field
+ /// and no setter for `currency` — both are fixed for the
+ /// token's lifetime at creation.
struct B20StablecoinCreateParams {
uint8 version;
string name;
@@ -169,9 +172,20 @@ interface ITokenFactory {
error UnsupportedVersion(uint8 version);
/// @notice A required string argument was the empty string (e.g.
- /// stablecoin `currency`, security `isin`).
+ /// security `isin`). The stablecoin `currency` field is
+ /// validated more tightly and reverts with
+ /// `InvalidCurrency` instead — including for the empty
+ /// string — so callers get a single, diagnostic-carrying
+ /// error for every currency rejection rather than two
+ /// disjoint failure modes for the same field.
error MissingRequiredField();
+ /// @notice The stablecoin `currency` field was not on the ISO 4217
+ /// fiat allowlist. Carries the offending string verbatim
+ /// for diagnostics.
+ /// @dev See `docs/b20/stablecoin/currency-validation.md` for the allowlist.
+ error InvalidCurrency(string code);
+
/// @notice One of the `initCalls` reverted. The factory bubbles the
/// underlying revert reason where the call returns one;
/// this error wraps empty reverts.
diff --git a/test/lib/ISO4217.sol b/test/lib/ISO4217.sol
new file mode 100644
index 0000000..c66e80f
--- /dev/null
+++ b/test/lib/ISO4217.sol
@@ -0,0 +1,376 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+/// @title ISO4217
+/// @notice Helpers anchored in the ISO 4217 currency-code standard.
+/// Exposes two primitives:
+/// - `isValidFiatCode` — allowlist of active ISO 4217 alphabetic
+/// codes for circulating national fiat currencies.
+/// - `excludedCount` / `excludedAt` — enumerable record of ISO
+/// 4217 codes that are on the standard but deliberately
+/// excluded, with per-entry rationale inline in `excludedAt`.
+/// @dev See `docs/b20/stablecoin/currency-validation.md` for scope, exclusion categories,
+/// and the regulatory framing behind the narrow fiat scope.
+/// Any future Rust precompile implementation must mirror both
+/// lists exactly.
+library ISO4217 {
+ /// @notice Thrown by `excludedAt` when `idx` exceeds `excludedCount`.
+ error IndexOutOfBounds(uint256 idx);
+
+ // ============================================================
+ // Allowlist (ISO 4217 active circulating-fiat alphabetic codes)
+ // ============================================================
+ // Declared as `bytes3` constants so the lookup is a direct
+ // 32-byte equality (no keccak per comparison) and the canonical
+ // list is visible as data at the top of the file. Organized
+ // alphabetically for diff / audit against the ISO 4217 register.
+
+ bytes3 private constant AED = "AED";
+ bytes3 private constant AFN = "AFN";
+ bytes3 private constant ALL = "ALL";
+ bytes3 private constant AMD = "AMD";
+ bytes3 private constant ANG = "ANG";
+ bytes3 private constant AOA = "AOA";
+ bytes3 private constant ARS = "ARS";
+ bytes3 private constant AUD = "AUD";
+ bytes3 private constant AWG = "AWG";
+ bytes3 private constant AZN = "AZN";
+
+ bytes3 private constant BAM = "BAM";
+ bytes3 private constant BBD = "BBD";
+ bytes3 private constant BDT = "BDT";
+ bytes3 private constant BGN = "BGN";
+ bytes3 private constant BHD = "BHD";
+ bytes3 private constant BIF = "BIF";
+ bytes3 private constant BMD = "BMD";
+ bytes3 private constant BND = "BND";
+ bytes3 private constant BOB = "BOB";
+ bytes3 private constant BRL = "BRL";
+ bytes3 private constant BSD = "BSD";
+ bytes3 private constant BTN = "BTN";
+ bytes3 private constant BWP = "BWP";
+ bytes3 private constant BYN = "BYN";
+ bytes3 private constant BZD = "BZD";
+
+ bytes3 private constant CAD = "CAD";
+ bytes3 private constant CDF = "CDF";
+ bytes3 private constant CHF = "CHF";
+ bytes3 private constant CNY = "CNY";
+ bytes3 private constant COP = "COP";
+ bytes3 private constant CRC = "CRC";
+ bytes3 private constant CUP = "CUP";
+ bytes3 private constant CVE = "CVE";
+ bytes3 private constant CZK = "CZK";
+
+ bytes3 private constant DJF = "DJF";
+ bytes3 private constant DKK = "DKK";
+ bytes3 private constant DOP = "DOP";
+ bytes3 private constant DZD = "DZD";
+
+ bytes3 private constant EGP = "EGP";
+ bytes3 private constant ERN = "ERN";
+ bytes3 private constant ETB = "ETB";
+ bytes3 private constant EUR = "EUR";
+
+ bytes3 private constant FJD = "FJD";
+ bytes3 private constant FKP = "FKP";
+
+ bytes3 private constant GBP = "GBP";
+ bytes3 private constant GEL = "GEL";
+ bytes3 private constant GHS = "GHS";
+ bytes3 private constant GIP = "GIP";
+ bytes3 private constant GMD = "GMD";
+ bytes3 private constant GNF = "GNF";
+ bytes3 private constant GTQ = "GTQ";
+ bytes3 private constant GYD = "GYD";
+
+ bytes3 private constant HKD = "HKD";
+ bytes3 private constant HNL = "HNL";
+ bytes3 private constant HTG = "HTG";
+ bytes3 private constant HUF = "HUF";
+
+ bytes3 private constant IDR = "IDR";
+ bytes3 private constant ILS = "ILS";
+ bytes3 private constant INR = "INR";
+ bytes3 private constant IQD = "IQD";
+ bytes3 private constant IRR = "IRR";
+ bytes3 private constant ISK = "ISK";
+
+ bytes3 private constant JMD = "JMD";
+ bytes3 private constant JOD = "JOD";
+ bytes3 private constant JPY = "JPY";
+
+ bytes3 private constant KES = "KES";
+ bytes3 private constant KGS = "KGS";
+ bytes3 private constant KHR = "KHR";
+ bytes3 private constant KMF = "KMF";
+ bytes3 private constant KPW = "KPW";
+ bytes3 private constant KRW = "KRW";
+ bytes3 private constant KWD = "KWD";
+ bytes3 private constant KYD = "KYD";
+ bytes3 private constant KZT = "KZT";
+
+ bytes3 private constant LAK = "LAK";
+ bytes3 private constant LBP = "LBP";
+ bytes3 private constant LKR = "LKR";
+ bytes3 private constant LRD = "LRD";
+ bytes3 private constant LSL = "LSL";
+ bytes3 private constant LYD = "LYD";
+
+ bytes3 private constant MAD = "MAD";
+ bytes3 private constant MDL = "MDL";
+ bytes3 private constant MGA = "MGA";
+ bytes3 private constant MKD = "MKD";
+ bytes3 private constant MMK = "MMK";
+ bytes3 private constant MNT = "MNT";
+ bytes3 private constant MOP = "MOP";
+ bytes3 private constant MRU = "MRU";
+ bytes3 private constant MUR = "MUR";
+ bytes3 private constant MVR = "MVR";
+ bytes3 private constant MWK = "MWK";
+ bytes3 private constant MXN = "MXN";
+ bytes3 private constant MYR = "MYR";
+ bytes3 private constant MZN = "MZN";
+
+ bytes3 private constant NAD = "NAD";
+ bytes3 private constant NGN = "NGN";
+ bytes3 private constant NIO = "NIO";
+ bytes3 private constant NOK = "NOK";
+ bytes3 private constant NPR = "NPR";
+ bytes3 private constant NZD = "NZD";
+
+ bytes3 private constant OMR = "OMR";
+
+ bytes3 private constant PAB = "PAB";
+ bytes3 private constant PEN = "PEN";
+ bytes3 private constant PGK = "PGK";
+ bytes3 private constant PHP = "PHP";
+ bytes3 private constant PKR = "PKR";
+ bytes3 private constant PLN = "PLN";
+ bytes3 private constant PYG = "PYG";
+
+ bytes3 private constant QAR = "QAR";
+
+ bytes3 private constant RON = "RON";
+ bytes3 private constant RSD = "RSD";
+ bytes3 private constant RUB = "RUB";
+ bytes3 private constant RWF = "RWF";
+
+ bytes3 private constant SAR = "SAR";
+ bytes3 private constant SBD = "SBD";
+ bytes3 private constant SCR = "SCR";
+ bytes3 private constant SDG = "SDG";
+ bytes3 private constant SEK = "SEK";
+ bytes3 private constant SGD = "SGD";
+ bytes3 private constant SHP = "SHP";
+ bytes3 private constant SLE = "SLE";
+ bytes3 private constant SOS = "SOS";
+ bytes3 private constant SRD = "SRD";
+ bytes3 private constant SSP = "SSP";
+ bytes3 private constant STN = "STN";
+ bytes3 private constant SVC = "SVC";
+ bytes3 private constant SYP = "SYP";
+ bytes3 private constant SZL = "SZL";
+
+ bytes3 private constant THB = "THB";
+ bytes3 private constant TJS = "TJS";
+ bytes3 private constant TMT = "TMT";
+ bytes3 private constant TND = "TND";
+ bytes3 private constant TOP = "TOP";
+ bytes3 private constant TRY = "TRY";
+ bytes3 private constant TTD = "TTD";
+ bytes3 private constant TWD = "TWD";
+ bytes3 private constant TZS = "TZS";
+
+ bytes3 private constant UAH = "UAH";
+ bytes3 private constant UGX = "UGX";
+ bytes3 private constant USD = "USD";
+ bytes3 private constant UYU = "UYU";
+ bytes3 private constant UZS = "UZS";
+
+ bytes3 private constant VED = "VED";
+ bytes3 private constant VES = "VES";
+ bytes3 private constant VND = "VND";
+ bytes3 private constant VUV = "VUV";
+
+ bytes3 private constant WST = "WST";
+
+ // Multi-country circulating fiat (BCEAO, BEAC, ECCB, IEOM).
+ bytes3 private constant XAF = "XAF";
+ bytes3 private constant XCD = "XCD";
+ bytes3 private constant XOF = "XOF";
+ bytes3 private constant XPF = "XPF";
+
+ bytes3 private constant YER = "YER";
+
+ bytes3 private constant ZAR = "ZAR";
+ bytes3 private constant ZMW = "ZMW";
+ bytes3 private constant ZWG = "ZWG";
+
+ /// @notice Returns true iff `code` is on the active ISO 4217
+ /// circulating-fiat allowlist (exactly three ASCII bytes,
+ /// uppercase, on the curated set).
+ /// @dev O(1) per call: first-byte dispatch routes to a single
+ /// letter bucket of at most 15 entries (S is the largest;
+ /// most are <10). Worst case ≈ 16 word-equality
+ /// comparisons, vs ≈ 155 for a flat chain.
+ ///
+ /// Within each bucket, entries are ordered by approximate
+ /// FX-volume / economic-size / population so common codes
+ /// short-circuit early — e.g. USD is the first comparison
+ /// in the U bucket, EUR in E, JPY in J, CHF/CNY/CAD lead C.
+ /// Mean comparison count for real-world traffic is closer
+ /// to ~2 (first-byte + first match) than to the worst case.
+ /// Letters with no allowlist entries fall through to
+ /// `return false`.
+ function isValidFiatCode(string memory code) internal pure returns (bool) {
+ bytes memory b = bytes(code);
+ if (b.length != 3) return false;
+ bytes3 c;
+ // Left-justified 3-byte load; trailing 29 bytes are zero per
+ // Solidity's memory-zeroing guarantee between allocations.
+ // forge-lint: disable-next-line(asm-keccak256)
+ assembly {
+ c := mload(add(b, 32))
+ }
+ bytes1 first = bytes1(c);
+
+ // U: USD dominates global stablecoin volume — first match.
+ if (first == "U") return c == USD || c == UAH || c == UGX || c == UYU || c == UZS;
+ // E: EUR is top-2 in FX turnover.
+ if (first == "E") return c == EUR || c == EGP || c == ETB || c == ERN;
+ // J: JPY is G10.
+ if (first == "J") return c == JPY || c == JMD || c == JOD;
+ // G: GBP is G10.
+ if (first == "G") {
+ return c == GBP || c == GHS || c == GEL || c == GTQ || c == GIP || c == GMD || c == GNF
+ || c == GYD;
+ }
+ // C: three G10/major currencies (CHF, CNY, CAD) lead, then CZK.
+ if (first == "C") {
+ return c == CHF || c == CNY || c == CAD || c == CZK || c == COP || c == CRC || c == CUP
+ || c == CVE || c == CDF;
+ }
+ // A: AUD is G10; AED is a high-volume oil-linked unit.
+ if (first == "A") {
+ return c == AUD || c == AED || c == ARS || c == AMD || c == ANG || c == AOA || c == AFN
+ || c == ALL || c == AWG || c == AZN;
+ }
+ // N: NOK and NZD are both G10; NGN is the largest African economy.
+ if (first == "N") return c == NOK || c == NZD || c == NGN || c == NPR || c == NIO || c == NAD;
+ // S: SEK (G10), SGD (MAS-anchored), SAR (oil), then long tail.
+ if (first == "S") {
+ return c == SEK || c == SGD || c == SAR || c == SHP || c == SCR || c == SBD || c == SDG
+ || c == SLE || c == SOS || c == SRD || c == SSP || c == STN || c == SVC || c == SYP
+ || c == SZL;
+ }
+ // I: INR / IDR / ILS dominate the bucket.
+ if (first == "I") return c == INR || c == IDR || c == ILS || c == ISK || c == IQD || c == IRR;
+ // M: MXN (top-15 FX), MYR, MAD.
+ if (first == "M") {
+ return c == MXN || c == MYR || c == MAD || c == MNT || c == MMK || c == MUR || c == MOP
+ || c == MVR || c == MWK || c == MGA || c == MDL || c == MZN || c == MKD || c == MRU;
+ }
+ // T: TRY (notable for stablecoin demand under inflation), THB, TWD.
+ if (first == "T") {
+ return c == TRY || c == THB || c == TWD || c == TZS || c == TND || c == TOP || c == TTD
+ || c == TJS || c == TMT;
+ }
+ // P: PLN (top-20 FX), PHP, PKR.
+ if (first == "P") {
+ return c == PLN || c == PHP || c == PKR || c == PEN || c == PGK || c == PYG || c == PAB;
+ }
+ // K: KRW dominates the bucket.
+ if (first == "K") {
+ return c == KRW || c == KZT || c == KES || c == KWD || c == KGS || c == KHR || c == KMF
+ || c == KPW || c == KYD;
+ }
+ // B: BRL is the major; rest are long-tail.
+ if (first == "B") {
+ return c == BRL || c == BHD || c == BDT || c == BGN || c == BAM || c == BBD || c == BIF
+ || c == BMD || c == BND || c == BOB || c == BSD || c == BTN || c == BWP || c == BYN
+ || c == BZD;
+ }
+ // H: HKD is a major financial-center currency.
+ if (first == "H") return c == HKD || c == HUF || c == HNL || c == HTG;
+ // R: RUB is top-20 FX (though sanctioned).
+ if (first == "R") return c == RUB || c == RON || c == RSD || c == RWF;
+ // D: DKK is top-25 FX.
+ if (first == "D") return c == DKK || c == DOP || c == DZD || c == DJF;
+ // X: multi-country circulating fiat; XOF covers the largest population.
+ if (first == "X") return c == XOF || c == XAF || c == XCD || c == XPF;
+ // Z: ZAR is top-25 FX.
+ if (first == "Z") return c == ZAR || c == ZMW || c == ZWG;
+ // V: VND is the largest by economy/population in the bucket.
+ if (first == "V") return c == VND || c == VES || c == VED || c == VUV;
+ // L: LKR / LBP are roughly the most active.
+ if (first == "L") return c == LKR || c == LBP || c == LAK || c == LRD || c == LSL || c == LYD;
+ // Letters with one entry (O/Q/W/Y) and tiny multi-entry buckets (F).
+ if (first == "F") return c == FJD || c == FKP;
+ if (first == "O") return c == OMR;
+ if (first == "Q") return c == QAR;
+ if (first == "W") return c == WST;
+ if (first == "Y") return c == YER;
+
+ return false;
+ }
+
+ /// @notice Number of ISO 4217 codes deliberately excluded from
+ /// `isValidFiatCode`. Pair with `excludedAt` to enumerate.
+ /// Excludes only currently-active ISO 4217 entries; deprecated
+ /// codes (CUC, HRK, VEF, ZWL, etc.) are caught by absence
+ /// from the allowlist instead.
+ function excludedCount() internal pure returns (uint256) {
+ return 22;
+ }
+
+ /// @notice Returns the excluded code at index `idx`. Per-entry
+ /// rationale is inline in this function's body, grouped by
+ /// exclusion category.
+ /// @dev Index order is stable; new entries append. Fuzz tests
+ /// drive `idx` via `seed % excludedCount()` to pick up new
+ /// entries automatically.
+ function excludedAt(uint256 idx) internal pure returns (string memory) {
+ // Precious metals (commodities, not means of payment).
+ // Commodity-backed tokens belong on the B-20 Security variant.
+ if (idx == 0) return "XAU"; // Gold
+ if (idx == 1) return "XAG"; // Silver
+ if (idx == 2) return "XPT"; // Platinum
+ if (idx == 3) return "XPD"; // Palladium
+
+ // European composite units (defunct supranational accounting
+ // units retained on ISO 4217 for historical reconciliation).
+ if (idx == 4) return "XBA"; // European Composite Unit (EURCO)
+ if (idx == 5) return "XBB"; // European Monetary Unit (E.M.U.-6)
+ if (idx == 6) return "XBC"; // European Unit of Account 9 (E.U.A.-9)
+ if (idx == 7) return "XBD"; // European Unit of Account 17 (E.U.A.-17)
+
+ // Other supranational synthetic units (reserve assets and
+ // composite indices, not circulating currencies).
+ if (idx == 8) return "XDR"; // IMF Special Drawing Rights
+ if (idx == 9) return "XSU"; // Sucre (ALBA regional unit)
+ if (idx == 10) return "XUA"; // ADB Unit of Account
+
+ // Sentinels (reserved by ISO 4217 for "no currency" and "test"
+ // — neither denotes an actual currency).
+ if (idx == 11) return "XXX"; // No-currency marker
+ if (idx == 12) return "XTS"; // Test code
+
+ // Funds codes (indexing units, internal accounting devices,
+ // and forex conventions). These exist to denominate
+ // inflation-indexed obligations or settlement timing; they
+ // are not things one can hold or settle in, so a stablecoin
+ // pegged to them is not coherent.
+ if (idx == 13) return "BOV"; // Bolivian Mvdol (indexing unit)
+ if (idx == 14) return "CHE"; // WIR Euro (Swiss complementary, WIR Bank)
+ if (idx == 15) return "CHW"; // WIR Franc (Swiss complementary, WIR Bank)
+ if (idx == 16) return "CLF"; // Chilean Unidad de Fomento (inflation-indexed)
+ if (idx == 17) return "COU"; // Colombian Unidad de Valor Real (inflation-indexed)
+ if (idx == 18) return "MXV"; // Mexican Unidad de Inversión (inflation-indexed)
+ if (idx == 19) return "USN"; // US Dollar Next Day (forex settlement convention)
+ if (idx == 20) return "UYI"; // Uruguayan UI (inflation-indexed)
+ if (idx == 21) return "UYW"; // Uruguayan Unidad Previsional (pension indexing)
+
+ revert IndexOutOfBounds(idx);
+ }
+}
diff --git a/test/lib/mocks/MockTokenFactory.sol b/test/lib/mocks/MockTokenFactory.sol
index a71b28f..a10392b 100644
--- a/test/lib/mocks/MockTokenFactory.sol
+++ b/test/lib/mocks/MockTokenFactory.sol
@@ -5,6 +5,8 @@ import {Vm} from "forge-std/Vm.sol";
import {ITokenFactory} from "src/interfaces/ITokenFactory.sol";
+import {ISO4217} from "test/lib/ISO4217.sol";
+
import {MockB20} from "test/lib/mocks/MockB20.sol";
import {MockB20Stablecoin} from "test/lib/mocks/MockB20Stablecoin.sol";
import {MockB20Storage, MockB20StablecoinStorage} from "test/lib/mocks/MockB20Storage.sol";
@@ -107,7 +109,8 @@ contract MockTokenFactory is ITokenFactory {
} else if (variant == TokenVariant.STABLECOIN) {
B20StablecoinCreateParams memory p = abi.decode(params, (B20StablecoinCreateParams));
if (p.version != 1) revert UnsupportedVersion(p.version);
- if (bytes(p.currency).length == 0) revert MissingRequiredField();
+ // ISO 4217 fiat allowlist; see docs/b20/stablecoin/currency-validation.md.
+ if (!ISO4217.isValidFiatCode(p.currency)) revert InvalidCurrency(p.currency);
name_ = p.name;
symbol_ = p.symbol;
admin = p.initialAdmin;
diff --git a/test/unit/TokenFactory/createToken.t.sol b/test/unit/TokenFactory/createToken.t.sol
index 6347f4e..b78f833 100644
--- a/test/unit/TokenFactory/createToken.t.sol
+++ b/test/unit/TokenFactory/createToken.t.sol
@@ -6,6 +6,7 @@ import {Vm} from "forge-std/Vm.sol";
import {IB20} from "src/interfaces/IB20.sol";
import {IB20Stablecoin} from "src/interfaces/IB20Stablecoin.sol";
import {ITokenFactory} from "src/interfaces/ITokenFactory.sol";
+import {ISO4217} from "test/lib/ISO4217.sol";
import {MockB20, B20Constants} from "test/lib/mocks/MockB20.sol";
import {MockB20Storage, MockB20StablecoinStorage} from "test/lib/mocks/MockB20Storage.sol";
@@ -57,16 +58,57 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest {
factory.createToken(ITokenFactory.TokenVariant.STABLECOIN, salt, abi.encode(p), new bytes[](0));
}
- /// @notice Verifies stablecoin createToken reverts when currency is the empty string
- /// @dev Per-variant required-field check; checks MissingRequiredField() error
- function test_createToken_revert_missingCurrency(address caller, bytes32 salt) public {
+ // STABLECOIN currency validation — see docs/b20/stablecoin/currency-validation.md.
+
+ /// @notice Any non-allowlist string reverts with `InvalidCurrency(code)`.
+ /// @dev Subsumes every point case (empty, wrong length/case, X-prefix, crypto, etc.)
+ /// via `vm.assume(!isValidFiatCode)`.
+ function test_createToken_revert_currency_rejectsNonAllowlist(string memory code, address caller, bytes32 salt)
+ public
+ {
_assumeValidCaller(caller);
- ITokenFactory.B20StablecoinCreateParams memory p = _stablecoinParams("USD Test", "USDT", admin, "");
+ vm.assume(!ISO4217.isValidFiatCode(code));
+ ITokenFactory.B20StablecoinCreateParams memory p = _stablecoinParams("Test", "TST", admin, code);
vm.prank(caller);
- vm.expectRevert(ITokenFactory.MissingRequiredField.selector);
+ vm.expectRevert(abi.encodeWithSelector(ITokenFactory.InvalidCurrency.selector, code));
factory.createToken(ITokenFactory.TokenVariant.STABLECOIN, salt, abi.encode(p), new bytes[](0));
}
+ /// @notice Every entry in the explicit ISO 4217 blocklist reverts.
+ /// @dev Pins the documented exclusions; new entries are picked up automatically.
+ function test_createToken_revert_currency_blocklist(uint256 seed, address caller) public {
+ _assumeValidCaller(caller);
+ uint256 idx = seed % ISO4217.excludedCount();
+ string memory code = ISO4217.excludedAt(idx);
+ ITokenFactory.B20StablecoinCreateParams memory p = _stablecoinParams("Test", "TST", admin, code);
+ vm.prank(caller);
+ vm.expectRevert(abi.encodeWithSelector(ITokenFactory.InvalidCurrency.selector, code));
+ factory.createToken(
+ ITokenFactory.TokenVariant.STABLECOIN, keccak256(abi.encode("blocklist", seed)), abi.encode(p), new bytes[](0)
+ );
+ }
+
+ /// @notice Rejected create leaves no partial state at the deterministic address.
+ /// @dev Retry with the same (sender, salt) and a valid currency must succeed at the same address.
+ function test_createToken_revert_currency_leavesNoPartialState(address caller, bytes32 salt) public {
+ _assumeValidCaller(caller);
+ address predicted = factory.getTokenAddress(ITokenFactory.TokenVariant.STABLECOIN, caller, salt);
+
+ // First attempt: invalid currency.
+ ITokenFactory.B20StablecoinCreateParams memory bad = _stablecoinParams("Test", "TST", admin, "XAU");
+ vm.prank(caller);
+ vm.expectRevert(abi.encodeWithSelector(ITokenFactory.InvalidCurrency.selector, "XAU"));
+ factory.createToken(ITokenFactory.TokenVariant.STABLECOIN, salt, abi.encode(bad), new bytes[](0));
+
+ assertEq(predicted.code.length, 0, "predicted address must be empty after rejected create");
+
+ // Retry with a valid currency. Same (sender, salt) -> same address.
+ address retried =
+ _createStablecoin(caller, salt, _stablecoinParams("Test", "TST", admin, "USD"), new bytes[](0));
+ assertEq(retried, predicted, "retry must produce the same deterministic address");
+ assertEq(IB20Stablecoin(retried).currency(), "USD", "retry currency must be the valid one");
+ }
+
/// @notice Verifies the SECURITY variant currently reverts UnsupportedVersion(0)
/// @dev Pins down the current "Security variant deferred to Katzman" behavior so it
/// can't silently start succeeding before the variant impl actually lands. Delete
@@ -192,6 +234,46 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest {
assertEq(actual, predicted, "createToken address must match prediction");
}
+ /// @notice Major reserve currencies (USD, EUR, JPY, GBP, CHF, CNY, CAD, AUD) are accepted.
+ /// @dev Round-trip through `currency()` proves the string is stored verbatim.
+ function test_createToken_success_currency_acceptsMajorFiatCodes(address caller) public {
+ _assumeValidCaller(caller);
+ string[8] memory majors = ["USD", "EUR", "JPY", "GBP", "CHF", "CNY", "CAD", "AUD"];
+ for (uint256 i = 0; i < majors.length; i++) {
+ address token = _createStablecoin(
+ caller,
+ keccak256(abi.encode("major-fiat", i)),
+ _stablecoinParams("Test", "TST", admin, majors[i]),
+ new bytes[](0)
+ );
+ assertEq(
+ IB20Stablecoin(token).currency(),
+ majors[i],
+ "currency() must round-trip the accepted code byte-for-byte"
+ );
+ }
+ }
+
+ /// @notice Multi-country X-prefix fiat (XOF, XAF, XCD, XPF) is accepted.
+ /// @dev Pins the deliberate carve-out: X-prefix is not a categorical exclusion.
+ function test_createToken_success_currency_acceptsMultiCountryXPrefix(address caller) public {
+ _assumeValidCaller(caller);
+ string[4] memory xFiat = ["XOF", "XAF", "XCD", "XPF"];
+ for (uint256 i = 0; i < xFiat.length; i++) {
+ address token = _createStablecoin(
+ caller,
+ keccak256(abi.encode("xprefix-fiat", i)),
+ _stablecoinParams("Test", "TST", admin, xFiat[i]),
+ new bytes[](0)
+ );
+ assertEq(
+ IB20Stablecoin(token).currency(),
+ xFiat[i],
+ "multi-country X-prefix fiat code must round-trip"
+ );
+ }
+ }
+
/// @notice Verifies createToken emits TokenCreated with the correct identity fields
/// @dev Event integrity: token, variant, name, symbol, decimals must match derived variant defaults.
/// Admin role assignment is announced via RoleGranted, not as a field on this event;