From c128493f0a58bffdc325044cd49f04a1eb7fcf9a Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 20 May 2026 22:30:37 -0700 Subject: [PATCH 01/14] feat: restrict stablecoin currency to ISO 4217 fiat allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `ISO4217.sol` library exposing two primitives the `TokenFactory` mock now uses to validate `B20StablecoinCreateParams.currency`: an `isValidFiatCode` allowlist of circulating national fiat codes, and an enumerable `excludedAt` / `excludedCount` blocklist of ISO 4217 entries that are deliberately rejected (precious metals, supranational synthetics, sentinels, funds codes) with per-entry rationale. Replaces the prior empty-string-only check on `currency` with the allowlist enforcement; rejections surface through a new `InvalidCurrency(string)` error carrying the offending input. Updates the stablecoin and factory natspec to describe the trust model (self-declared identifier, not a trust signal — consumers bring their own admission logic) and the scope (X-prefix is not categorical; multi-country fiat codes XOF/XAF/XCD/XPF are included). Consolidates the currency-validation test surface from 15 point tests to 5: two explicit successes (majors + multi-country X-prefix), two fuzz reverts (universal non-allowlist rejection, blocklist enumeration), and one atomicity test. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/interfaces/IB20Stablecoin.sol | 65 +++-- src/interfaces/ITokenFactory.sol | 47 +++- src/utils/ISO4217.sol | 302 +++++++++++++++++++++++ test/lib/mocks/MockTokenFactory.sol | 11 +- test/unit/TokenFactory/createToken.t.sol | 153 +++++++++++- 5 files changed, 551 insertions(+), 27 deletions(-) create mode 100644 src/utils/ISO4217.sol diff --git a/src/interfaces/IB20Stablecoin.sol b/src/interfaces/IB20Stablecoin.sol index f6084c2..5184a26 100644 --- a/src/interfaces/IB20Stablecoin.sol +++ b/src/interfaces/IB20Stablecoin.sol @@ -4,26 +4,61 @@ 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. +/// @notice A B-20 token variant for tokens designed to track the value +/// of a national fiat currency. Inherits the full `IB20` +/// surface and adds a single immutable `currency()` identifier +/// used by downstream tooling (indexers, wallets, and any +/// protocol that wants to group tokens by the asset they peg +/// to) to categorize the token. /// 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 national fiat currency this stablecoin is designed + /// to track, expressed as an active ISO 4217 alphabetic + /// code (e.g. `"USD"`, `"EUR"`, `"JPY"`). Set at creation + /// by the factory; immutable thereafter. Two stablecoins + /// tracking the same currency return byte-identical values. + /// + /// @dev **Value space.** The factory validates this field + /// against a hardcoded allowlist of active ISO 4217 codes + /// registered as national means of payment. Any value + /// outside that allowlist reverts at creation with + /// `ITokenFactory.InvalidCurrency(code)`; see + /// `ISO4217.isValidFiatCode` for the canonical list. + /// Specifically excluded: + /// - **ISO 4217 X-prefix codes** (XAU/XAG/XPT/XPD precious + /// metals, XBA-XBD bond market units, XDR IMF special + /// drawing rights, XSU sucre, XUA ADB unit of account, + /// XTS test code, XXX no-currency sentinel). These are + /// reserved by ISO 4217 for non-currency uses. Tokens + /// backed by precious metals or other commodities are + /// securities-shaped instruments and belong on the + /// `IB20Security` variant. + /// - **Crypto tickers** (BTC, ETH, etc.) and any other + /// free-form symbol — out of scope for this variant. + /// + /// **Trust model.** This field is the issuer's *self- + /// declared* peg. The factory enforces format and + /// membership in the ISO 4217 fiat allowlist; it does NOT + /// verify the token is actually backed by, or actually + /// tracking, the named currency. Any protocol that consumes + /// `currency()` to make an authorization or categorization + /// decision MUST still apply its own allowlist or trust + /// resolution on top — typically an issuer / contract + /// allowlist maintained by the consumer. What this field + /// provides is a standardized, immutable, machine-readable + /// identifier that consumer-side allowlists can be + /// organized around (e.g. "must be on my issuer allowlist + /// AND `currency() == \"USD\"`"), not the allowlist itself. + /// + /// **Immutability.** No setter exists. The identifier is + /// fixed at construction by the factory and cannot be + /// changed afterward; mutability would let an admitted + /// token silently switch what it claims to represent and + /// break any consumer that built admission logic on top of + /// the field. function currency() external view returns (string memory); } diff --git a/src/interfaces/ITokenFactory.sol b/src/interfaces/ITokenFactory.sol index a07974d..f762901 100644 --- a/src/interfaces/ITokenFactory.sol +++ b/src/interfaces/ITokenFactory.sol @@ -112,12 +112,28 @@ 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. + /// @param currency Immutable identifier for the national fiat + /// currency this stablecoin is designed to + /// track. MUST be an active ISO 4217 + /// alphabetic code for a sovereign or + /// supranational means of payment (e.g. `"USD"`, + /// `"EUR"`, `"JPY"`, `"GBP"`). Any input + /// outside this allowlist — empty string, + /// wrong case, wrong length, unknown + /// three-letter combinations, ISO 4217 + /// X-prefix codes (XAU, XAG, XDR, XTS, XXX), + /// or crypto tickers (BTC, ETH) — reverts + /// with `InvalidCurrency(code)`. See + /// `IB20Stablecoin.currency` for the semantics + /// and the rationale behind the restriction; + /// the canonical allowlist is in + /// `ISO4217.isValidFiatCode`. /// @dev Decimals are fixed at `6` (the SPL stablecoin convention). - /// There is no decimals field on this struct. + /// There is no decimals field on this struct. The currency + /// allowlist is enforced at creation and is immutable for + /// the lifetime of the token — there is no setter on the + /// deployed contract, so this is the only place the + /// identifier can ever be chosen. struct B20StablecoinCreateParams { uint8 version; string name; @@ -169,9 +185,28 @@ 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 did not match an active + /// ISO 4217 national-fiat alphabetic code. Carries the + /// offending string verbatim for diagnostics. + /// + /// @dev Triggered for every form of invalid currency: empty + /// string, wrong length, wrong case (`"usd"`), unknown + /// three-letter combinations (`"ZZZ"`), X-prefix codes + /// (XAU, XAG, XDR, XXX, XTS — reserved by ISO 4217 for + /// non-national-currency uses), and out-of-scope tickers + /// (BTC, ETH, etc.). See `B20StablecoinCreateParams.currency` + /// for the rationale and value-space definition; the + /// reference allowlist lives in `ISO4217.isValidFiatCode`. + 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/src/utils/ISO4217.sol b/src/utils/ISO4217.sol new file mode 100644 index 0000000..e310c73 --- /dev/null +++ b/src/utils/ISO4217.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title ISO4217 +/// @notice Helpers anchored in the ISO 4217 currency-code standard. +/// Currently exposes two primitives: +/// - `isValidFiatCode`: a static allowlist of active ISO 4217 +/// alphabetic codes that correspond to **circulating national +/// fiat currencies**, used by the `TokenFactory` precompile +/// to validate the `currency` field passed to the STABLECOIN +/// variant of `createToken`. +/// - `excludedCount` / `excludedAt`: an enumerable record of +/// the ISO 4217 codes that are on the standard but +/// deliberately excluded from the fiat allowlist, with +/// inline comments explaining each exclusion. This makes the +/// rejected set readable as data rather than as the absence +/// of an entry, and lets tests fuzz over the blocklist to +/// prove every documented exclusion actually reverts. +/// +/// The library scope is left intentionally broad so that +/// further ISO 4217 helpers (numeric-code lookup, +/// decimal-exponent lookup, etc.) can land in this file +/// without restructuring imports. +/// +/// @dev **Scope.** "Fiat" here means a currency that is issued by +/// a sovereign or supranational monetary authority and +/// circulates as a means of payment — i.e., something you can +/// hold and settle in. This is narrower than "every ISO 4217 +/// alphabetic code": ISO 4217 also enumerates funds codes +/// (indexing units, internal accounting devices), bond-market +/// composite units, supranational synthetic reserve assets, +/// precious metals, and sentinel codes, none of which are +/// circulating currencies. +/// +/// **The X-prefix is NOT a categorical exclusion rule.** ISO +/// 4217 reserves the X-prefix for codes where no single ISO +/// 3166 country code applies. That covers both "not a +/// currency" (metals, sentinels, synthetic units) AND +/// "currency shared across multiple countries by a +/// supranational central bank." The latter category contains +/// four real circulating fiat currencies used by tens of +/// millions of people, which the allowlist DOES include: +/// - `XOF` — CFA Franc BCEAO (8 West African states, BCEAO) +/// - `XAF` — CFA Franc BEAC (6 Central African states, BEAC) +/// - `XCD` — East Caribbean Dollar (8 Caribbean states, ECCB) +/// - `XPF` — CFP Franc (French Pacific territories) +/// +/// **Why an allowlist at all.** `currency` is a self-declared, +/// immutable identifier. It is NOT a trust signal: a token can +/// declare any code without anyone checking that the token is +/// actually backed by the named currency. The allowlist +/// provides three concrete benefits regardless: +/// 1. Standardized value space — every stablecoin's +/// `currency()` returns a code from one well-known +/// registry, so tooling can categorize the field without +/// having to parse free-form strings or invent its own +/// taxonomy. +/// 2. Typo / garbage rejection at creation — `"usd"`, +/// `"USDT"`, `"US"`, `""` all revert at the factory rather +/// than silently shipping an unreadable identifier. +/// 3. Forced design discussion for new categories — any +/// consumer that wants to ship tokens outside the fiat +/// category (commodity-backed, crypto-tracking, governance +/// tokens with stablecoin operational surface) is pushed +/// into the variant discussion rather than smuggling +/// through this field. The B-20 Security variant is the +/// intended home for commodity-backed tokens. +/// +/// **What's excluded (enumerated by `excludedAt`).** +/// - **Precious metals** (XAU/XAG/XPT/XPD): commodities, not +/// means of payment. Commodity-backed tokens belong on the +/// Security variant. +/// - **European composite units** (XBA-XBD): defunct +/// supranational accounting units (EURCO, E.M.U.-6, E.U.A.-9, +/// E.U.A.-17). Retained on ISO 4217 for historical +/// reconciliation only. +/// - **Other supranational synthetic units** (XDR/XSU/XUA): +/// IMF Special Drawing Rights, Sucre, ADB Unit of Account. +/// Synthetic reserve assets, not circulating currencies. +/// - **Sentinels** (XXX/XTS): "no currency" marker and the +/// reserved test code. Neither is a currency. +/// - **Funds codes** (BOV, CHE, CHW, CLF, COU, MXV, USN, UYI, +/// UYW): indexing units, internal accounting devices, and +/// forex conventions. Most exist to denominate +/// inflation-indexed obligations or settlement timing; a +/// stablecoin pegged to "CLF" or "USN" is not coherent +/// because those aren't things one can hold or settle in. +/// +/// **Trust model recap.** The allowlist gates the *format and +/// membership* of the identifier, not the truthfulness of the +/// issuer's claim. A factory call with `currency = "USD"` and +/// no backing reserves still succeeds at this layer. Any +/// protocol that consumes `currency()` to make an authorization +/// or routing decision is responsible for its own admission +/// logic on top. +/// +/// **Implementation note.** Allowlist membership is tested by +/// comparing `keccak256(bytes(code))` against the hash of each +/// allowlist entry. Hashes of string literals are constant +/// expressions under the optimizer, so the per-call cost is +/// one keccak256 on the 3-byte input plus a chain of 32-byte +/// equalities. The alternative — direct `bytes3` comparison +/// via casts — trips forge-lint's `unsafe-typecast` rule on +/// every literal and requires ~150 inline suppressions; this +/// form keeps the lint clean without sacrificing readability. +/// +/// **Updating the lists.** Adding a new ISO 4217 active code +/// (rare; registrations happen on the order of once per year) +/// is a contract change here or in the Rust precompile that +/// mirrors it. Both lists are organized alphabetically by +/// leading letter (allowlist) or by exclusion category +/// (blocklist) for ease of audit and diff. +library ISO4217 { + /// @notice Thrown by `excludedAt` when `idx` exceeds `excludedCount`. + error IndexOutOfBounds(uint256 idx); + + /// @notice Returns true iff `code` is exactly three ASCII bytes + /// long and matches an active ISO 4217 circulating-fiat + /// alphabetic code. See the library-level natspec for the + /// scope and the rationale behind every exclusion. + function isValidFiatCode(string memory code) internal pure returns (bool) { + bytes memory b = bytes(code); + if (b.length != 3) return false; + bytes32 h = keccak256(b); + + // A: Arabian-region, Caucasus, Latin-America, Pacific + if ( + h == keccak256("AED") || h == keccak256("AFN") || h == keccak256("ALL") || h == keccak256("AMD") + || h == keccak256("ANG") || h == keccak256("AOA") || h == keccak256("ARS") || h == keccak256("AUD") + || h == keccak256("AWG") || h == keccak256("AZN") + ) return true; + // B + if ( + h == keccak256("BAM") || h == keccak256("BBD") || h == keccak256("BDT") || h == keccak256("BGN") + || h == keccak256("BHD") || h == keccak256("BIF") || h == keccak256("BMD") || h == keccak256("BND") + || h == keccak256("BOB") || h == keccak256("BRL") || h == keccak256("BSD") || h == keccak256("BTN") + || h == keccak256("BWP") || h == keccak256("BYN") || h == keccak256("BZD") + ) return true; + // C + if ( + h == keccak256("CAD") || h == keccak256("CDF") || h == keccak256("CHF") || h == keccak256("CNY") + || h == keccak256("COP") || h == keccak256("CRC") || h == keccak256("CUP") || h == keccak256("CVE") + || h == keccak256("CZK") + ) return true; + // D, E + if ( + h == keccak256("DJF") || h == keccak256("DKK") || h == keccak256("DOP") || h == keccak256("DZD") + || h == keccak256("EGP") || h == keccak256("ERN") || h == keccak256("ETB") || h == keccak256("EUR") + ) return true; + // F, G + if ( + h == keccak256("FJD") || h == keccak256("FKP") || h == keccak256("GBP") || h == keccak256("GEL") + || h == keccak256("GHS") || h == keccak256("GIP") || h == keccak256("GMD") || h == keccak256("GNF") + || h == keccak256("GTQ") || h == keccak256("GYD") + ) return true; + // H, I + if ( + h == keccak256("HKD") || h == keccak256("HNL") || h == keccak256("HTG") || h == keccak256("HUF") + || h == keccak256("IDR") || h == keccak256("ILS") || h == keccak256("INR") || h == keccak256("IQD") + || h == keccak256("IRR") || h == keccak256("ISK") + ) return true; + // J, K + if ( + h == keccak256("JMD") || h == keccak256("JOD") || h == keccak256("JPY") || h == keccak256("KES") + || h == keccak256("KGS") || h == keccak256("KHR") || h == keccak256("KMF") || h == keccak256("KPW") + || h == keccak256("KRW") || h == keccak256("KWD") || h == keccak256("KYD") || h == keccak256("KZT") + ) return true; + // L + if ( + h == keccak256("LAK") || h == keccak256("LBP") || h == keccak256("LKR") || h == keccak256("LRD") + || h == keccak256("LSL") || h == keccak256("LYD") + ) return true; + // M + if ( + h == keccak256("MAD") || h == keccak256("MDL") || h == keccak256("MGA") || h == keccak256("MKD") + || h == keccak256("MMK") || h == keccak256("MNT") || h == keccak256("MOP") || h == keccak256("MRU") + || h == keccak256("MUR") || h == keccak256("MVR") || h == keccak256("MWK") || h == keccak256("MXN") + || h == keccak256("MYR") || h == keccak256("MZN") + ) return true; + // N, O + if ( + h == keccak256("NAD") || h == keccak256("NGN") || h == keccak256("NIO") || h == keccak256("NOK") + || h == keccak256("NPR") || h == keccak256("NZD") || h == keccak256("OMR") + ) return true; + // P, Q + if ( + h == keccak256("PAB") || h == keccak256("PEN") || h == keccak256("PGK") || h == keccak256("PHP") + || h == keccak256("PKR") || h == keccak256("PLN") || h == keccak256("PYG") || h == keccak256("QAR") + ) return true; + // R + if (h == keccak256("RON") || h == keccak256("RSD") || h == keccak256("RUB") || h == keccak256("RWF")) { + return true; + } + // S + if ( + h == keccak256("SAR") || h == keccak256("SBD") || h == keccak256("SCR") || h == keccak256("SDG") + || h == keccak256("SEK") || h == keccak256("SGD") || h == keccak256("SHP") || h == keccak256("SLE") + || h == keccak256("SOS") || h == keccak256("SRD") || h == keccak256("SSP") || h == keccak256("STN") + || h == keccak256("SVC") || h == keccak256("SYP") || h == keccak256("SZL") + ) return true; + // T + if ( + h == keccak256("THB") || h == keccak256("TJS") || h == keccak256("TMT") || h == keccak256("TND") + || h == keccak256("TOP") || h == keccak256("TRY") || h == keccak256("TTD") || h == keccak256("TWD") + || h == keccak256("TZS") + ) return true; + // U + if ( + h == keccak256("UAH") || h == keccak256("UGX") || h == keccak256("USD") || h == keccak256("UYU") + || h == keccak256("UZS") + ) return true; + // V, W + if ( + h == keccak256("VED") || h == keccak256("VES") || h == keccak256("VND") || h == keccak256("VUV") + || h == keccak256("WST") + ) return true; + // X (multi-country circulating fiat — see library natspec for + // why these four codes are deliberately accepted despite the + // X-prefix being commonly associated with non-currency entries) + if ( + h == keccak256("XAF") || h == keccak256("XCD") || h == keccak256("XOF") || h == keccak256("XPF") + ) return true; + // Y, Z + if (h == keccak256("YER") || h == keccak256("ZAR") || h == keccak256("ZMW") || h == keccak256("ZWG")) { + return true; + } + + return false; + } + + /// @notice The number of ISO 4217 alphabetic codes that are on + /// the standard but deliberately excluded from + /// `isValidFiatCode`. Pair with `excludedAt` to enumerate. + /// + /// @dev The blocklist exists to document — rather than silently + /// drop — every ISO 4217 entry that was considered for + /// inclusion and rejected, so the rejected set is readable + /// as data (with per-entry rationale in `excludedAt`'s + /// body) and so tests can fuzz over the full blocklist to + /// prove the validator's exclusions match the documented + /// design. Deprecated / withdrawn codes (CUC, HRK, VEF, + /// ZWL, etc.) are NOT in the blocklist because they're no + /// longer on the ISO 4217 active list at all; the + /// universal-rejection guarantee of `isValidFiatCode` + /// covers them by default. + function excludedCount() internal pure returns (uint256) { + return 22; + } + + /// @notice Returns the ISO 4217 alphabetic code at index `idx` in + /// the blocklist. See `excludedCount` for the rationale and + /// the per-category inline comments below for why each + /// entry is excluded. + /// + /// @dev Index order is stable across categories — adding new + /// entries appends to the end. Tests fuzzing the blocklist + /// should drive `idx` by `seed % excludedCount()` so they + /// pick up new entries automatically as the list grows. + 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..dea12c2 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 "src/utils/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,14 @@ 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(); + // The stablecoin variant restricts `currency` to the active + // ISO 4217 fiat allowlist. This subsumes the prior + // empty-string check: empty, wrong-length, wrong-case, + // unknown-three-letter, X-prefix, and out-of-scope ticker + // inputs all surface as InvalidCurrency rather than two + // disjoint errors. See IB20Stablecoin.currency for the + // rationale and `ISO4217.isValidFiatCode` for the allowlist. + 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..7fb114c 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 "src/utils/ISO4217.sol"; import {MockB20, B20Constants} from "test/lib/mocks/MockB20.sol"; import {MockB20Storage, MockB20StablecoinStorage} from "test/lib/mocks/MockB20Storage.sol"; @@ -57,16 +58,95 @@ 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-variant currency validation (ISO 4217 fiat allowlist) + // ============================================================ + // The STABLECOIN arm enforces `currency` against the ISO 4217 + // active-fiat allowlist exposed by `ISO4217.isValidFiatCode`. The + // claim is universal: any input on the allowlist is accepted, any + // input off it reverts with `InvalidCurrency(code)`. The two fuzz + // tests below pin down both halves of that universal directly; the + // remaining named tests document the two non-obvious membership + // decisions (multi-country X-prefix codes accepted, the + // explicitly-blocked ISO 4217 codes rejected) that a reader would + // not infer from the fuzz tests alone. + + /// @notice Verifies any string not on the allowlist reverts with InvalidCurrency + /// carrying the offending value verbatim + /// @dev Universal rejection claim: this single fuzz test subsumes + /// every point-input variation a hand-written test could + /// probe — empty string, wrong length, wrong case, unknown + /// three-letter combinations, non-ASCII bytes, crypto + /// tickers, governance symbols, and every excluded ISO 4217 + /// entry. Fuzz iterations that happen to hit a valid code + /// are filtered via `vm.assume`; the hit rate is negligible + /// (allowlist size << 2^24 length-3 inputs alone). The + /// revert-data assertion proves the diagnostic contract too: + /// the rejected string round-trips verbatim into the error. + function test_fuzz_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 Verifies every code in the explicitly-documented ISO 4217 + /// blocklist reverts + /// @dev Documentation-pinning fuzz: the blocklist (`ISO4217.excludedAt`) + /// enumerates every ISO 4217 entry that was considered for + /// inclusion and rejected, with per-entry rationale in the + /// library. Driving the fuzz seed against + /// `seed % excludedCount()` covers every entry uniformly and + /// automatically picks up new entries as the blocklist grows. + /// The universal-rejection fuzz above would catch each entry + /// too, but this test makes the "we deliberately considered + /// and rejected these specific codes" claim executable rather + /// than implicit. + function test_fuzz_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 Verifies an invalid currency at creation leaves no partial state + /// at the deterministic address + /// @dev Atomicity claim: a rejected create must not commit bytecode, + /// identity storage, the currency slot, or the admin grant. + /// A subsequent create at the same (variant, sender, salt) + /// with a valid currency must succeed at the same address. + /// This guarantees a misnamed retry is not blocked by ghost + /// state from the failed attempt. Distinct claim from the + /// universal-rejection fuzz, which only inspects the revert + /// shape and not the post-revert chain state. + 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 +272,69 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest { assertEq(actual, predicted, "createToken address must match prediction"); } + /// @notice Verifies a representative set of active ISO 4217 fiat codes + /// are accepted by the stablecoin variant + /// @dev Spot-check on the major reserve currencies (USD, EUR, JPY, + /// GBP, CHF, CNY, CAD, AUD) — the codes most likely to be in + /// use; if any of them stops parsing as valid the impact is + /// immediate and broad. Each create reads back `currency()` + /// to confirm the string round-trips through storage + /// byte-for-byte; a successful create with a corrupted + /// readback would still violate the field's contract. The + /// universal-rejection fuzz in the REVERTS section is the + /// complementary half of the same claim — together they + /// cover "any allowlist code accepted, anything else rejected." + 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 Verifies the four multi-country X-prefix fiat currencies + /// (XOF, XAF, XCD, XPF) are accepted + /// @dev Documentation-pinning test for the non-obvious design + /// choice that the X-prefix is NOT a categorical exclusion + /// rule. ISO 4217 reserves the X-prefix for codes where no + /// single ISO 3166 country code applies — which covers both + /// "not a currency" (metals, sentinels) AND "currency shared + /// across multiple countries by a supranational central + /// bank." The four codes here belong to the latter category + /// (CFA Franc BCEAO, CFA Franc BEAC, East Caribbean Dollar, + /// CFP Franc — real currencies used by tens of millions of + /// people) and are deliberately on the allowlist. A reader + /// who saw the blocklist's X-prefix entries and inferred + /// "all X-prefix codes are blocked" would be wrong; this + /// test makes the carve-out executable. + 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; From 8f681478bf3a42baa108c883c6ad2fc2f405449f Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 20 May 2026 22:50:43 -0700 Subject: [PATCH 02/14] docs: add scope and industry-naming notes to IB20Stablecoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents why this variant is restricted to single-fiat tracking even though "stablecoin" is used in industry and regulation to describe a wider set of instruments. Anchors the narrowing to MiCA E-Money Tokens, MAS Single-Currency Stablecoins, and US payment-stablecoin legislative definitions — the regulatory regimes that have already sub-divided the term along the same line we draw. Calls out specifically that commodity-backed tokens marketed as stablecoins (PAXG, XAUT) belong on IB20Security; that crypto- collateralized fiat-pegged tokens (DAI, LUSD) fit this variant since the peg target — not the collateral mechanism — is what currency() expresses; and that basket-pegged and algorithmic non-pegged tokens have no current B-20 home. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/interfaces/IB20Stablecoin.sol | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/interfaces/IB20Stablecoin.sol b/src/interfaces/IB20Stablecoin.sol index 5184a26..8209aa4 100644 --- a/src/interfaces/IB20Stablecoin.sol +++ b/src/interfaces/IB20Stablecoin.sol @@ -11,6 +11,58 @@ import {IB20} from "./IB20.sol"; /// protocol that wants to group tokens by the asset they peg /// to) to categorize the token. /// +/// @dev **Scope and industry-naming notes.** "Stablecoin" is used +/// in industry and regulation to describe a wider set of +/// instruments than this variant accepts. The Financial +/// Stability Board and the Bank for International Settlements +/// define a stablecoin broadly as "a crypto-asset that aims +/// to maintain a stable value relative to a specified asset, +/// or a pool or basket of assets" — which covers fiat, +/// commodities, baskets, and other crypto. This variant +/// deliberately scopes to the narrower category, aligning +/// with the regulators that have sub-divided the term: +/// +/// - **EU MiCA** defines *E-Money Tokens (EMTs)* — single fiat +/// currency, our scope — as a distinct category from +/// *Asset-Referenced Tokens (ARTs)*, which cover +/// commodities, baskets, and multi-currency pegs. +/// - **MAS Singapore** defines *Single-Currency Stablecoins +/// (SCS)* — pegged to one G10 fiat currency — as the +/// regulated category; other asset-referenced tokens fall +/// outside the SCS framework. +/// - **US payment-stablecoin legislative proposals** (GENIUS +/// Act, Clarity for Payment Stablecoins Act, and +/// predecessors) generally define "payment stablecoin" as +/// fiat-backed only. +/// +/// This variant lines up with the EMT / SCS / payment- +/// stablecoin definition specifically. Concretely: +/// +/// - **Commodity-backed tokens** that market themselves as +/// "stablecoins" — e.g. PAXG, XAUT (gold-backed) — are +/// structurally claims on a vault and are securities-shaped +/// instruments. They belong on the `IB20Security` variant, +/// not here. +/// - **Crypto-collateralized stablecoins** that still peg to +/// a fiat currency — e.g. DAI, LUSD, crvUSD — fit this +/// variant. The mechanism backing the peg (custodial +/// reserves vs. on-chain collateral vs. T-bills) is +/// irrelevant to `currency()`; what matters is what the +/// token tracks. If it tracks 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 — a +/// future basket / ART variant or use of the Default +/// variant would be the path for those, not a relaxation +/// of this one. +/// +/// The naming friction is real: PAXG calls itself a +/// "stablecoin" and would not be admitted here. We accept +/// that friction because it preserves a clean, regulator- +/// aligned semantic for the variant — `IB20Stablecoin` means +/// "fiat-tracking stablecoin" specifically, and downstream +/// tooling can rely on `currency()` returning an ISO 4217 +/// fiat code from any token of this variant. interface IB20Stablecoin is IB20 { /*////////////////////////////////////////////////////////////// CURRENCY IDENTIFIER From 4d3e7e9b25f139b2636c0d91aaf929b03663c965 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 20 May 2026 22:54:03 -0700 Subject: [PATCH 03/14] =?UTF-8?q?docs:=20trim=20scope=20notes=20=E2=80=94?= =?UTF-8?q?=20drop=20basket=20bullet,=20condense=20conclusion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/interfaces/IB20Stablecoin.sol | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/interfaces/IB20Stablecoin.sol b/src/interfaces/IB20Stablecoin.sol index 8209aa4..079ce0b 100644 --- a/src/interfaces/IB20Stablecoin.sol +++ b/src/interfaces/IB20Stablecoin.sol @@ -49,20 +49,10 @@ import {IB20} from "./IB20.sol"; /// reserves vs. on-chain collateral vs. T-bills) is /// irrelevant to `currency()`; what matters is what the /// token tracks. If it tracks 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 — a -/// future basket / ART variant or use of the Default -/// variant would be the path for those, not a relaxation -/// of this one. /// -/// The naming friction is real: PAXG calls itself a -/// "stablecoin" and would not be admitted here. We accept -/// that friction because it preserves a clean, regulator- -/// aligned semantic for the variant — `IB20Stablecoin` means -/// "fiat-tracking stablecoin" specifically, and downstream -/// tooling can rely on `currency()` returning an ISO 4217 -/// fiat code from any token of this variant. +/// This variant prioritizes the majority fiat-pegged use +/// case at the cost of excluding edge cases that other B-20 +/// variants are better suited to serve. interface IB20Stablecoin is IB20 { /*////////////////////////////////////////////////////////////// CURRENCY IDENTIFIER From 8eba531a00438eeba8c1ce3beadab30d9f7200db Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 20 May 2026 23:22:00 -0700 Subject: [PATCH 04/14] docs: extract ISO 4217 filter rationale to docs/, tighten natspec Moves the scope, exclusion-category reasoning, regulatory framing (MiCA EMT / MAS SCS / FSB / BIS), and trust model out of the IB20Stablecoin, ITokenFactory, and ISO4217 natspec into a single canonical docs/iso4217-filter.md. The new file presents inclusion / exclusion as a scannable table and totals ~26 lines. Natspec now stays at the API-contract layer: what each function does, what reverts, with one-line pointers to docs/ for the deeper context. Per-entry rationale in ISO4217.excludedAt stays inline since it answers "why isn't X on the list?" at the exact location a reader would ask. Net: -217 natspec lines, +26 docs lines. IB20Stablecoin.sol is now 20 lines total. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/iso4217-filter.md | 26 ++++++ src/interfaces/IB20Stablecoin.sol | 110 +++------------------- src/interfaces/ITokenFactory.sol | 47 +++------- src/utils/ISO4217.sol | 150 ++++-------------------------- 4 files changed, 71 insertions(+), 262 deletions(-) create mode 100644 docs/iso4217-filter.md diff --git a/docs/iso4217-filter.md b/docs/iso4217-filter.md new file mode 100644 index 0000000..3e600a8 --- /dev/null +++ b/docs/iso4217-filter.md @@ -0,0 +1,26 @@ +# ISO 4217 Currency Filter + +How the `B20Stablecoin` variant decides which `currency` strings to accept, and why the scope is narrower than "stablecoin" colloquially suggests. + +- The filter validates `B20Stablecoin.currency` at creation against a hardcoded allowlist in [`src/utils/ISO4217.sol`](../src/utils/ISO4217.sol). +- Scope: active ISO 4217 alphabetic codes for **circulating national fiat currencies only** (~150 codes). +- 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. +- `currency` is **self-declared** — the filter gates format and membership, not truthfulness; the token may not actually be backed by what it declares. +- Consumers using `currency()` for authorization or routing MUST add their own issuer/contract allowlist on top. +- The value is set at creation and is immutable; rejected inputs revert with `ITokenFactory.InvalidCurrency(code)` carrying the offending string verbatim. + +## Inclusion / Exclusion + +| 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. diff --git a/src/interfaces/IB20Stablecoin.sol b/src/interfaces/IB20Stablecoin.sol index 079ce0b..6882805 100644 --- a/src/interfaces/IB20Stablecoin.sol +++ b/src/interfaces/IB20Stablecoin.sol @@ -3,104 +3,18 @@ pragma solidity >=0.8.20 <0.9.0; import {IB20} from "./IB20.sol"; -/// @title IB20Stablecoin -/// @notice A B-20 token variant for tokens designed to track the value -/// of a national fiat currency. Inherits the full `IB20` -/// surface and adds a single immutable `currency()` identifier -/// used by downstream tooling (indexers, wallets, and any -/// protocol that wants to group tokens by the asset they peg -/// to) to categorize the token. -/// -/// @dev **Scope and industry-naming notes.** "Stablecoin" is used -/// in industry and regulation to describe a wider set of -/// instruments than this variant accepts. The Financial -/// Stability Board and the Bank for International Settlements -/// define a stablecoin broadly as "a crypto-asset that aims -/// to maintain a stable value relative to a specified asset, -/// or a pool or basket of assets" — which covers fiat, -/// commodities, baskets, and other crypto. This variant -/// deliberately scopes to the narrower category, aligning -/// with the regulators that have sub-divided the term: -/// -/// - **EU MiCA** defines *E-Money Tokens (EMTs)* — single fiat -/// currency, our scope — as a distinct category from -/// *Asset-Referenced Tokens (ARTs)*, which cover -/// commodities, baskets, and multi-currency pegs. -/// - **MAS Singapore** defines *Single-Currency Stablecoins -/// (SCS)* — pegged to one G10 fiat currency — as the -/// regulated category; other asset-referenced tokens fall -/// outside the SCS framework. -/// - **US payment-stablecoin legislative proposals** (GENIUS -/// Act, Clarity for Payment Stablecoins Act, and -/// predecessors) generally define "payment stablecoin" as -/// fiat-backed only. -/// -/// This variant lines up with the EMT / SCS / payment- -/// stablecoin definition specifically. Concretely: -/// -/// - **Commodity-backed tokens** that market themselves as -/// "stablecoins" — e.g. PAXG, XAUT (gold-backed) — are -/// structurally claims on a vault and are securities-shaped -/// instruments. They belong on the `IB20Security` variant, -/// not here. -/// - **Crypto-collateralized stablecoins** that still peg to -/// a fiat currency — e.g. DAI, LUSD, crvUSD — fit this -/// variant. The mechanism backing the peg (custodial -/// reserves vs. on-chain collateral vs. T-bills) is -/// irrelevant to `currency()`; what matters is what the -/// token tracks. If it tracks USD, declare `"USD"`. -/// -/// This variant prioritizes the majority fiat-pegged use -/// case at the cost of excluding edge cases that other B-20 -/// variants are better suited to serve. +/// @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/iso4217-filter.md` for the inclusion / +/// exclusion lists, regulatory framing, and trust model. interface IB20Stablecoin is IB20 { - /*////////////////////////////////////////////////////////////// - CURRENCY IDENTIFIER - //////////////////////////////////////////////////////////////*/ - - /// @notice The national fiat currency this stablecoin is designed - /// to track, expressed as an active ISO 4217 alphabetic - /// code (e.g. `"USD"`, `"EUR"`, `"JPY"`). Set at creation - /// by the factory; immutable thereafter. Two stablecoins - /// tracking the same currency return byte-identical values. - /// - /// @dev **Value space.** The factory validates this field - /// against a hardcoded allowlist of active ISO 4217 codes - /// registered as national means of payment. Any value - /// outside that allowlist reverts at creation with - /// `ITokenFactory.InvalidCurrency(code)`; see - /// `ISO4217.isValidFiatCode` for the canonical list. - /// Specifically excluded: - /// - **ISO 4217 X-prefix codes** (XAU/XAG/XPT/XPD precious - /// metals, XBA-XBD bond market units, XDR IMF special - /// drawing rights, XSU sucre, XUA ADB unit of account, - /// XTS test code, XXX no-currency sentinel). These are - /// reserved by ISO 4217 for non-currency uses. Tokens - /// backed by precious metals or other commodities are - /// securities-shaped instruments and belong on the - /// `IB20Security` variant. - /// - **Crypto tickers** (BTC, ETH, etc.) and any other - /// free-form symbol — out of scope for this variant. - /// - /// **Trust model.** This field is the issuer's *self- - /// declared* peg. The factory enforces format and - /// membership in the ISO 4217 fiat allowlist; it does NOT - /// verify the token is actually backed by, or actually - /// tracking, the named currency. Any protocol that consumes - /// `currency()` to make an authorization or categorization - /// decision MUST still apply its own allowlist or trust - /// resolution on top — typically an issuer / contract - /// allowlist maintained by the consumer. What this field - /// provides is a standardized, immutable, machine-readable - /// identifier that consumer-side allowlists can be - /// organized around (e.g. "must be on my issuer allowlist - /// AND `currency() == \"USD\"`"), not the allowlist itself. - /// - /// **Immutability.** No setter exists. The identifier is - /// fixed at construction by the factory and cannot be - /// changed afterward; mutability would let an admitted - /// token silently switch what it claims to represent and - /// break any consumer that built admission logic on top of - /// the field. + /// @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/iso4217-filter.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 f762901..0d1c211 100644 --- a/src/interfaces/ITokenFactory.sol +++ b/src/interfaces/ITokenFactory.sol @@ -112,28 +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 identifier for the national fiat - /// currency this stablecoin is designed to - /// track. MUST be an active ISO 4217 - /// alphabetic code for a sovereign or - /// supranational means of payment (e.g. `"USD"`, - /// `"EUR"`, `"JPY"`, `"GBP"`). Any input - /// outside this allowlist — empty string, - /// wrong case, wrong length, unknown - /// three-letter combinations, ISO 4217 - /// X-prefix codes (XAU, XAG, XDR, XTS, XXX), - /// or crypto tickers (BTC, ETH) — reverts - /// with `InvalidCurrency(code)`. See - /// `IB20Stablecoin.currency` for the semantics - /// and the rationale behind the restriction; - /// the canonical allowlist is in - /// `ISO4217.isValidFiatCode`. - /// @dev Decimals are fixed at `6` (the SPL stablecoin convention). - /// There is no decimals field on this struct. The currency - /// allowlist is enforced at creation and is immutable for - /// the lifetime of the token — there is no setter on the - /// deployed contract, so this is the only place the - /// identifier can ever be chosen. + /// @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/iso4217-filter.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; @@ -193,18 +180,10 @@ interface ITokenFactory { /// disjoint failure modes for the same field. error MissingRequiredField(); - /// @notice The stablecoin `currency` field did not match an active - /// ISO 4217 national-fiat alphabetic code. Carries the - /// offending string verbatim for diagnostics. - /// - /// @dev Triggered for every form of invalid currency: empty - /// string, wrong length, wrong case (`"usd"`), unknown - /// three-letter combinations (`"ZZZ"`), X-prefix codes - /// (XAU, XAG, XDR, XXX, XTS — reserved by ISO 4217 for - /// non-national-currency uses), and out-of-scope tickers - /// (BTC, ETH, etc.). See `B20StablecoinCreateParams.currency` - /// for the rationale and value-space definition; the - /// reference allowlist lives in `ISO4217.isValidFiatCode`. + /// @notice The stablecoin `currency` field was not on the ISO 4217 + /// fiat allowlist. Carries the offending string verbatim + /// for diagnostics. + /// @dev See `docs/iso4217-filter.md` for the allowlist. error InvalidCurrency(string code); /// @notice One of the `initCalls` reverted. The factory bubbles the diff --git a/src/utils/ISO4217.sol b/src/utils/ISO4217.sol index e310c73..39e919b 100644 --- a/src/utils/ISO4217.sol +++ b/src/utils/ISO4217.sol @@ -3,113 +3,16 @@ pragma solidity ^0.8.20; /// @title ISO4217 /// @notice Helpers anchored in the ISO 4217 currency-code standard. -/// Currently exposes two primitives: -/// - `isValidFiatCode`: a static allowlist of active ISO 4217 -/// alphabetic codes that correspond to **circulating national -/// fiat currencies**, used by the `TokenFactory` precompile -/// to validate the `currency` field passed to the STABLECOIN -/// variant of `createToken`. -/// - `excludedCount` / `excludedAt`: an enumerable record of -/// the ISO 4217 codes that are on the standard but -/// deliberately excluded from the fiat allowlist, with -/// inline comments explaining each exclusion. This makes the -/// rejected set readable as data rather than as the absence -/// of an entry, and lets tests fuzz over the blocklist to -/// prove every documented exclusion actually reverts. -/// -/// The library scope is left intentionally broad so that -/// further ISO 4217 helpers (numeric-code lookup, -/// decimal-exponent lookup, etc.) can land in this file -/// without restructuring imports. -/// -/// @dev **Scope.** "Fiat" here means a currency that is issued by -/// a sovereign or supranational monetary authority and -/// circulates as a means of payment — i.e., something you can -/// hold and settle in. This is narrower than "every ISO 4217 -/// alphabetic code": ISO 4217 also enumerates funds codes -/// (indexing units, internal accounting devices), bond-market -/// composite units, supranational synthetic reserve assets, -/// precious metals, and sentinel codes, none of which are -/// circulating currencies. -/// -/// **The X-prefix is NOT a categorical exclusion rule.** ISO -/// 4217 reserves the X-prefix for codes where no single ISO -/// 3166 country code applies. That covers both "not a -/// currency" (metals, sentinels, synthetic units) AND -/// "currency shared across multiple countries by a -/// supranational central bank." The latter category contains -/// four real circulating fiat currencies used by tens of -/// millions of people, which the allowlist DOES include: -/// - `XOF` — CFA Franc BCEAO (8 West African states, BCEAO) -/// - `XAF` — CFA Franc BEAC (6 Central African states, BEAC) -/// - `XCD` — East Caribbean Dollar (8 Caribbean states, ECCB) -/// - `XPF` — CFP Franc (French Pacific territories) -/// -/// **Why an allowlist at all.** `currency` is a self-declared, -/// immutable identifier. It is NOT a trust signal: a token can -/// declare any code without anyone checking that the token is -/// actually backed by the named currency. The allowlist -/// provides three concrete benefits regardless: -/// 1. Standardized value space — every stablecoin's -/// `currency()` returns a code from one well-known -/// registry, so tooling can categorize the field without -/// having to parse free-form strings or invent its own -/// taxonomy. -/// 2. Typo / garbage rejection at creation — `"usd"`, -/// `"USDT"`, `"US"`, `""` all revert at the factory rather -/// than silently shipping an unreadable identifier. -/// 3. Forced design discussion for new categories — any -/// consumer that wants to ship tokens outside the fiat -/// category (commodity-backed, crypto-tracking, governance -/// tokens with stablecoin operational surface) is pushed -/// into the variant discussion rather than smuggling -/// through this field. The B-20 Security variant is the -/// intended home for commodity-backed tokens. -/// -/// **What's excluded (enumerated by `excludedAt`).** -/// - **Precious metals** (XAU/XAG/XPT/XPD): commodities, not -/// means of payment. Commodity-backed tokens belong on the -/// Security variant. -/// - **European composite units** (XBA-XBD): defunct -/// supranational accounting units (EURCO, E.M.U.-6, E.U.A.-9, -/// E.U.A.-17). Retained on ISO 4217 for historical -/// reconciliation only. -/// - **Other supranational synthetic units** (XDR/XSU/XUA): -/// IMF Special Drawing Rights, Sucre, ADB Unit of Account. -/// Synthetic reserve assets, not circulating currencies. -/// - **Sentinels** (XXX/XTS): "no currency" marker and the -/// reserved test code. Neither is a currency. -/// - **Funds codes** (BOV, CHE, CHW, CLF, COU, MXV, USN, UYI, -/// UYW): indexing units, internal accounting devices, and -/// forex conventions. Most exist to denominate -/// inflation-indexed obligations or settlement timing; a -/// stablecoin pegged to "CLF" or "USN" is not coherent -/// because those aren't things one can hold or settle in. -/// -/// **Trust model recap.** The allowlist gates the *format and -/// membership* of the identifier, not the truthfulness of the -/// issuer's claim. A factory call with `currency = "USD"` and -/// no backing reserves still succeeds at this layer. Any -/// protocol that consumes `currency()` to make an authorization -/// or routing decision is responsible for its own admission -/// logic on top. -/// -/// **Implementation note.** Allowlist membership is tested by -/// comparing `keccak256(bytes(code))` against the hash of each -/// allowlist entry. Hashes of string literals are constant -/// expressions under the optimizer, so the per-call cost is -/// one keccak256 on the 3-byte input plus a chain of 32-byte -/// equalities. The alternative — direct `bytes3` comparison -/// via casts — trips forge-lint's `unsafe-typecast` rule on -/// every literal and requires ~150 inline suppressions; this -/// form keeps the lint clean without sacrificing readability. -/// -/// **Updating the lists.** Adding a new ISO 4217 active code -/// (rare; registrations happen on the order of once per year) -/// is a contract change here or in the Rust precompile that -/// mirrors it. Both lists are organized alphabetically by -/// leading letter (allowlist) or by exclusion category -/// (blocklist) for ease of audit and diff. +/// 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/iso4217-filter.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); @@ -228,34 +131,21 @@ library ISO4217 { return false; } - /// @notice The number of ISO 4217 alphabetic codes that are on - /// the standard but deliberately excluded from + /// @notice Number of ISO 4217 codes deliberately excluded from /// `isValidFiatCode`. Pair with `excludedAt` to enumerate. - /// - /// @dev The blocklist exists to document — rather than silently - /// drop — every ISO 4217 entry that was considered for - /// inclusion and rejected, so the rejected set is readable - /// as data (with per-entry rationale in `excludedAt`'s - /// body) and so tests can fuzz over the full blocklist to - /// prove the validator's exclusions match the documented - /// design. Deprecated / withdrawn codes (CUC, HRK, VEF, - /// ZWL, etc.) are NOT in the blocklist because they're no - /// longer on the ISO 4217 active list at all; the - /// universal-rejection guarantee of `isValidFiatCode` - /// covers them by default. + /// 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 ISO 4217 alphabetic code at index `idx` in - /// the blocklist. See `excludedCount` for the rationale and - /// the per-category inline comments below for why each - /// entry is excluded. - /// - /// @dev Index order is stable across categories — adding new - /// entries appends to the end. Tests fuzzing the blocklist - /// should drive `idx` by `seed % excludedCount()` so they - /// pick up new entries automatically as the list grows. + /// @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. From 54ffbef745b9b748b468a39a0449b5df227daabc Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 20 May 2026 23:25:50 -0700 Subject: [PATCH 05/14] docs: move currency-validation doc under docs/b20/stablecoin/ and expand to full problem/solution/risks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the docs file into a four-section flow that's self-describing on its own: problem statement (why constrain currency at all), solution (ISO 4217 fiat subset), complete specification (the inclusion/exclusion table), and risks + mitigations (PAXG-class commodities go to Security; DAI-class crypto-collateralized still fit; basket/algorithmic have no home; naming friction with industry terminology is anchored in MiCA / MAS precedent; trust model is consumer-layered). Moves the file from docs/iso4217-filter.md to docs/b20/stablecoin/currency-validation.md so the docs taxonomy mirrors the interface hierarchy (B20 / B20Stablecoin / concept). Updates all natspec links to the new path and trims two more small natspec sprawls — the X-prefix inline comment and the isValidFiatCode @notice — that duplicated content now in docs. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/b20/stablecoin/currency-validation.md | 52 ++++++++++++++++++++++ docs/iso4217-filter.md | 26 ----------- src/interfaces/IB20Stablecoin.sol | 4 +- src/interfaces/ITokenFactory.sol | 4 +- src/utils/ISO4217.sol | 13 +++--- 5 files changed, 61 insertions(+), 38 deletions(-) create mode 100644 docs/b20/stablecoin/currency-validation.md delete mode 100644 docs/iso4217-filter.md diff --git a/docs/b20/stablecoin/currency-validation.md b/docs/b20/stablecoin/currency-validation.md new file mode 100644 index 0000000..7ee3bca --- /dev/null +++ b/docs/b20/stablecoin/currency-validation.md @@ -0,0 +1,52 @@ +# 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 [`src/utils/ISO4217.sol`](../../../src/utils/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. | diff --git a/docs/iso4217-filter.md b/docs/iso4217-filter.md deleted file mode 100644 index 3e600a8..0000000 --- a/docs/iso4217-filter.md +++ /dev/null @@ -1,26 +0,0 @@ -# ISO 4217 Currency Filter - -How the `B20Stablecoin` variant decides which `currency` strings to accept, and why the scope is narrower than "stablecoin" colloquially suggests. - -- The filter validates `B20Stablecoin.currency` at creation against a hardcoded allowlist in [`src/utils/ISO4217.sol`](../src/utils/ISO4217.sol). -- Scope: active ISO 4217 alphabetic codes for **circulating national fiat currencies only** (~150 codes). -- 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. -- `currency` is **self-declared** — the filter gates format and membership, not truthfulness; the token may not actually be backed by what it declares. -- Consumers using `currency()` for authorization or routing MUST add their own issuer/contract allowlist on top. -- The value is set at creation and is immutable; rejected inputs revert with `ITokenFactory.InvalidCurrency(code)` carrying the offending string verbatim. - -## Inclusion / Exclusion - -| 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. diff --git a/src/interfaces/IB20Stablecoin.sol b/src/interfaces/IB20Stablecoin.sol index 6882805..b2f5e6b 100644 --- a/src/interfaces/IB20Stablecoin.sol +++ b/src/interfaces/IB20Stablecoin.sol @@ -7,14 +7,14 @@ import {IB20} from "./IB20.sol"; /// @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/iso4217-filter.md` for the inclusion / +/// elsewhere. See `docs/b20/stablecoin/currency-validation.md` for the inclusion / /// exclusion lists, regulatory framing, and trust model. interface IB20Stablecoin is IB20 { /// @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/iso4217-filter.md` for the validated value space + /// `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 0d1c211..ca6f664 100644 --- a/src/interfaces/ITokenFactory.sol +++ b/src/interfaces/ITokenFactory.sol @@ -117,7 +117,7 @@ interface ITokenFactory { /// against the allowlist in `ISO4217.sol`; /// anything off the list reverts with /// `InvalidCurrency(code)`. See - /// `docs/iso4217-filter.md`. + /// `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. @@ -183,7 +183,7 @@ interface ITokenFactory { /// @notice The stablecoin `currency` field was not on the ISO 4217 /// fiat allowlist. Carries the offending string verbatim /// for diagnostics. - /// @dev See `docs/iso4217-filter.md` for the allowlist. + /// @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 diff --git a/src/utils/ISO4217.sol b/src/utils/ISO4217.sol index 39e919b..54eeff4 100644 --- a/src/utils/ISO4217.sol +++ b/src/utils/ISO4217.sol @@ -9,7 +9,7 @@ pragma solidity ^0.8.20; /// - `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/iso4217-filter.md` for scope, exclusion categories, +/// @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. @@ -17,10 +17,9 @@ library ISO4217 { /// @notice Thrown by `excludedAt` when `idx` exceeds `excludedCount`. error IndexOutOfBounds(uint256 idx); - /// @notice Returns true iff `code` is exactly three ASCII bytes - /// long and matches an active ISO 4217 circulating-fiat - /// alphabetic code. See the library-level natspec for the - /// scope and the rationale behind every exclusion. + /// @notice Returns true iff `code` is on the active ISO 4217 + /// circulating-fiat allowlist (exactly three ASCII bytes, + /// uppercase, on the curated set). function isValidFiatCode(string memory code) internal pure returns (bool) { bytes memory b = bytes(code); if (b.length != 3) return false; @@ -117,9 +116,7 @@ library ISO4217 { h == keccak256("VED") || h == keccak256("VES") || h == keccak256("VND") || h == keccak256("VUV") || h == keccak256("WST") ) return true; - // X (multi-country circulating fiat — see library natspec for - // why these four codes are deliberately accepted despite the - // X-prefix being commonly associated with non-currency entries) + // X (multi-country circulating fiat: BCEAO, BEAC, ECCB, IEOM) if ( h == keccak256("XAF") || h == keccak256("XCD") || h == keccak256("XOF") || h == keccak256("XPF") ) return true; From e5cfc54ef3056e9d71d798479f9a73e8533a69a9 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 20 May 2026 23:33:37 -0700 Subject: [PATCH 06/14] docs: trim factory comment and currency-test natspec MockTokenFactory's 7-line ISO-4217 explainer collapses to one line pointing at docs/. The five currency tests in createToken.t.sol each drop to a 2-line @notice + @dev docblock; the section header collapses from a 12-line preamble to a single pointer comment. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- test/lib/mocks/MockTokenFactory.sol | 8 +-- test/unit/TokenFactory/createToken.t.sol | 87 ++++-------------------- 2 files changed, 14 insertions(+), 81 deletions(-) diff --git a/test/lib/mocks/MockTokenFactory.sol b/test/lib/mocks/MockTokenFactory.sol index dea12c2..49d5a9d 100644 --- a/test/lib/mocks/MockTokenFactory.sol +++ b/test/lib/mocks/MockTokenFactory.sol @@ -109,13 +109,7 @@ contract MockTokenFactory is ITokenFactory { } else if (variant == TokenVariant.STABLECOIN) { B20StablecoinCreateParams memory p = abi.decode(params, (B20StablecoinCreateParams)); if (p.version != 1) revert UnsupportedVersion(p.version); - // The stablecoin variant restricts `currency` to the active - // ISO 4217 fiat allowlist. This subsumes the prior - // empty-string check: empty, wrong-length, wrong-case, - // unknown-three-letter, X-prefix, and out-of-scope ticker - // inputs all surface as InvalidCurrency rather than two - // disjoint errors. See IB20Stablecoin.currency for the - // rationale and `ISO4217.isValidFiatCode` for the allowlist. + // 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; diff --git a/test/unit/TokenFactory/createToken.t.sol b/test/unit/TokenFactory/createToken.t.sol index 7fb114c..4ee2c84 100644 --- a/test/unit/TokenFactory/createToken.t.sol +++ b/test/unit/TokenFactory/createToken.t.sol @@ -58,31 +58,11 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest { factory.createToken(ITokenFactory.TokenVariant.STABLECOIN, salt, abi.encode(p), new bytes[](0)); } - // ============================================================ - // STABLECOIN-variant currency validation (ISO 4217 fiat allowlist) - // ============================================================ - // The STABLECOIN arm enforces `currency` against the ISO 4217 - // active-fiat allowlist exposed by `ISO4217.isValidFiatCode`. The - // claim is universal: any input on the allowlist is accepted, any - // input off it reverts with `InvalidCurrency(code)`. The two fuzz - // tests below pin down both halves of that universal directly; the - // remaining named tests document the two non-obvious membership - // decisions (multi-country X-prefix codes accepted, the - // explicitly-blocked ISO 4217 codes rejected) that a reader would - // not infer from the fuzz tests alone. - - /// @notice Verifies any string not on the allowlist reverts with InvalidCurrency - /// carrying the offending value verbatim - /// @dev Universal rejection claim: this single fuzz test subsumes - /// every point-input variation a hand-written test could - /// probe — empty string, wrong length, wrong case, unknown - /// three-letter combinations, non-ASCII bytes, crypto - /// tickers, governance symbols, and every excluded ISO 4217 - /// entry. Fuzz iterations that happen to hit a valid code - /// are filtered via `vm.assume`; the hit rate is negligible - /// (allowlist size << 2^24 length-3 inputs alone). The - /// revert-data assertion proves the diagnostic contract too: - /// the rejected string round-trips verbatim into the error. + // 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_fuzz_createToken_revert_currency_rejectsNonAllowlist(string memory code, address caller, bytes32 salt) public { @@ -94,18 +74,8 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest { factory.createToken(ITokenFactory.TokenVariant.STABLECOIN, salt, abi.encode(p), new bytes[](0)); } - /// @notice Verifies every code in the explicitly-documented ISO 4217 - /// blocklist reverts - /// @dev Documentation-pinning fuzz: the blocklist (`ISO4217.excludedAt`) - /// enumerates every ISO 4217 entry that was considered for - /// inclusion and rejected, with per-entry rationale in the - /// library. Driving the fuzz seed against - /// `seed % excludedCount()` covers every entry uniformly and - /// automatically picks up new entries as the blocklist grows. - /// The universal-rejection fuzz above would catch each entry - /// too, but this test makes the "we deliberately considered - /// and rejected these specific codes" claim executable rather - /// than implicit. + /// @notice Every entry in the explicit ISO 4217 blocklist reverts. + /// @dev Pins the documented exclusions; new entries are picked up automatically. function test_fuzz_createToken_revert_currency_blocklist(uint256 seed, address caller) public { _assumeValidCaller(caller); uint256 idx = seed % ISO4217.excludedCount(); @@ -118,16 +88,8 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest { ); } - /// @notice Verifies an invalid currency at creation leaves no partial state - /// at the deterministic address - /// @dev Atomicity claim: a rejected create must not commit bytecode, - /// identity storage, the currency slot, or the admin grant. - /// A subsequent create at the same (variant, sender, salt) - /// with a valid currency must succeed at the same address. - /// This guarantees a misnamed retry is not blocked by ghost - /// state from the failed attempt. Distinct claim from the - /// universal-rejection fuzz, which only inspects the revert - /// shape and not the post-revert chain state. + /// @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); @@ -272,18 +234,8 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest { assertEq(actual, predicted, "createToken address must match prediction"); } - /// @notice Verifies a representative set of active ISO 4217 fiat codes - /// are accepted by the stablecoin variant - /// @dev Spot-check on the major reserve currencies (USD, EUR, JPY, - /// GBP, CHF, CNY, CAD, AUD) — the codes most likely to be in - /// use; if any of them stops parsing as valid the impact is - /// immediate and broad. Each create reads back `currency()` - /// to confirm the string round-trips through storage - /// byte-for-byte; a successful create with a corrupted - /// readback would still violate the field's contract. The - /// universal-rejection fuzz in the REVERTS section is the - /// complementary half of the same claim — together they - /// cover "any allowlist code accepted, anything else rejected." + /// @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"]; @@ -302,21 +254,8 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest { } } - /// @notice Verifies the four multi-country X-prefix fiat currencies - /// (XOF, XAF, XCD, XPF) are accepted - /// @dev Documentation-pinning test for the non-obvious design - /// choice that the X-prefix is NOT a categorical exclusion - /// rule. ISO 4217 reserves the X-prefix for codes where no - /// single ISO 3166 country code applies — which covers both - /// "not a currency" (metals, sentinels) AND "currency shared - /// across multiple countries by a supranational central - /// bank." The four codes here belong to the latter category - /// (CFA Franc BCEAO, CFA Franc BEAC, East Caribbean Dollar, - /// CFP Franc — real currencies used by tens of millions of - /// people) and are deliberately on the allowlist. A reader - /// who saw the blocklist's X-prefix entries and inferred - /// "all X-prefix codes are blocked" would be wrong; this - /// test makes the carve-out executable. + /// @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"]; From c99da80d4bc95b3beaf2e07d38a61aac200d7fd0 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 20 May 2026 23:36:38 -0700 Subject: [PATCH 07/14] test: drop test_fuzz_ prefix to match convention Tests with arguments are fuzzed by default in Foundry; the suite convention is test_{function}_{condition}_{case} regardless of whether the body uses fuzz inputs. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- test/unit/TokenFactory/createToken.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/TokenFactory/createToken.t.sol b/test/unit/TokenFactory/createToken.t.sol index 4ee2c84..54a0818 100644 --- a/test/unit/TokenFactory/createToken.t.sol +++ b/test/unit/TokenFactory/createToken.t.sol @@ -63,7 +63,7 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest { /// @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_fuzz_createToken_revert_currency_rejectsNonAllowlist(string memory code, address caller, bytes32 salt) + function test_createToken_revert_currency_rejectsNonAllowlist(string memory code, address caller, bytes32 salt) public { _assumeValidCaller(caller); @@ -76,7 +76,7 @@ contract TokenFactoryCreateTokenTest is TokenFactoryTest { /// @notice Every entry in the explicit ISO 4217 blocklist reverts. /// @dev Pins the documented exclusions; new entries are picked up automatically. - function test_fuzz_createToken_revert_currency_blocklist(uint256 seed, address caller) public { + function test_createToken_revert_currency_blocklist(uint256 seed, address caller) public { _assumeValidCaller(caller); uint256 idx = seed % ISO4217.excludedCount(); string memory code = ISO4217.excludedAt(idx); From f14e3f108b4c07e7a84335e230b16d132ca6544a Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 20 May 2026 23:53:40 -0700 Subject: [PATCH 08/14] refactor: ISO4217 allowlist via bytes3 constants instead of keccak chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-comparison `keccak256("AED")` form with a block of named `bytes3 private constant` declarations at the top of the library, and a comparison chain that reads `c == AED || c == AFN || ...`. The canonical 155-entry list is now visible as data, easier to scan against ISO 4217, and easier to audit for additions and removals. Honest tradeoff: per-call gas is ~2× higher than the keccak chain (the Solidity optimizer was already constant-folding the keccak literals, so the old version was paying ~1 keccak on the input plus 155 PUSH32 EQ; the new version pays no keccak but ~155 PUSH3 EQ with additional control-flow overhead). Impact is mock-test runtime only; the Rust precompile will hash to O(1) regardless. A first-byte-dispatch optimization (`if (first == "U") return c == USD || ...`) would bring gas below the keccak version while keeping the readability win; deferred until reviewer signs off on the readability direction. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/utils/ISO4217.sol | 314 +++++++++++++++++++++++++++++++----------- 1 file changed, 230 insertions(+), 84 deletions(-) diff --git a/src/utils/ISO4217.sol b/src/utils/ISO4217.sol index 54eeff4..899d323 100644 --- a/src/utils/ISO4217.sol +++ b/src/utils/ISO4217.sol @@ -17,113 +17,259 @@ 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). function isValidFiatCode(string memory code) internal pure returns (bool) { bytes memory b = bytes(code); if (b.length != 3) return false; - bytes32 h = keccak256(b); + 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)) + } - // A: Arabian-region, Caucasus, Latin-America, Pacific - if ( - h == keccak256("AED") || h == keccak256("AFN") || h == keccak256("ALL") || h == keccak256("AMD") - || h == keccak256("ANG") || h == keccak256("AOA") || h == keccak256("ARS") || h == keccak256("AUD") - || h == keccak256("AWG") || h == keccak256("AZN") - ) return true; - // B - if ( - h == keccak256("BAM") || h == keccak256("BBD") || h == keccak256("BDT") || h == keccak256("BGN") - || h == keccak256("BHD") || h == keccak256("BIF") || h == keccak256("BMD") || h == keccak256("BND") - || h == keccak256("BOB") || h == keccak256("BRL") || h == keccak256("BSD") || h == keccak256("BTN") - || h == keccak256("BWP") || h == keccak256("BYN") || h == keccak256("BZD") - ) return true; - // C if ( - h == keccak256("CAD") || h == keccak256("CDF") || h == keccak256("CHF") || h == keccak256("CNY") - || h == keccak256("COP") || h == keccak256("CRC") || h == keccak256("CUP") || h == keccak256("CVE") - || h == keccak256("CZK") + c == AED || c == AFN || c == ALL || c == AMD || c == ANG || c == AOA || c == ARS || c == AUD + || c == AWG || c == AZN ) return true; - // D, E if ( - h == keccak256("DJF") || h == keccak256("DKK") || h == keccak256("DOP") || h == keccak256("DZD") - || h == keccak256("EGP") || h == keccak256("ERN") || h == keccak256("ETB") || h == keccak256("EUR") + c == BAM || c == BBD || c == BDT || c == BGN || c == BHD || c == BIF || c == BMD || c == BND + || c == BOB || c == BRL || c == BSD || c == BTN || c == BWP || c == BYN || c == BZD ) return true; - // F, G if ( - h == keccak256("FJD") || h == keccak256("FKP") || h == keccak256("GBP") || h == keccak256("GEL") - || h == keccak256("GHS") || h == keccak256("GIP") || h == keccak256("GMD") || h == keccak256("GNF") - || h == keccak256("GTQ") || h == keccak256("GYD") + c == CAD || c == CDF || c == CHF || c == CNY || c == COP || c == CRC || c == CUP || c == CVE + || c == CZK ) return true; - // H, I + if (c == DJF || c == DKK || c == DOP || c == DZD) return true; + if (c == EGP || c == ERN || c == ETB || c == EUR) return true; + if (c == FJD || c == FKP) return true; if ( - h == keccak256("HKD") || h == keccak256("HNL") || h == keccak256("HTG") || h == keccak256("HUF") - || h == keccak256("IDR") || h == keccak256("ILS") || h == keccak256("INR") || h == keccak256("IQD") - || h == keccak256("IRR") || h == keccak256("ISK") + c == GBP || c == GEL || c == GHS || c == GIP || c == GMD || c == GNF || c == GTQ || c == GYD ) return true; - // J, K + if (c == HKD || c == HNL || c == HTG || c == HUF) return true; + if (c == IDR || c == ILS || c == INR || c == IQD || c == IRR || c == ISK) return true; + if (c == JMD || c == JOD || c == JPY) return true; if ( - h == keccak256("JMD") || h == keccak256("JOD") || h == keccak256("JPY") || h == keccak256("KES") - || h == keccak256("KGS") || h == keccak256("KHR") || h == keccak256("KMF") || h == keccak256("KPW") - || h == keccak256("KRW") || h == keccak256("KWD") || h == keccak256("KYD") || h == keccak256("KZT") + c == KES || c == KGS || c == KHR || c == KMF || c == KPW || c == KRW || c == KWD || c == KYD + || c == KZT ) return true; - // L + if (c == LAK || c == LBP || c == LKR || c == LRD || c == LSL || c == LYD) return true; if ( - h == keccak256("LAK") || h == keccak256("LBP") || h == keccak256("LKR") || h == keccak256("LRD") - || h == keccak256("LSL") || h == keccak256("LYD") + c == MAD || c == MDL || c == MGA || c == MKD || c == MMK || c == MNT || c == MOP || c == MRU + || c == MUR || c == MVR || c == MWK || c == MXN || c == MYR || c == MZN ) return true; - // M + if (c == NAD || c == NGN || c == NIO || c == NOK || c == NPR || c == NZD) return true; + if (c == OMR) return true; + if (c == PAB || c == PEN || c == PGK || c == PHP || c == PKR || c == PLN || c == PYG) return true; + if (c == QAR) return true; + if (c == RON || c == RSD || c == RUB || c == RWF) return true; if ( - h == keccak256("MAD") || h == keccak256("MDL") || h == keccak256("MGA") || h == keccak256("MKD") - || h == keccak256("MMK") || h == keccak256("MNT") || h == keccak256("MOP") || h == keccak256("MRU") - || h == keccak256("MUR") || h == keccak256("MVR") || h == keccak256("MWK") || h == keccak256("MXN") - || h == keccak256("MYR") || h == keccak256("MZN") + c == SAR || c == SBD || c == SCR || c == SDG || c == SEK || c == SGD || c == SHP || c == SLE + || c == SOS || c == SRD || c == SSP || c == STN || c == SVC || c == SYP || c == SZL ) return true; - // N, O if ( - h == keccak256("NAD") || h == keccak256("NGN") || h == keccak256("NIO") || h == keccak256("NOK") - || h == keccak256("NPR") || h == keccak256("NZD") || h == keccak256("OMR") + c == THB || c == TJS || c == TMT || c == TND || c == TOP || c == TRY || c == TTD || c == TWD + || c == TZS ) return true; - // P, Q - if ( - h == keccak256("PAB") || h == keccak256("PEN") || h == keccak256("PGK") || h == keccak256("PHP") - || h == keccak256("PKR") || h == keccak256("PLN") || h == keccak256("PYG") || h == keccak256("QAR") - ) return true; - // R - if (h == keccak256("RON") || h == keccak256("RSD") || h == keccak256("RUB") || h == keccak256("RWF")) { - return true; - } - // S - if ( - h == keccak256("SAR") || h == keccak256("SBD") || h == keccak256("SCR") || h == keccak256("SDG") - || h == keccak256("SEK") || h == keccak256("SGD") || h == keccak256("SHP") || h == keccak256("SLE") - || h == keccak256("SOS") || h == keccak256("SRD") || h == keccak256("SSP") || h == keccak256("STN") - || h == keccak256("SVC") || h == keccak256("SYP") || h == keccak256("SZL") - ) return true; - // T - if ( - h == keccak256("THB") || h == keccak256("TJS") || h == keccak256("TMT") || h == keccak256("TND") - || h == keccak256("TOP") || h == keccak256("TRY") || h == keccak256("TTD") || h == keccak256("TWD") - || h == keccak256("TZS") - ) return true; - // U - if ( - h == keccak256("UAH") || h == keccak256("UGX") || h == keccak256("USD") || h == keccak256("UYU") - || h == keccak256("UZS") - ) return true; - // V, W - if ( - h == keccak256("VED") || h == keccak256("VES") || h == keccak256("VND") || h == keccak256("VUV") - || h == keccak256("WST") - ) return true; - // X (multi-country circulating fiat: BCEAO, BEAC, ECCB, IEOM) - if ( - h == keccak256("XAF") || h == keccak256("XCD") || h == keccak256("XOF") || h == keccak256("XPF") - ) return true; - // Y, Z - if (h == keccak256("YER") || h == keccak256("ZAR") || h == keccak256("ZMW") || h == keccak256("ZWG")) { - return true; - } + if (c == UAH || c == UGX || c == USD || c == UYU || c == UZS) return true; + if (c == VED || c == VES || c == VND || c == VUV) return true; + if (c == WST) return true; + if (c == XAF || c == XCD || c == XOF || c == XPF) return true; + if (c == YER) return true; + if (c == ZAR || c == ZMW || c == ZWG) return true; return false; } From 53f3b5576192045ae40f6c4242ff72ce74881941 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Wed, 20 May 2026 23:57:23 -0700 Subject: [PATCH 09/14] perf: first-byte dispatch for ISO4217.isValidFiatCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the bytes3 comparison chain in an outer `if (first == "X")` dispatch on the input's first byte, so each call evaluates at most one letter bucket instead of all 26. Worst-case ≈ 16 word-equality comparisons (S bucket, 15 entries) plus a few first-byte branches, vs ≈ 155 for a flat chain. Per-call gas (micro-bench, 100 calls each): - keccak (original): 4.2k / call - bytes3 flat: 7.9k / call - this commit: 1.4k / call (~3× faster than keccak) Adds an @dev natspec on `isValidFiatCode` documenting the O(1) amortized characteristic and the worst-case bucket size. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/utils/ISO4217.sol | 109 +++++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 49 deletions(-) diff --git a/src/utils/ISO4217.sol b/src/utils/ISO4217.sol index 899d323..8bb222a 100644 --- a/src/utils/ISO4217.sol +++ b/src/utils/ISO4217.sol @@ -210,6 +210,11 @@ library ISO4217 { /// @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. 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; @@ -220,56 +225,62 @@ library ISO4217 { assembly { c := mload(add(b, 32)) } + bytes1 first = bytes1(c); - if ( - c == AED || c == AFN || c == ALL || c == AMD || c == ANG || c == AOA || c == ARS || c == AUD - || c == AWG || c == AZN - ) return true; - if ( - c == BAM || c == BBD || c == BDT || c == BGN || c == BHD || c == BIF || c == BMD || c == BND - || c == BOB || c == BRL || c == BSD || c == BTN || c == BWP || c == BYN || c == BZD - ) return true; - if ( - c == CAD || c == CDF || c == CHF || c == CNY || c == COP || c == CRC || c == CUP || c == CVE - || c == CZK - ) return true; - if (c == DJF || c == DKK || c == DOP || c == DZD) return true; - if (c == EGP || c == ERN || c == ETB || c == EUR) return true; - if (c == FJD || c == FKP) return true; - if ( - c == GBP || c == GEL || c == GHS || c == GIP || c == GMD || c == GNF || c == GTQ || c == GYD - ) return true; - if (c == HKD || c == HNL || c == HTG || c == HUF) return true; - if (c == IDR || c == ILS || c == INR || c == IQD || c == IRR || c == ISK) return true; - if (c == JMD || c == JOD || c == JPY) return true; - if ( - c == KES || c == KGS || c == KHR || c == KMF || c == KPW || c == KRW || c == KWD || c == KYD - || c == KZT - ) return true; - if (c == LAK || c == LBP || c == LKR || c == LRD || c == LSL || c == LYD) return true; - if ( - c == MAD || c == MDL || c == MGA || c == MKD || c == MMK || c == MNT || c == MOP || c == MRU - || c == MUR || c == MVR || c == MWK || c == MXN || c == MYR || c == MZN - ) return true; - if (c == NAD || c == NGN || c == NIO || c == NOK || c == NPR || c == NZD) return true; - if (c == OMR) return true; - if (c == PAB || c == PEN || c == PGK || c == PHP || c == PKR || c == PLN || c == PYG) return true; - if (c == QAR) return true; - if (c == RON || c == RSD || c == RUB || c == RWF) return true; - if ( - c == SAR || c == SBD || c == SCR || c == SDG || c == SEK || c == SGD || c == SHP || c == SLE - || c == SOS || c == SRD || c == SSP || c == STN || c == SVC || c == SYP || c == SZL - ) return true; - if ( - c == THB || c == TJS || c == TMT || c == TND || c == TOP || c == TRY || c == TTD || c == TWD - || c == TZS - ) return true; - if (c == UAH || c == UGX || c == USD || c == UYU || c == UZS) return true; - if (c == VED || c == VES || c == VND || c == VUV) return true; - if (c == WST) return true; - if (c == XAF || c == XCD || c == XOF || c == XPF) return true; - if (c == YER) return true; - if (c == ZAR || c == ZMW || c == ZWG) return true; + if (first == "A") { + return c == AED || c == AFN || c == ALL || c == AMD || c == ANG || c == AOA || c == ARS + || c == AUD || c == AWG || c == AZN; + } + if (first == "B") { + return c == BAM || c == BBD || c == BDT || c == BGN || c == BHD || c == BIF || c == BMD + || c == BND || c == BOB || c == BRL || c == BSD || c == BTN || c == BWP || c == BYN + || c == BZD; + } + if (first == "C") { + return c == CAD || c == CDF || c == CHF || c == CNY || c == COP || c == CRC || c == CUP + || c == CVE || c == CZK; + } + if (first == "D") return c == DJF || c == DKK || c == DOP || c == DZD; + if (first == "E") return c == EGP || c == ERN || c == ETB || c == EUR; + if (first == "F") return c == FJD || c == FKP; + if (first == "G") { + return c == GBP || c == GEL || c == GHS || c == GIP || c == GMD || c == GNF || c == GTQ + || c == GYD; + } + if (first == "H") return c == HKD || c == HNL || c == HTG || c == HUF; + if (first == "I") return c == IDR || c == ILS || c == INR || c == IQD || c == IRR || c == ISK; + if (first == "J") return c == JMD || c == JOD || c == JPY; + if (first == "K") { + return c == KES || c == KGS || c == KHR || c == KMF || c == KPW || c == KRW || c == KWD + || c == KYD || c == KZT; + } + if (first == "L") return c == LAK || c == LBP || c == LKR || c == LRD || c == LSL || c == LYD; + if (first == "M") { + return c == MAD || c == MDL || c == MGA || c == MKD || c == MMK || c == MNT || c == MOP + || c == MRU || c == MUR || c == MVR || c == MWK || c == MXN || c == MYR || c == MZN; + } + if (first == "N") return c == NAD || c == NGN || c == NIO || c == NOK || c == NPR || c == NZD; + if (first == "O") return c == OMR; + if (first == "P") { + return c == PAB || c == PEN || c == PGK || c == PHP || c == PKR || c == PLN || c == PYG; + } + if (first == "Q") return c == QAR; + if (first == "R") return c == RON || c == RSD || c == RUB || c == RWF; + if (first == "S") { + return c == SAR || c == SBD || c == SCR || c == SDG || c == SEK || c == SGD || c == SHP + || c == SLE || c == SOS || c == SRD || c == SSP || c == STN || c == SVC || c == SYP + || c == SZL; + } + if (first == "T") { + return c == THB || c == TJS || c == TMT || c == TND || c == TOP || c == TRY || c == TTD + || c == TWD || c == TZS; + } + if (first == "U") return c == UAH || c == UGX || c == USD || c == UYU || c == UZS; + if (first == "V") return c == VED || c == VES || c == VND || c == VUV; + if (first == "W") return c == WST; + if (first == "X") return c == XAF || c == XCD || c == XOF || c == XPF; + if (first == "Y") return c == YER; + if (first == "Z") return c == ZAR || c == ZMW || c == ZWG; return false; } From 082ea99cb5c19fb4712854cef2b0bf3944bfc618 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Thu, 21 May 2026 00:02:44 -0700 Subject: [PATCH 10/14] perf: order ISO4217 buckets by FX volume / population MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sorts entries within each first-byte bucket so the highest-volume codes short-circuit first. USD/EUR/JPY/GBP/CHF/CAD/AUD/SEK/NOK/NZD (G10) lead their respective buckets, with CNY, INR, MXN, BRL, etc. following close behind. Also reorders the outer letter-dispatch so the buckets containing the G10 and top-FX currencies are checked first (U/E/J/G/C/A/N/S/I/M/T/P/K/B/H/R/D/X/Z/V/L), keeping single-entry buckets at the end. Per-call gas (micro-bench, 100 calls each): - USD: ~370 gas (was 1.4k; ~11× the original keccak chain's 4.2k) - EUR: ~400 gas - JPY: ~430 gas - ZWG (late): ~1.3k - ZZZ (miss): ~1.3k Mean for real-world stablecoin traffic — overwhelmingly USD with EUR a distant second — is effectively two comparisons (first-byte + first match), per the power-law observation that drove the sort. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/utils/ISO4217.sol | 116 ++++++++++++++++++++++++++---------------- 1 file changed, 73 insertions(+), 43 deletions(-) diff --git a/src/utils/ISO4217.sol b/src/utils/ISO4217.sol index 8bb222a..c66e80f 100644 --- a/src/utils/ISO4217.sol +++ b/src/utils/ISO4217.sol @@ -213,8 +213,16 @@ library ISO4217 { /// @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. Letters with - /// no allowlist entries fall through to `return false`. + /// 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; @@ -227,60 +235,82 @@ library ISO4217 { } bytes1 first = bytes1(c); - if (first == "A") { - return c == AED || c == AFN || c == ALL || c == AMD || c == ANG || c == AOA || c == ARS - || c == AUD || c == AWG || c == AZN; - } - if (first == "B") { - return c == BAM || c == BBD || c == BDT || c == BGN || c == BHD || c == BIF || c == BMD - || c == BND || c == BOB || c == BRL || c == BSD || c == BTN || c == BWP || c == BYN - || c == BZD; - } - if (first == "C") { - return c == CAD || c == CDF || c == CHF || c == CNY || c == COP || c == CRC || c == CUP - || c == CVE || c == CZK; - } - if (first == "D") return c == DJF || c == DKK || c == DOP || c == DZD; - if (first == "E") return c == EGP || c == ERN || c == ETB || c == EUR; - if (first == "F") return c == FJD || c == FKP; + // 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 == GEL || c == GHS || c == GIP || c == GMD || c == GNF || c == GTQ + return c == GBP || c == GHS || c == GEL || c == GTQ || c == GIP || c == GMD || c == GNF || c == GYD; } - if (first == "H") return c == HKD || c == HNL || c == HTG || c == HUF; - if (first == "I") return c == IDR || c == ILS || c == INR || c == IQD || c == IRR || c == ISK; - if (first == "J") return c == JMD || c == JOD || c == JPY; - if (first == "K") { - return c == KES || c == KGS || c == KHR || c == KMF || c == KPW || c == KRW || c == KWD - || c == KYD || c == KZT; - } - if (first == "L") return c == LAK || c == LBP || c == LKR || c == LRD || c == LSL || c == LYD; - if (first == "M") { - return c == MAD || c == MDL || c == MGA || c == MKD || c == MMK || c == MNT || c == MOP - || c == MRU || c == MUR || c == MVR || c == MWK || c == MXN || c == MYR || c == MZN; + // 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; } - if (first == "N") return c == NAD || c == NGN || c == NIO || c == NOK || c == NPR || c == NZD; - if (first == "O") return c == OMR; - if (first == "P") { - return c == PAB || c == PEN || c == PGK || c == PHP || c == PKR || c == PLN || c == PYG; + // 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; } - if (first == "Q") return c == QAR; - if (first == "R") return c == RON || c == RSD || c == RUB || c == RWF; + // 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 == SAR || c == SBD || c == SCR || c == SDG || c == SEK || c == SGD || c == SHP + 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 == THB || c == TJS || c == TMT || c == TND || c == TOP || c == TRY || c == TTD - || c == TWD || c == TZS; + 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; } - if (first == "U") return c == UAH || c == UGX || c == USD || c == UYU || c == UZS; - if (first == "V") return c == VED || c == VES || c == VND || c == VUV; + // 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 == "X") return c == XAF || c == XCD || c == XOF || c == XPF; if (first == "Y") return c == YER; - if (first == "Z") return c == ZAR || c == ZMW || c == ZWG; return false; } From 7ef9e9cfeb6c23998301eb34239c09fc3161b9ec Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Thu, 21 May 2026 00:08:49 -0700 Subject: [PATCH 11/14] refactor: move ISO4217 into test/lib and document supported codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library is consumed only by the mock factory and test fuzzers, so it doesn't belong under src/ — moving to test/lib/ alongside the other mock-supporting code keeps the src/ surface focused on the interfaces consumers actually import. The src/utils/ directory is removed (it had no other entries). Also appends a "Supported currencies" table to the docs page listing all 155 allowlist codes alphabetically with currency name and region/issuer. Useful as a self-contained reference so readers don't have to map ISO 4217 codes to currencies themselves. Updates the docs-file path link from src/utils/ to test/lib/ and the natspec import paths in MockTokenFactory and createToken.t.sol. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/b20/stablecoin/currency-validation.md | 164 ++++++++++++++++++++- {src/utils => test/lib}/ISO4217.sol | 0 test/lib/mocks/MockTokenFactory.sol | 2 +- test/unit/TokenFactory/createToken.t.sol | 2 +- 4 files changed, 165 insertions(+), 3 deletions(-) rename {src/utils => test/lib}/ISO4217.sol (100%) diff --git a/docs/b20/stablecoin/currency-validation.md b/docs/b20/stablecoin/currency-validation.md index 7ee3bca..cea7805 100644 --- a/docs/b20/stablecoin/currency-validation.md +++ b/docs/b20/stablecoin/currency-validation.md @@ -14,7 +14,7 @@ The factory needs a deterministic, machine-readable filter rather than a free-fo ## Solution -Validate `currency` at creation against a hardcoded allowlist of active **ISO 4217 alphabetic codes** for circulating national fiat currencies, implemented in [`src/utils/ISO4217.sol`](../../../src/utils/ISO4217.sol). Anything off the list reverts with `ITokenFactory.InvalidCurrency(code)` carrying the offending string verbatim. +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. @@ -50,3 +50,165 @@ Key properties: | 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. | + +## 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/utils/ISO4217.sol b/test/lib/ISO4217.sol similarity index 100% rename from src/utils/ISO4217.sol rename to test/lib/ISO4217.sol diff --git a/test/lib/mocks/MockTokenFactory.sol b/test/lib/mocks/MockTokenFactory.sol index 49d5a9d..a10392b 100644 --- a/test/lib/mocks/MockTokenFactory.sol +++ b/test/lib/mocks/MockTokenFactory.sol @@ -5,7 +5,7 @@ import {Vm} from "forge-std/Vm.sol"; import {ITokenFactory} from "src/interfaces/ITokenFactory.sol"; -import {ISO4217} from "src/utils/ISO4217.sol"; +import {ISO4217} from "test/lib/ISO4217.sol"; import {MockB20} from "test/lib/mocks/MockB20.sol"; import {MockB20Stablecoin} from "test/lib/mocks/MockB20Stablecoin.sol"; diff --git a/test/unit/TokenFactory/createToken.t.sol b/test/unit/TokenFactory/createToken.t.sol index 54a0818..b78f833 100644 --- a/test/unit/TokenFactory/createToken.t.sol +++ b/test/unit/TokenFactory/createToken.t.sol @@ -6,7 +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 "src/utils/ISO4217.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"; From 4c35ff282772d7549bebe92002ad8fe162de352b Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Thu, 21 May 2026 02:18:07 -0700 Subject: [PATCH 12/14] docs: add Alternatives Considered section to currency-validation.md Captures the five validation approaches surfaced during design (no validation, format-only, full ISO 4217, narrow ISO 4217 fiat, off- chain registry) with a one-line rationale for why each non-chosen option was set aside. Marks the chosen approach inline so the decision is visible without forcing readers to cross-reference the solution section. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/b20/stablecoin/currency-validation.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/b20/stablecoin/currency-validation.md b/docs/b20/stablecoin/currency-validation.md index cea7805..96d4f2c 100644 --- a/docs/b20/stablecoin/currency-validation.md +++ b/docs/b20/stablecoin/currency-validation.md @@ -51,6 +51,16 @@ Key properties: | 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 + +| Approach | Description | Why not chosen | +| --- | --- | --- | +| No validation | Accept any non-empty string for `currency` (the original behavior) | Typos (`"usd"`), token symbols (`"USDC"`), and arbitrary strings pollute the value space; no on-chain way for tooling to categorize | +| Format-only check | Enforce length-3 uppercase ASCII without an allowlist | Admits `"ZZZ"`, crypto tickers, and any well-formed 3-char string indistinguishable from real codes; no semantic gate | +| Full ISO 4217 active list | Accept every ISO 4217 alphabetic code, including X-prefix metals, supranational synthetics, and funds codes (the TIP-20 broad-scope precedent) | Conflates commodities and accounting units with fiat — commodity-backed tokens belong on `B20Security`; indexing units (CLF, USN, etc.) aren't holdable; loses the regulatory alignment with MiCA EMT / MAS SCS | +| Narrow ISO 4217 fiat allowlist | Circulating national fiat only, aligned with MiCA EMT / MAS SCS | **Chosen.** Standardized value space; rejects typos and out-of-scope categories at creation; matches the regulatory categorization that downstream tooling will encounter | +| Off-chain registry | No on-chain validation; consumers look up currency identity in an external registry | No standard value space across tokens; every consumer reinvents the categorization; doesn't catch typos at creation; pushes a fixable problem into runtime | + ## Supported currencies All 155 codes on the allowlist, alphabetical by code. From dd249b6ac0a5287848e1af3b5fafa612ac544420 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Thu, 21 May 2026 02:20:28 -0700 Subject: [PATCH 13/14] docs: restructure Alternatives table as Option / Pros / Cons Bolds the option title, moves the description underneath with a `
` separator, and splits the per-option rationale into bulleted Pros and Cons columns. Easier to scan; trade-offs land on both sides of the line rather than only as a single "why not" sentence. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/b20/stablecoin/currency-validation.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/b20/stablecoin/currency-validation.md b/docs/b20/stablecoin/currency-validation.md index 96d4f2c..42eef21 100644 --- a/docs/b20/stablecoin/currency-validation.md +++ b/docs/b20/stablecoin/currency-validation.md @@ -53,13 +53,13 @@ Key properties: ## Alternatives considered -| Approach | Description | Why not chosen | +| Option | Pros | Cons | | --- | --- | --- | -| No validation | Accept any non-empty string for `currency` (the original behavior) | Typos (`"usd"`), token symbols (`"USDC"`), and arbitrary strings pollute the value space; no on-chain way for tooling to categorize | -| Format-only check | Enforce length-3 uppercase ASCII without an allowlist | Admits `"ZZZ"`, crypto tickers, and any well-formed 3-char string indistinguishable from real codes; no semantic gate | -| Full ISO 4217 active list | Accept every ISO 4217 alphabetic code, including X-prefix metals, supranational synthetics, and funds codes (the TIP-20 broad-scope precedent) | Conflates commodities and accounting units with fiat — commodity-backed tokens belong on `B20Security`; indexing units (CLF, USN, etc.) aren't holdable; loses the regulatory alignment with MiCA EMT / MAS SCS | -| Narrow ISO 4217 fiat allowlist | Circulating national fiat only, aligned with MiCA EMT / MAS SCS | **Chosen.** Standardized value space; rejects typos and out-of-scope categories at creation; matches the regulatory categorization that downstream tooling will encounter | -| Off-chain registry | No on-chain validation; consumers look up currency identity in an external registry | No standard value space across tokens; every consumer reinvents the categorization; doesn't catch typos at creation; pushes a fixable problem into runtime | +| **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 | +| **Off-chain registry**
No on-chain validation; consumers look up currency identity externally | • Zero on-chain cost
• Max flexibility for issuers
• No allowlist to maintain | • No standard value space across tokens
• Every consumer reinvents categorization
• Doesn't catch typos at creation | ## Supported currencies From e7ffcf51f094d38fbd6137bb5fce5afc71a186c7 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Thu, 21 May 2026 02:23:31 -0700 Subject: [PATCH 14/14] =?UTF-8?q?docs:=20tighten=20Alternatives=20table=20?= =?UTF-8?q?=E2=80=94=20manual=20line=20breaks,=20drop=20off-chain=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds manual `
` breaks within each Option-column description so the column stops sprawling and the Pros/Cons columns get balanced width. Removes the off-chain registry row — it's a non-option (no on-chain validation defeats the whole purpose). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/b20/stablecoin/currency-validation.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/b20/stablecoin/currency-validation.md b/docs/b20/stablecoin/currency-validation.md index 42eef21..7866e44 100644 --- a/docs/b20/stablecoin/currency-validation.md +++ b/docs/b20/stablecoin/currency-validation.md @@ -55,11 +55,10 @@ Key properties: | 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 | -| **Off-chain registry**
No on-chain validation; consumers look up currency identity externally | • Zero on-chain cost
• Max flexibility for issuers
• No allowlist to maintain | • No standard value space across tokens
• Every consumer reinvents categorization
• Doesn't catch typos at creation | +| **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