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;