From 5ccbd13bb5938a1e11e4ee8647c05f48e91c48b4 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Mon, 1 Jun 2026 22:16:57 -0400 Subject: [PATCH 1/3] feat(asset): add configurable decimals to security variant (BOP-252, BOP-255, BOP-259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a uint8 `decimals` field to the security variant — stored in the `base.b20.security` namespace alongside `sharesToTokensRatio`, accepted as part of `B20SecurityCreateParams` (bumps the encoding version to 2), validated to the range [6, 18], and threaded into the `B20Created` event. Stablecoin variant continues to hardcode 6. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/B20/README.md | 2 +- docs/B20/Security.md | 6 +- src/interfaces/IB20Factory.sol | 18 +- src/lib/B20Constants.sol | 10 + src/lib/B20FactoryLib.sol | 14 +- test/lib/B20FactoryTest.sol | 31 ++- test/lib/mocks/MockB20.sol | 8 +- test/lib/mocks/MockB20Factory.sol | 27 ++- test/lib/mocks/MockB20Security.sol | 10 +- test/lib/mocks/MockB20Storage.sol | 24 +- .../createB20_security_decimals.t.sol | 222 ++++++++++++++++++ test/unit/B20Factory/createToken.t.sol | 22 +- .../encodeSecurityCreateParams.t.sol | 20 +- test/unit/storage/B20SecurityFullLayout.t.sol | 28 ++- 14 files changed, 402 insertions(+), 40 deletions(-) create mode 100644 test/unit/B20Factory/createB20_security_decimals.t.sol diff --git a/docs/B20/README.md b/docs/B20/README.md index 31f4e59..0d15a2a 100644 --- a/docs/B20/README.md +++ b/docs/B20/README.md @@ -114,4 +114,4 @@ ERC-1271 contract signatures are deliberately NOT accepted — permit recovers v | Variant | Decimals | What it adds | |---|---|---| | [Stablecoin](Stablecoin.md) | 6 (fixed) | currency ISO code | -| [Security](Security.md) | 6 (fixed) | multiplier, announcements, identifiers, redemptions | +| [Security](Security.md) | 6-18 (configurable per token) | multiplier, announcements, identifiers, redemptions | diff --git a/docs/B20/Security.md b/docs/B20/Security.md index e7dad7f..74031a6 100644 --- a/docs/B20/Security.md +++ b/docs/B20/Security.md @@ -61,6 +61,8 @@ Each Security token can carry one or more standardized identifiers (ISIN, CUSIP, Gates the three corporate-actions setters (`updateMultiplier`, `batchMint`, `updateSecurityIdentifier`) and the `announce` wrapper itself. Held separately from `DEFAULT_ADMIN_ROLE` so corporate-actions operators don't need full admin authority. Operationally paired with `METADATA_ROLE` — when granting one, you typically grant the other to the same address. -## Fixed Decimals (6) +## Configurable Decimals -`decimals()` is hard-wired to `6`. The choice matches the precision used by popular real-world securities-platform integrations. +`decimals()` is chosen at creation via `B20SecurityCreateParams.decimals` and immutable thereafter. The factory enforces the inclusive range `[6, 18]` (exposed as `B20Constants.MIN_ASSET_DECIMALS` and `MAX_ASSET_DECIMALS`); out-of-range values revert `InvalidDecimals(decimals)`. `6` matches the precision used by popular real-world securities-platform integrations and is the smallest unit any common stablecoin uses; `18` is the ERC-20 community ceiling that every wallet and indexer renders correctly. + +The stablecoin variant is unchanged — it hardcodes `decimals()` to `6`. diff --git a/src/interfaces/IB20Factory.sol b/src/interfaces/IB20Factory.sol index 2865476..282d1b3 100644 --- a/src/interfaces/IB20Factory.sol +++ b/src/interfaces/IB20Factory.sol @@ -13,7 +13,7 @@ interface IB20Factory { /// @notice Variant of a B-20 token. Encoded in address byte `[10]`. /// - /// @param SECURITY Security variant (multiplier, announcements, batched issuance / clawback). + /// @param SECURITY Security variant (configurable `decimals`, multiplier, announcements, batched issuance / clawback). /// @param STABLECOIN Stablecoin variant (fixed `6` decimals, immutable `currency`). enum B20Variant { SECURITY, @@ -36,7 +36,7 @@ interface IB20Factory { /// @notice Creation parameters for a Security-variant B-20 token. ABI-encoded into `params`. struct B20SecurityCreateParams { - /// @dev Encoding version. Currently `1`. + /// @dev Encoding version. Currently `2`. uint8 version; /// @dev ERC-20 token name. string name; @@ -44,6 +44,10 @@ interface IB20Factory { string symbol; /// @dev Initial holder of `DEFAULT_ADMIN_ROLE`, or `address(0)` to deploy admin-less. address initialAdmin; + /// @dev ERC-20 `decimals` value. Immutable post-creation. Must be in the inclusive + /// range `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]` + /// (`[6, 18]`); out-of-range values revert with `InvalidDecimals`. + uint8 decimals; } /// @notice Event payload carried in the `variantEventParams` field of `B20Created` for @@ -78,6 +82,12 @@ interface IB20Factory { /// @notice The stablecoin `currency` was non-empty but contained a non-`A`-`Z` byte. error InvalidCurrency(string code); + /// @notice The security `decimals` was outside the allowed inclusive range + /// `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]`. + /// + /// @param decimals Offending decimals value. + error InvalidDecimals(uint8 decimals); + /// @notice One of the `initCalls` reverted. The factory bubbles the underlying revert reason /// where the call returns one; this error wraps empty reverts. error InitCallFailed(uint256 index); @@ -92,6 +102,9 @@ interface IB20Factory { /// @dev `variantEventParams` carries variant-specific immutable identity data, ABI-encoded /// and prefixed with a version byte. Empty for SECURITY; for STABLECOIN, /// `abi.encode(B20StablecoinEventParams)` with `currency`. + /// @dev `decimals` mirrors the value chosen at creation: configurable in + /// `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]` for SECURITY + /// (from `B20SecurityCreateParams.decimals`); hardcoded to `6` for STABLECOIN. event B20Created( address indexed token, B20Variant indexed variant, @@ -114,6 +127,7 @@ interface IB20Factory { /// @dev Reverts with `UnsupportedVersion` when the leading `version` byte in `params` is unrecognized for `variant`. /// @dev Reverts with `MissingRequiredField` when a required string field is empty (e.g. stablecoin `currency` or security `isin`). /// @dev Reverts with `InvalidCurrency` when a stablecoin `currency` is non-empty but contains a non-`A`-`Z` byte. + /// @dev Reverts with `InvalidDecimals` when a security `decimals` is outside `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]`. /// @dev Reverts with `TokenAlreadyExists` when a token already exists at the derived address. /// @dev Reverts with `InitCallFailed` (or the bubbled inner reason) when any entry in `initCalls` reverts. /// diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index 1d78aab..d2cf37a 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -20,4 +20,14 @@ library B20Constants { /// @notice Bitmask with all `PausableFeature` bits set (TRANSFER | MINT | BURN). uint8 internal constant ALL_FEATURES_PAUSED = 7; + + /// @notice Inclusive lower bound for `B20SecurityCreateParams.decimals`. `6` is the + /// floor most stablecoin-grade integrations expect; values below it lose + /// meaningful unit precision for tokenized-security workflows. + uint8 internal constant MIN_ASSET_DECIMALS = 6; + + /// @notice Inclusive upper bound for `B20SecurityCreateParams.decimals`. `18` is the + /// ERC-20 community ceiling — every common wallet and indexer renders up to + /// 18 decimals correctly; going higher risks integration breakage. + uint8 internal constant MAX_ASSET_DECIMALS = 18; } diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index d28ad47..dbe2559 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -15,7 +15,9 @@ library B20FactoryLib { uint8 internal constant B20_STABLECOIN_CREATE_PARAMS_VERSION = 1; /// @notice Encoding version carried as the leading byte of a `B20SecurityCreateParams` blob. - uint8 internal constant B20_SECURITY_CREATE_PARAMS_VERSION = 1; + /// Bumped from `1` to `2` when the struct gained the `decimals` field + /// (previously hardcoded to `6` by the factory's security arm). + uint8 internal constant B20_SECURITY_CREATE_PARAMS_VERSION = 2; /// @notice Encoding version carried as the leading byte of a `B20StablecoinEventParams` blob. uint8 internal constant B20_STABLECOIN_EVENT_PARAMS_VERSION = 1; @@ -102,14 +104,20 @@ library B20FactoryLib { /// @param name ERC-20 token name. /// @param symbol ERC-20 token symbol. /// @param initialAdmin Initial holder of `DEFAULT_ADMIN_ROLE`, or `address(0)` to deploy admin-less. - function encodeSecurityCreateParams(string memory name, string memory symbol, address initialAdmin) + /// @param decimals ERC-20 `decimals` value. Must be in `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]`; + /// out-of-range values cause the factory to revert with `InvalidDecimals`. + function encodeSecurityCreateParams(string memory name, string memory symbol, address initialAdmin, uint8 decimals) internal pure returns (bytes memory) { return abi.encode( IB20Factory.B20SecurityCreateParams({ - version: B20_SECURITY_CREATE_PARAMS_VERSION, name: name, symbol: symbol, initialAdmin: initialAdmin + version: B20_SECURITY_CREATE_PARAMS_VERSION, + name: name, + symbol: symbol, + initialAdmin: initialAdmin, + decimals: decimals }) ); } diff --git a/test/lib/B20FactoryTest.sol b/test/lib/B20FactoryTest.sol index cc86267..5104ea3 100644 --- a/test/lib/B20FactoryTest.sol +++ b/test/lib/B20FactoryTest.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.20; import {BaseTest} from "test/lib/BaseTest.sol"; import {MockB20Storage} from "test/lib/mocks/MockB20Storage.sol"; +import {B20Constants} from "src/lib/B20Constants.sol"; +import {B20FactoryLib} from "src/lib/B20FactoryLib.sol"; import {IB20Factory} from "src/interfaces/IB20Factory.sol"; import {StdPrecompiles} from "src/StdPrecompiles.sol"; @@ -29,7 +31,11 @@ contract B20FactoryTest is BaseTest { string memory currency_ ) internal pure returns (IB20Factory.B20StablecoinCreateParams memory) { return IB20Factory.B20StablecoinCreateParams({ - version: 1, name: name_, symbol: symbol_, initialAdmin: initialAdmin_, currency: currency_ + version: B20FactoryLib.B20_STABLECOIN_CREATE_PARAMS_VERSION, + name: name_, + symbol: symbol_, + initialAdmin: initialAdmin_, + currency: currency_ }); } @@ -39,17 +45,34 @@ contract B20FactoryTest is BaseTest { } /// @notice Build a `B20SecurityCreateParams` with explicit fields. - function _securityParams(string memory name_, string memory symbol_, address initialAdmin_) + /// @dev Tests that don't care about `decimals` should call the + /// no-arg overload (which pins `decimals = MIN_ASSET_DECIMALS` + /// to match historical behavior). Tests that DO care thread the + /// explicit value through here. + function _securityParams(string memory name_, string memory symbol_, address initialAdmin_, uint8 decimals_) internal pure returns (IB20Factory.B20SecurityCreateParams memory) { return IB20Factory.B20SecurityCreateParams({ - version: 1, name: name_, symbol: symbol_, initialAdmin: initialAdmin_ + version: B20FactoryLib.B20_SECURITY_CREATE_PARAMS_VERSION, + name: name_, + symbol: symbol_, + initialAdmin: initialAdmin_, + decimals: decimals_ }); } - /// @notice Build a default `B20SecurityCreateParams` (`Security Test`/`SEC`, admin). + /// @notice Build a `B20SecurityCreateParams` with the default decimals (`MIN_ASSET_DECIMALS`). + function _securityParams(string memory name_, string memory symbol_, address initialAdmin_) + internal + pure + returns (IB20Factory.B20SecurityCreateParams memory) + { + return _securityParams(name_, symbol_, initialAdmin_, B20Constants.MIN_ASSET_DECIMALS); + } + + /// @notice Build a default `B20SecurityCreateParams` (`Security Test`/`SEC`, admin, `MIN_ASSET_DECIMALS`). function _securityParams() internal view returns (IB20Factory.B20SecurityCreateParams memory) { return _securityParams("Security Test", "SEC", admin); } diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 3e13e38..ff9207a 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -161,8 +161,12 @@ abstract contract MockB20 is IB20 { return MockB20Storage.layout().symbol; } - /// @notice Default-variant decimals are fixed at 18. - function decimals() external pure virtual returns (uint8) { + /// @notice Default-variant decimals are fixed at 18. The security variant + /// overrides this to read its per-token configured decimals from + /// `MockB20SecurityStorage`, which requires `view` (not `pure`) + /// mutability — so the base declares `view` to keep the override + /// legal under Solidity's tightening rules. + function decimals() external view virtual returns (uint8) { return 18; } diff --git a/test/lib/mocks/MockB20Factory.sol b/test/lib/mocks/MockB20Factory.sol index 715bfe9..3edc65a 100644 --- a/test/lib/mocks/MockB20Factory.sol +++ b/test/lib/mocks/MockB20Factory.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.20; import {Vm} from "forge-std/Vm.sol"; +import {B20Constants} from "src/lib/B20Constants.sol"; import {IB20Factory} from "src/interfaces/IB20Factory.sol"; import {B20FactoryLib} from "src/lib/B20FactoryLib.sol"; import {StdPrecompiles} from "src/StdPrecompiles.sol"; @@ -12,7 +13,7 @@ import {ActivationRegistryFeatureList} from "test/lib/mocks/ActivationRegistryFe import {MockB20Stablecoin} from "test/lib/mocks/MockB20Stablecoin.sol"; import {MockB20Security} from "test/lib/mocks/MockB20Security.sol"; import {MockB20} from "test/lib/mocks/MockB20.sol"; -import {MockB20Storage, MockB20StablecoinStorage} from "test/lib/mocks/MockB20Storage.sol"; +import {MockB20Storage, MockB20SecurityStorage, MockB20StablecoinStorage} from "test/lib/mocks/MockB20Storage.sol"; /// @title MockB20Factory /// @notice Reference implementation of the `IB20Factory` precompile @@ -117,10 +118,15 @@ contract MockB20Factory is IB20Factory { if (p.version != B20FactoryLib.B20_SECURITY_CREATE_PARAMS_VERSION) { revert UnsupportedVersion(p.version, variant); } + // Configurable per-token decimals; bounded so wallets, indexers, and + // downstream integrations stay in the well-supported ERC-20 range. + if (p.decimals < B20Constants.MIN_ASSET_DECIMALS || p.decimals > B20Constants.MAX_ASSET_DECIMALS) { + revert InvalidDecimals(p.decimals); + } name_ = p.name; symbol_ = p.symbol; admin = p.initialAdmin; - decimals = 6; + decimals = p.decimals; } else if (variant == B20Variant.STABLECOIN) { B20StablecoinCreateParams memory p = abi.decode(params, (B20StablecoinCreateParams)); if (p.version != B20FactoryLib.B20_STABLECOIN_CREATE_PARAMS_VERSION) { @@ -161,7 +167,9 @@ contract MockB20Factory is IB20Factory { // canonical grantRole path in step 7 so RoleGranted fires // from the token. _writeBaseStorage(token, name_, symbol_); - if (variant == B20Variant.STABLECOIN) { + if (variant == B20Variant.SECURITY) { + _writeSecurityStorage(token, decimals); + } else if (variant == B20Variant.STABLECOIN) { _writeStablecoinStorage(token, currency_); } @@ -308,6 +316,19 @@ contract MockB20Factory is IB20Factory { // after initCalls have run. } + /// @dev Writes the security variant's per-token immutable state at its + /// disjoint ERC-7201 namespace (`base.b20.security`). Today that + /// is just `decimals`; `multiplier` defaults to zero + /// (interpreted by the read surface as WAD), and announcement / + /// identifier maps are empty by default. + function _writeSecurityStorage(address token, uint8 decimals) internal { + // `decimals` is a `uint8` packed in the low byte of its own slot. + // Writing the whole slot is safe because the slot is otherwise + // unused today (future small variant-immutable fields packed into + // this slot would need this writer to mask instead). + _writeUint(token, MockB20SecurityStorage.decimalsSlot(), uint256(decimals)); + } + /// @dev Writes the stablecoin variant's `currency` field at its /// disjoint ERC-7201 namespace (`base.b20.stablecoin`). function _writeStablecoinStorage(address token, string memory currency_) internal { diff --git a/test/lib/mocks/MockB20Security.sol b/test/lib/mocks/MockB20Security.sol index ad3d2cc..ecd1dc8 100644 --- a/test/lib/mocks/MockB20Security.sol +++ b/test/lib/mocks/MockB20Security.sol @@ -68,11 +68,15 @@ contract MockB20Security is MockB20, IB20Security { // DECIMALS // ============================================================ - /// @notice Security-variant decimals are fixed at 6. Overrides the + /// @notice Security-variant decimals are chosen at creation from + /// `B20SecurityCreateParams.decimals` (validated to + /// `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]` + /// by the factory) and stored at + /// `MockB20SecurityStorage.decimalsSlot()`. Overrides the /// base `MockB20.decimals()` (which returns 18 for the /// default variant) per the `IB20Security` convention. - function decimals() external pure override(MockB20, IB20) returns (uint8) { - return 6; + function decimals() external view override(MockB20, IB20) returns (uint8) { + return MockB20SecurityStorage.layout().decimals; } // ============================================================ diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index 9db176e..e220afb 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -342,6 +342,14 @@ library MockB20Storage { /// default value during bootstrap. `updateMultiplier` writes /// the new multiplier verbatim; subsequent reads return the /// stored value as-is. +/// - `decimals` is the per-token ERC-20 decimals value, chosen +/// at creation by the factory from `B20SecurityCreateParams` +/// and immutable thereafter. Validated to the range +/// `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]` +/// by the factory. Solidity packs `uint8` into its own slot +/// (it does not back-pack with the preceding `uint256`); +/// future small variant-immutable fields can pack into the +/// remaining 31 bytes of this slot. /// - `usedAnnouncementIds` keys directly on the raw `string id` /// that callers pass to `announce` / `isAnnouncementIdUsed`, /// not on a hash, so the on-chain query mirrors the API. @@ -354,6 +362,12 @@ library MockB20SecurityStorage { // Scaled by WAD_PRECISION (1e18). Stored value of 0 is // interpreted as WAD by the read surface. uint256 multiplier; + // ---------- Decimals ---------- + // Per-token ERC-20 `decimals`. Written by the factory at + // creation from the `B20SecurityCreateParams.decimals` field + // (validated to [MIN_ASSET_DECIMALS, MAX_ASSET_DECIMALS]) and + // never mutated afterwards. + uint8 decimals; // ---------- Announcements ---------- // Tracks consumed announcement IDs; flips to true on first // `announce` for a given id, and remains true forever. @@ -376,8 +390,13 @@ library MockB20SecurityStorage { // deriving member slots via `keccak256(abi.encode(key, baseSlot))`. uint256 internal constant MULTIPLIER_OFFSET = 0; - uint256 internal constant USED_ANNOUNCEMENT_IDS_OFFSET = 1; - uint256 internal constant IDENTIFIERS_OFFSET = 2; + // `decimals` (a `uint8`) cannot back-pack with the preceding + // `uint256`, so Solidity allocates it a fresh slot. It occupies + // only the low byte; the remaining 31 bytes are reserved for + // future small variant-immutable fields. + uint256 internal constant DECIMALS_OFFSET = 1; + uint256 internal constant USED_ANNOUNCEMENT_IDS_OFFSET = 2; + uint256 internal constant IDENTIFIERS_OFFSET = 3; /// @notice Absolute slot for a top-level field of `Layout`. function slotOf(uint256 offset) internal pure returns (bytes32) { @@ -403,6 +422,7 @@ library MockB20SecurityStorage { // forgefmt: disable-start function multiplierSlot() internal pure returns (bytes32) { return slotOf(MULTIPLIER_OFFSET); } + function decimalsSlot() internal pure returns (bytes32) { return slotOf(DECIMALS_OFFSET); } function usedAnnouncementIdsBaseSlot() internal pure returns (bytes32) { return slotOf(USED_ANNOUNCEMENT_IDS_OFFSET); } function identifiersBaseSlot() internal pure returns (bytes32) { return slotOf(IDENTIFIERS_OFFSET); } diff --git a/test/unit/B20Factory/createB20_security_decimals.t.sol b/test/unit/B20Factory/createB20_security_decimals.t.sol new file mode 100644 index 0000000..bded26d --- /dev/null +++ b/test/unit/B20Factory/createB20_security_decimals.t.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Vm} from "forge-std/Vm.sol"; + +import {IB20} from "src/interfaces/IB20.sol"; +import {IB20Factory} from "src/interfaces/IB20Factory.sol"; +import {B20Constants} from "src/lib/B20Constants.sol"; + +import {MockB20} from "test/lib/mocks/MockB20.sol"; +import {MockB20SecurityStorage} from "test/lib/mocks/MockB20Storage.sol"; +import {B20FactoryTest} from "test/lib/B20FactoryTest.sol"; + +/// @notice Coverage for the security variant's configurable `decimals` field +/// introduced in BOP-252 / BOP-255 (storage) and BOP-259 (tests). +/// +/// @dev Asserts the boundary values, the out-of-range revert with the new +/// `InvalidDecimals(uint8)` error, fuzz coverage over the full +/// below-min / above-max / in-range ranges, the `B20Created` event's +/// `decimals` field reflecting the input, the storage round-trip via +/// the `MockB20SecurityStorage.decimalsSlot()` paired-slot pattern, +/// and the stablecoin variant's regression-free hardcoded `6`. +contract B20FactoryCreateB20SecurityDecimalsTest is B20FactoryTest { + /*////////////////////////////////////////////////////////////// + BOUNDARY SUCCESSES + //////////////////////////////////////////////////////////////*/ + + /// @notice Verifies the lower-bound boundary value `MIN_ASSET_DECIMALS` (`6`) succeeds + /// and the deployed token reports it from `decimals()`. + function test_createB20_success_decimals_lowerBound(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + IB20Factory.B20SecurityCreateParams memory p = + _securityParams("Security Test", "SEC", admin, B20Constants.MIN_ASSET_DECIMALS); + address token = _createSecurity(caller, salt, p, new bytes[](0)); + assertEq( + MockB20(token).decimals(), B20Constants.MIN_ASSET_DECIMALS, "decimals() must equal MIN_ASSET_DECIMALS (6)" + ); + } + + /// @notice Verifies the upper-bound boundary value `MAX_ASSET_DECIMALS` (`18`) succeeds + /// and the deployed token reports it from `decimals()`. + function test_createB20_success_decimals_upperBound(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + IB20Factory.B20SecurityCreateParams memory p = + _securityParams("Security Test", "SEC", admin, B20Constants.MAX_ASSET_DECIMALS); + address token = _createSecurity(caller, salt, p, new bytes[](0)); + assertEq( + MockB20(token).decimals(), B20Constants.MAX_ASSET_DECIMALS, "decimals() must equal MAX_ASSET_DECIMALS (18)" + ); + } + + /*////////////////////////////////////////////////////////////// + OUT-OF-RANGE REVERTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Verifies the just-below-min point case (`5`) reverts with `InvalidDecimals(5)`. + function test_createB20_revert_decimals_justBelowMin(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + uint8 bad = B20Constants.MIN_ASSET_DECIMALS - 1; + IB20Factory.B20SecurityCreateParams memory p = _securityParams("Security Test", "SEC", admin, bad); + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(IB20Factory.InvalidDecimals.selector, bad)); + factory.createB20(IB20Factory.B20Variant.SECURITY, salt, abi.encode(p), new bytes[](0)); + } + + /// @notice Verifies the just-above-max point case (`19`) reverts with `InvalidDecimals(19)`. + function test_createB20_revert_decimals_justAboveMax(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + uint8 bad = B20Constants.MAX_ASSET_DECIMALS + 1; + IB20Factory.B20SecurityCreateParams memory p = _securityParams("Security Test", "SEC", admin, bad); + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(IB20Factory.InvalidDecimals.selector, bad)); + factory.createB20(IB20Factory.B20Variant.SECURITY, salt, abi.encode(p), new bytes[](0)); + } + + /// @notice Fuzzes the entire below-min range `[0, MIN_ASSET_DECIMALS - 1]` and asserts + /// the factory reverts with `InvalidDecimals(decimals)` on every value. + function test_createB20_revert_decimals_belowMin_fuzz(address caller, bytes32 salt, uint8 decimalsSeed) public { + _assumeValidCaller(caller); + uint8 bad = uint8(bound(uint256(decimalsSeed), 0, uint256(B20Constants.MIN_ASSET_DECIMALS) - 1)); + IB20Factory.B20SecurityCreateParams memory p = _securityParams("Security Test", "SEC", admin, bad); + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(IB20Factory.InvalidDecimals.selector, bad)); + factory.createB20(IB20Factory.B20Variant.SECURITY, salt, abi.encode(p), new bytes[](0)); + } + + /// @notice Fuzzes the entire above-max range `[MAX_ASSET_DECIMALS + 1, type(uint8).max]` + /// and asserts the factory reverts with `InvalidDecimals(decimals)` on every value. + function test_createB20_revert_decimals_aboveMax_fuzz(address caller, bytes32 salt, uint8 decimalsSeed) public { + _assumeValidCaller(caller); + uint8 bad = + uint8(bound(uint256(decimalsSeed), uint256(B20Constants.MAX_ASSET_DECIMALS) + 1, uint256(type(uint8).max))); + IB20Factory.B20SecurityCreateParams memory p = _securityParams("Security Test", "SEC", admin, bad); + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(IB20Factory.InvalidDecimals.selector, bad)); + factory.createB20(IB20Factory.B20Variant.SECURITY, salt, abi.encode(p), new bytes[](0)); + } + + /*////////////////////////////////////////////////////////////// + IN-RANGE FUZZ + //////////////////////////////////////////////////////////////*/ + + /// @notice Fuzzes the entire allowed range `[MIN_ASSET_DECIMALS, MAX_ASSET_DECIMALS]` + /// and asserts creation succeeds and `decimals()` round-trips the input. + function test_createB20_success_decimals_inRange_fuzz(address caller, bytes32 salt, uint8 decimalsSeed) public { + _assumeValidCaller(caller); + uint8 good = uint8( + bound( + uint256(decimalsSeed), + uint256(B20Constants.MIN_ASSET_DECIMALS), + uint256(B20Constants.MAX_ASSET_DECIMALS) + ) + ); + IB20Factory.B20SecurityCreateParams memory p = _securityParams("Security Test", "SEC", admin, good); + address token = _createSecurity(caller, salt, p, new bytes[](0)); + assertEq(MockB20(token).decimals(), good, "decimals() must round-trip the in-range fuzzed input"); + } + + /*////////////////////////////////////////////////////////////// + STORAGE ROUND-TRIP + //////////////////////////////////////////////////////////////*/ + + /// @notice Verifies the factory writes `decimals` to the canonical slot + /// (`MockB20SecurityStorage.decimalsSlot()`) so the storage value + /// matches both the input and `decimals()` on every in-range value. + /// @dev Paired-slot pattern: the surface read AND the raw `vm.load` both + /// match the fuzz input. + function test_createB20_success_decimals_storageRoundTrip(address caller, bytes32 salt, uint8 decimalsSeed) public { + _assumeValidCaller(caller); + uint8 good = uint8( + bound( + uint256(decimalsSeed), + uint256(B20Constants.MIN_ASSET_DECIMALS), + uint256(B20Constants.MAX_ASSET_DECIMALS) + ) + ); + IB20Factory.B20SecurityCreateParams memory p = _securityParams("Security Test", "SEC", admin, good); + address token = _createSecurity(caller, salt, p, new bytes[](0)); + + assertEq( + uint256(vm.load(token, MockB20SecurityStorage.decimalsSlot())), + uint256(good), + "decimalsSlot must hold the factory-written decimals byte" + ); + assertEq(MockB20(token).decimals(), good, "decimals() surface must match the slot value"); + } + + /*////////////////////////////////////////////////////////////// + EVENT-VS-STATE COHERENCE + //////////////////////////////////////////////////////////////*/ + + /// @notice Verifies the `B20Created` event's `decimals` field equals the input value + /// on every in-range fuzz input. + /// @dev `expectEmit`-level pin with a fuzzed value. + function test_createB20_success_emitsB20Created_security_decimals(address caller, bytes32 salt, uint8 decimalsSeed) + public + { + _assumeValidCaller(caller); + uint8 good = uint8( + bound( + uint256(decimalsSeed), + uint256(B20Constants.MIN_ASSET_DECIMALS), + uint256(B20Constants.MAX_ASSET_DECIMALS) + ) + ); + IB20Factory.B20SecurityCreateParams memory p = _securityParams("Security Test", "SEC", admin, good); + address predicted = factory.getB20Address(IB20Factory.B20Variant.SECURITY, caller, salt); + + vm.expectEmit(true, true, false, true, address(factory)); + emit IB20Factory.B20Created(predicted, IB20Factory.B20Variant.SECURITY, "Security Test", "SEC", good, bytes("")); + _createSecurity(caller, salt, p, new bytes[](0)); + } + + /// @notice Decodes the `B20Created` event from the recorded logs and asserts its + /// `decimals` field equals `token.decimals()` (the deployed token's surface). + /// @dev Decode-level pin (complementary to the `expectEmit` pin above): catches a + /// regression where the event payload and the storage write would diverge. + function test_createB20_success_b20CreatedDecimals_decodes(address caller, bytes32 salt, uint8 decimalsSeed) + public + { + _assumeValidCaller(caller); + uint8 good = uint8( + bound( + uint256(decimalsSeed), + uint256(B20Constants.MIN_ASSET_DECIMALS), + uint256(B20Constants.MAX_ASSET_DECIMALS) + ) + ); + IB20Factory.B20SecurityCreateParams memory p = _securityParams("Security Test", "SEC", admin, good); + + vm.recordLogs(); + address token = _createSecurity(caller, salt, p, new bytes[](0)); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 selector = IB20Factory.B20Created.selector; + uint8 eventDecimals; + bool found; + for (uint256 i = 0; i < logs.length; ++i) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == selector) { + // B20Created data payload: (name, symbol, decimals, variantEventParams). + (,, eventDecimals,) = abi.decode(logs[i].data, (string, string, uint8, bytes)); + found = true; + break; + } + } + assertTrue(found, "B20Created event must be emitted"); + assertEq(eventDecimals, good, "event decimals must equal the input"); + assertEq(MockB20(token).decimals(), eventDecimals, "event decimals must equal token.decimals()"); + } + + /*////////////////////////////////////////////////////////////// + STABLECOIN REGRESSION SANITY + //////////////////////////////////////////////////////////////*/ + + /// @notice Verifies the stablecoin variant still hardcodes `decimals() == 6`. + /// BOP-255 is a security-only change; the stablecoin path must be untouched. + function test_createB20_success_stablecoin_decimalsStillHardcoded(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + address token = _createStablecoin(caller, salt, _stablecoinParams(), new bytes[](0)); + assertEq(MockB20(token).decimals(), 6, "stablecoin decimals must remain hardcoded at 6"); + } +} diff --git a/test/unit/B20Factory/createToken.t.sol b/test/unit/B20Factory/createToken.t.sol index 5385b00..70a3254 100644 --- a/test/unit/B20Factory/createToken.t.sol +++ b/test/unit/B20Factory/createToken.t.sol @@ -107,9 +107,12 @@ contract B20FactoryCreateB20Test is B20FactoryTest { /// @notice Verifies createToken reverts for any unsupported version byte on the SECURITY variant /// @dev Each variant arm has its own version check; this exercises the security arm's check. + /// The current supported version is `B20_SECURITY_CREATE_PARAMS_VERSION` (bumped from + /// `1` to `2` when the struct gained the `decimals` field) — fuzzing assumes the + /// version byte is anything else. function test_createB20_revert_unsupportedVersion_security(address caller, uint8 badVersion, bytes32 salt) public { _assumeValidCaller(caller); - vm.assume(badVersion != 1); + vm.assume(badVersion != B20FactoryLib.B20_SECURITY_CREATE_PARAMS_VERSION); IB20Factory.B20SecurityCreateParams memory p = _securityParams(); p.version = badVersion; vm.prank(caller); @@ -683,14 +686,23 @@ contract B20FactoryCreateB20Test is B20FactoryTest { ); } - /// @notice Verifies decimals are fixed by variant and not encoded in address bytes - /// @dev Both variants return 6. + /// @notice Verifies stablecoin decimals are hardcoded at `6` and security decimals + /// reflect the value passed at creation (rather than coming from address bytes). + /// @dev Stablecoin decimals are immutable and not configurable. Security decimals + /// are per-token (see `B20SecurityCreateParams.decimals`); the default + /// `_securityParams()` helper passes `MIN_ASSET_DECIMALS` (`6`) so this test + /// still pins the legacy-behavior case. Full range coverage lives in + /// `createB20_security_decimals.t.sol`. function test_createB20_success_decimalsFixedByVariant(address caller, bytes32 salt) public { _assumeValidCaller(caller); address stablecoinToken = _createStablecoin(caller, salt, _stablecoinParams(), new bytes[](0)); address securityToken = _createSecurity(caller, salt, _securityParams(), new bytes[](0)); - assertEq(MockB20(stablecoinToken).decimals(), 6, "stablecoin decimals must be fixed at 6"); - assertEq(MockB20(securityToken).decimals(), 6, "security decimals must be fixed at 6"); + assertEq(MockB20(stablecoinToken).decimals(), 6, "stablecoin decimals must be hardcoded at 6"); + assertEq( + MockB20(securityToken).decimals(), + B20Constants.MIN_ASSET_DECIMALS, + "security decimals must reflect default _securityParams (MIN_ASSET_DECIMALS)" + ); } } diff --git a/test/unit/B20FactoryLib/encodeSecurityCreateParams.t.sol b/test/unit/B20FactoryLib/encodeSecurityCreateParams.t.sol index aed0bf2..c095874 100644 --- a/test/unit/B20FactoryLib/encodeSecurityCreateParams.t.sol +++ b/test/unit/B20FactoryLib/encodeSecurityCreateParams.t.sol @@ -14,9 +14,10 @@ contract B20FactoryLibEncodeSecurityCreateParamsTest is B20FactoryLibTest { function test_encodeSecurityCreateParams_success_roundTripsThroughDecode( string memory name, string memory symbol, - address initialAdmin + address initialAdmin, + uint8 decimals ) public pure { - bytes memory blob = B20FactoryLib.encodeSecurityCreateParams(name, symbol, initialAdmin); + bytes memory blob = B20FactoryLib.encodeSecurityCreateParams(name, symbol, initialAdmin, decimals); IB20Factory.B20SecurityCreateParams memory decoded = abi.decode(blob, (IB20Factory.B20SecurityCreateParams)); assertEq( @@ -27,6 +28,7 @@ contract B20FactoryLibEncodeSecurityCreateParamsTest is B20FactoryLibTest { assertEq(decoded.name, name, "name must round-trip"); assertEq(decoded.symbol, symbol, "symbol must round-trip"); assertEq(decoded.initialAdmin, initialAdmin, "initialAdmin must round-trip"); + assertEq(decoded.decimals, decimals, "decimals must round-trip"); } /// @notice Verifies the encoded blob is byte-identical to a hand-encoded @@ -36,17 +38,25 @@ contract B20FactoryLibEncodeSecurityCreateParamsTest is B20FactoryLibTest { function test_encodeSecurityCreateParams_success_matchesHandEncodedStruct( string memory name, string memory symbol, - address initialAdmin + address initialAdmin, + uint8 decimals ) public pure { bytes memory expected = abi.encode( IB20Factory.B20SecurityCreateParams({ version: B20FactoryLib.B20_SECURITY_CREATE_PARAMS_VERSION, name: name, symbol: symbol, - initialAdmin: initialAdmin + initialAdmin: initialAdmin, + decimals: decimals }) ); - bytes memory actual = B20FactoryLib.encodeSecurityCreateParams(name, symbol, initialAdmin); + bytes memory actual = B20FactoryLib.encodeSecurityCreateParams(name, symbol, initialAdmin, decimals); assertEq(actual, expected, "encoded blob must match hand-encoded struct byte-for-byte"); } + + /// @notice Verifies the library's current security create-params version is `2`. + /// @dev Pins the constant so a future bump is intentional and visible in diff. + function test_b20SecurityCreateParamsVersion_pinned() public pure { + assertEq(uint256(B20FactoryLib.B20_SECURITY_CREATE_PARAMS_VERSION), 2, "version must be 2"); + } } diff --git a/test/unit/storage/B20SecurityFullLayout.t.sol b/test/unit/storage/B20SecurityFullLayout.t.sol index 4977aae..b13699f 100644 --- a/test/unit/storage/B20SecurityFullLayout.t.sol +++ b/test/unit/storage/B20SecurityFullLayout.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import {B20Constants} from "src/lib/B20Constants.sol"; + import {B20SecurityTest} from "test/lib/B20SecurityTest.sol"; import {MockB20SecurityStorage} from "test/lib/mocks/MockB20Storage.sol"; @@ -27,10 +29,11 @@ contract B20SecurityFullLayoutTest is B20SecurityTest { /// @notice Cross-cuts every field of the security-variant namespace in /// one populated snapshot. - /// @dev Field coverage for `base.b20.security` (slots 0..2): + /// @dev Field coverage for `base.b20.security` (slots 0..3): /// - 0: multiplier - /// - 1: usedAnnouncementIds[id] - /// - 2: identifiers[identifierType] (FIGI mutated) + /// - 1: decimals (factory-written at creation) + /// - 2: usedAnnouncementIds[id] + /// - 3: identifiers[identifierType] (FIGI mutated) function test_b20SecurityLayout_success_populatedSnapshotMatchesAllSlots() public { // ---------- Populate ---------- _populate(); @@ -44,28 +47,37 @@ contract B20SecurityFullLayoutTest is B20SecurityTest { "security slot 0: multiplier must hold the written value" ); - // ---------- usedAnnouncementIds[id] (slot 1, hashed by id) ---------- + // ---------- decimals (slot 1) ---------- + // The default `_securityParams()` helper passes MIN_ASSET_DECIMALS (6), + // and the factory writes that as a one-word `uint256` (low byte). + assertEq( + uint256(vm.load(tokenAddr, MockB20SecurityStorage.decimalsSlot())), + uint256(B20Constants.MIN_ASSET_DECIMALS), + "security slot 1: decimals must hold the factory-written value" + ); + + // ---------- usedAnnouncementIds[id] (slot 2, hashed by id) ---------- // Slot resolves to keccak256(abi.encodePacked(id, baseSlot)). Solidity // stores a `bool true` as a one-word `uint256(1)`. assertEq( uint256(vm.load(tokenAddr, MockB20SecurityStorage.usedAnnouncementIdSlot(ANNOUNCEMENT_ID))), uint256(1), - "security slot 1: usedAnnouncementIds[id] must be true after announce" + "security slot 2: usedAnnouncementIds[id] must be true after announce" ); - // ---------- identifiers[identifierType] (slot 2, hashed by type) ---------- + // ---------- identifiers[identifierType] (slot 3, hashed by type) ---------- // The factory does not seed any identifier at creation, so a fresh token's // ISIN slot is empty. The post-creation FIGI write is pinned to the // canonical string-field encoding at its derived slot. assertEq( vm.load(tokenAddr, MockB20SecurityStorage.identifierSlot(IDENTIFIER_ISIN)), bytes32(0), - "security slot 2: identifiers[ISIN] must remain zero (factory seeds no identifiers)" + "security slot 3: identifiers[ISIN] must remain zero (factory seeds no identifiers)" ); assertEq( vm.load(tokenAddr, MockB20SecurityStorage.identifierSlot(IDENTIFIER_FIGI)), _expectedStringFieldSlot(FIGI_VALUE), - "security slot 2: identifiers[FIGI] must hold the post-creation short-string encoding" + "security slot 3: identifiers[FIGI] must hold the post-creation short-string encoding" ); } From 1f1fffed56f5182a0b4b38dab53b74d6c58a2b96 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Mon, 1 Jun 2026 22:36:24 -0400 Subject: [PATCH 2/3] review(asset): address PR125 feedback on decimals work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts B20_SECURITY_CREATE_PARAMS_VERSION back to 1 (prerelease, no need to bump). Makes MockB20.decimals() abstract so each variant must explicitly override it (Stablecoin returns pure 6, Security reads its slot) — also clears the prior interface-coverage orphan on the base. Pins MockB20SecurityStorage.decimals to slot 0 ahead of sharesToTokensRatio so future small variant-immutable scalars can pack into its remaining 31 bytes without shifting downstream offsets; updates the layout test to match. Reorders the new decimals tests so reverts precede successes per house style, and drops the now-misnamed test_createB20_success_decimalsFixedByVariant (its assertions are covered by createB20_security_decimals.t.sol). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/interfaces/IB20Factory.sol | 2 +- src/lib/B20FactoryLib.sol | 4 +- test/lib/mocks/MockB20.sol | 16 +++--- test/lib/mocks/MockB20Storage.sol | 42 +++++++------- .../createB20_security_decimals.t.sol | 56 +++++++++---------- test/unit/B20Factory/createToken.t.sol | 25 +-------- .../encodeSecurityCreateParams.t.sol | 4 +- test/unit/storage/B20SecurityFullLayout.t.sol | 22 ++++---- 8 files changed, 75 insertions(+), 96 deletions(-) diff --git a/src/interfaces/IB20Factory.sol b/src/interfaces/IB20Factory.sol index 282d1b3..c7f7c52 100644 --- a/src/interfaces/IB20Factory.sol +++ b/src/interfaces/IB20Factory.sol @@ -36,7 +36,7 @@ interface IB20Factory { /// @notice Creation parameters for a Security-variant B-20 token. ABI-encoded into `params`. struct B20SecurityCreateParams { - /// @dev Encoding version. Currently `2`. + /// @dev Encoding version. Currently `1`. uint8 version; /// @dev ERC-20 token name. string name; diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index dbe2559..4537a34 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -15,9 +15,7 @@ library B20FactoryLib { uint8 internal constant B20_STABLECOIN_CREATE_PARAMS_VERSION = 1; /// @notice Encoding version carried as the leading byte of a `B20SecurityCreateParams` blob. - /// Bumped from `1` to `2` when the struct gained the `decimals` field - /// (previously hardcoded to `6` by the factory's security arm). - uint8 internal constant B20_SECURITY_CREATE_PARAMS_VERSION = 2; + uint8 internal constant B20_SECURITY_CREATE_PARAMS_VERSION = 1; /// @notice Encoding version carried as the leading byte of a `B20StablecoinEventParams` blob. uint8 internal constant B20_STABLECOIN_EVENT_PARAMS_VERSION = 1; diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index ff9207a..5d12613 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -161,14 +161,14 @@ abstract contract MockB20 is IB20 { return MockB20Storage.layout().symbol; } - /// @notice Default-variant decimals are fixed at 18. The security variant - /// overrides this to read its per-token configured decimals from - /// `MockB20SecurityStorage`, which requires `view` (not `pure`) - /// mutability — so the base declares `view` to keep the override - /// legal under Solidity's tightening rules. - function decimals() external view virtual returns (uint8) { - return 18; - } + /// @notice ERC-20 `decimals`. Unimplemented at the base; each variant + /// must provide its own override. `MockB20Stablecoin.decimals()` + /// returns `pure 6`; `MockB20Security.decimals()` returns the + /// per-token value stored at `MockB20SecurityStorage.decimalsSlot()` + /// (declared `view` for the storage read). Leaving the base + /// abstract forces new variants to make an explicit decision and + /// avoids accidentally inheriting a stale default. + function decimals() external view virtual returns (uint8); function totalSupply() external view returns (uint256) { return MockB20Storage.layout().totalSupply; diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index e220afb..dd3bb58 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -333,6 +333,14 @@ library MockB20Storage { /// is `keccak256(abi.encode(uint256(keccak256("base.b20.security")) - 1)) & ~bytes32(uint256(0xff))`. /// /// **Storage notes.** +/// - `decimals` is the per-token ERC-20 decimals value, chosen +/// at creation by the factory from `B20SecurityCreateParams` +/// and immutable thereafter. Validated to the range +/// `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]` +/// by the factory. Pinned to slot 0 (ahead of `multiplier`) +/// so future small variant-immutable scalars can pack into +/// this slot's remaining 31 bytes without shifting other +/// fields' offsets. /// - `multiplier` stores the WAD-scaled multiplier applied to /// raw balances. A stored value of `0` is interpreted by the /// read surface (`multiplier()`, `toScaledBalance(...)`, @@ -342,14 +350,6 @@ library MockB20Storage { /// default value during bootstrap. `updateMultiplier` writes /// the new multiplier verbatim; subsequent reads return the /// stored value as-is. -/// - `decimals` is the per-token ERC-20 decimals value, chosen -/// at creation by the factory from `B20SecurityCreateParams` -/// and immutable thereafter. Validated to the range -/// `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]` -/// by the factory. Solidity packs `uint8` into its own slot -/// (it does not back-pack with the preceding `uint256`); -/// future small variant-immutable fields can pack into the -/// remaining 31 bytes of this slot. /// - `usedAnnouncementIds` keys directly on the raw `string id` /// that callers pass to `announce` / `isAnnouncementIdUsed`, /// not on a hash, so the on-chain query mirrors the API. @@ -358,16 +358,19 @@ library MockB20Storage { library MockB20SecurityStorage { /// @custom:storage-location erc7201:base.b20.security struct Layout { - // ---------- Multiplier ---------- - // Scaled by WAD_PRECISION (1e18). Stored value of 0 is - // interpreted as WAD by the read surface. - uint256 multiplier; // ---------- Decimals ---------- // Per-token ERC-20 `decimals`. Written by the factory at // creation from the `B20SecurityCreateParams.decimals` field // (validated to [MIN_ASSET_DECIMALS, MAX_ASSET_DECIMALS]) and - // never mutated afterwards. + // never mutated afterwards. Pinned to slot 0 so future + // small variant-immutable scalars can pack into the same + // slot's remaining 31 bytes without disturbing offsets of + // later fields. uint8 decimals; + // ---------- Multiplier ---------- + // Scaled by WAD_PRECISION (1e18). Stored value of 0 is + // interpreted as WAD by the read surface. + uint256 multiplier; // ---------- Announcements ---------- // Tracks consumed announcement IDs; flips to true on first // `announce` for a given id, and remains true forever. @@ -389,12 +392,11 @@ library MockB20SecurityStorage { // unrelated locations); the factory uses these as base slots when // deriving member slots via `keccak256(abi.encode(key, baseSlot))`. - uint256 internal constant MULTIPLIER_OFFSET = 0; - // `decimals` (a `uint8`) cannot back-pack with the preceding - // `uint256`, so Solidity allocates it a fresh slot. It occupies - // only the low byte; the remaining 31 bytes are reserved for - // future small variant-immutable fields. - uint256 internal constant DECIMALS_OFFSET = 1; + // `decimals` (a `uint8`) sits alone in slot 0; the remaining 31 + // bytes are reserved for future small variant-immutable fields + // that can pack into the same slot. + uint256 internal constant DECIMALS_OFFSET = 0; + uint256 internal constant MULTIPLIER_OFFSET = 1; uint256 internal constant USED_ANNOUNCEMENT_IDS_OFFSET = 2; uint256 internal constant IDENTIFIERS_OFFSET = 3; @@ -421,8 +423,8 @@ library MockB20SecurityStorage { // ============================================================ // forgefmt: disable-start - function multiplierSlot() internal pure returns (bytes32) { return slotOf(MULTIPLIER_OFFSET); } function decimalsSlot() internal pure returns (bytes32) { return slotOf(DECIMALS_OFFSET); } + function multiplierSlot() internal pure returns (bytes32) { return slotOf(MULTIPLIER_OFFSET); } function usedAnnouncementIdsBaseSlot() internal pure returns (bytes32) { return slotOf(USED_ANNOUNCEMENT_IDS_OFFSET); } function identifiersBaseSlot() internal pure returns (bytes32) { return slotOf(IDENTIFIERS_OFFSET); } diff --git a/test/unit/B20Factory/createB20_security_decimals.t.sol b/test/unit/B20Factory/createB20_security_decimals.t.sol index bded26d..53d8604 100644 --- a/test/unit/B20Factory/createB20_security_decimals.t.sol +++ b/test/unit/B20Factory/createB20_security_decimals.t.sol @@ -21,34 +21,6 @@ import {B20FactoryTest} from "test/lib/B20FactoryTest.sol"; /// the `MockB20SecurityStorage.decimalsSlot()` paired-slot pattern, /// and the stablecoin variant's regression-free hardcoded `6`. contract B20FactoryCreateB20SecurityDecimalsTest is B20FactoryTest { - /*////////////////////////////////////////////////////////////// - BOUNDARY SUCCESSES - //////////////////////////////////////////////////////////////*/ - - /// @notice Verifies the lower-bound boundary value `MIN_ASSET_DECIMALS` (`6`) succeeds - /// and the deployed token reports it from `decimals()`. - function test_createB20_success_decimals_lowerBound(address caller, bytes32 salt) public { - _assumeValidCaller(caller); - IB20Factory.B20SecurityCreateParams memory p = - _securityParams("Security Test", "SEC", admin, B20Constants.MIN_ASSET_DECIMALS); - address token = _createSecurity(caller, salt, p, new bytes[](0)); - assertEq( - MockB20(token).decimals(), B20Constants.MIN_ASSET_DECIMALS, "decimals() must equal MIN_ASSET_DECIMALS (6)" - ); - } - - /// @notice Verifies the upper-bound boundary value `MAX_ASSET_DECIMALS` (`18`) succeeds - /// and the deployed token reports it from `decimals()`. - function test_createB20_success_decimals_upperBound(address caller, bytes32 salt) public { - _assumeValidCaller(caller); - IB20Factory.B20SecurityCreateParams memory p = - _securityParams("Security Test", "SEC", admin, B20Constants.MAX_ASSET_DECIMALS); - address token = _createSecurity(caller, salt, p, new bytes[](0)); - assertEq( - MockB20(token).decimals(), B20Constants.MAX_ASSET_DECIMALS, "decimals() must equal MAX_ASSET_DECIMALS (18)" - ); - } - /*////////////////////////////////////////////////////////////// OUT-OF-RANGE REVERTS //////////////////////////////////////////////////////////////*/ @@ -96,6 +68,34 @@ contract B20FactoryCreateB20SecurityDecimalsTest is B20FactoryTest { factory.createB20(IB20Factory.B20Variant.SECURITY, salt, abi.encode(p), new bytes[](0)); } + /*////////////////////////////////////////////////////////////// + BOUNDARY SUCCESSES + //////////////////////////////////////////////////////////////*/ + + /// @notice Verifies the lower-bound boundary value `MIN_ASSET_DECIMALS` (`6`) succeeds + /// and the deployed token reports it from `decimals()`. + function test_createB20_success_decimals_lowerBound(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + IB20Factory.B20SecurityCreateParams memory p = + _securityParams("Security Test", "SEC", admin, B20Constants.MIN_ASSET_DECIMALS); + address token = _createSecurity(caller, salt, p, new bytes[](0)); + assertEq( + MockB20(token).decimals(), B20Constants.MIN_ASSET_DECIMALS, "decimals() must equal MIN_ASSET_DECIMALS (6)" + ); + } + + /// @notice Verifies the upper-bound boundary value `MAX_ASSET_DECIMALS` (`18`) succeeds + /// and the deployed token reports it from `decimals()`. + function test_createB20_success_decimals_upperBound(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + IB20Factory.B20SecurityCreateParams memory p = + _securityParams("Security Test", "SEC", admin, B20Constants.MAX_ASSET_DECIMALS); + address token = _createSecurity(caller, salt, p, new bytes[](0)); + assertEq( + MockB20(token).decimals(), B20Constants.MAX_ASSET_DECIMALS, "decimals() must equal MAX_ASSET_DECIMALS (18)" + ); + } + /*////////////////////////////////////////////////////////////// IN-RANGE FUZZ //////////////////////////////////////////////////////////////*/ diff --git a/test/unit/B20Factory/createToken.t.sol b/test/unit/B20Factory/createToken.t.sol index 70a3254..adef05b 100644 --- a/test/unit/B20Factory/createToken.t.sol +++ b/test/unit/B20Factory/createToken.t.sol @@ -107,9 +107,8 @@ contract B20FactoryCreateB20Test is B20FactoryTest { /// @notice Verifies createToken reverts for any unsupported version byte on the SECURITY variant /// @dev Each variant arm has its own version check; this exercises the security arm's check. - /// The current supported version is `B20_SECURITY_CREATE_PARAMS_VERSION` (bumped from - /// `1` to `2` when the struct gained the `decimals` field) — fuzzing assumes the - /// version byte is anything else. + /// The current supported version is `B20_SECURITY_CREATE_PARAMS_VERSION` — fuzzing + /// assumes the version byte is anything else. function test_createB20_revert_unsupportedVersion_security(address caller, uint8 badVersion, bytes32 salt) public { _assumeValidCaller(caller); vm.assume(badVersion != B20FactoryLib.B20_SECURITY_CREATE_PARAMS_VERSION); @@ -685,24 +684,4 @@ contract B20FactoryCreateB20Test is B20FactoryTest { vm.load(tokenAddr, MockB20Storage.symbolSlot()), bytes32(0), "empty symbol field slot must be all-zero" ); } - - /// @notice Verifies stablecoin decimals are hardcoded at `6` and security decimals - /// reflect the value passed at creation (rather than coming from address bytes). - /// @dev Stablecoin decimals are immutable and not configurable. Security decimals - /// are per-token (see `B20SecurityCreateParams.decimals`); the default - /// `_securityParams()` helper passes `MIN_ASSET_DECIMALS` (`6`) so this test - /// still pins the legacy-behavior case. Full range coverage lives in - /// `createB20_security_decimals.t.sol`. - function test_createB20_success_decimalsFixedByVariant(address caller, bytes32 salt) public { - _assumeValidCaller(caller); - address stablecoinToken = _createStablecoin(caller, salt, _stablecoinParams(), new bytes[](0)); - address securityToken = _createSecurity(caller, salt, _securityParams(), new bytes[](0)); - - assertEq(MockB20(stablecoinToken).decimals(), 6, "stablecoin decimals must be hardcoded at 6"); - assertEq( - MockB20(securityToken).decimals(), - B20Constants.MIN_ASSET_DECIMALS, - "security decimals must reflect default _securityParams (MIN_ASSET_DECIMALS)" - ); - } } diff --git a/test/unit/B20FactoryLib/encodeSecurityCreateParams.t.sol b/test/unit/B20FactoryLib/encodeSecurityCreateParams.t.sol index c095874..b8c3702 100644 --- a/test/unit/B20FactoryLib/encodeSecurityCreateParams.t.sol +++ b/test/unit/B20FactoryLib/encodeSecurityCreateParams.t.sol @@ -54,9 +54,9 @@ contract B20FactoryLibEncodeSecurityCreateParamsTest is B20FactoryLibTest { assertEq(actual, expected, "encoded blob must match hand-encoded struct byte-for-byte"); } - /// @notice Verifies the library's current security create-params version is `2`. + /// @notice Verifies the library's current security create-params version is `1`. /// @dev Pins the constant so a future bump is intentional and visible in diff. function test_b20SecurityCreateParamsVersion_pinned() public pure { - assertEq(uint256(B20FactoryLib.B20_SECURITY_CREATE_PARAMS_VERSION), 2, "version must be 2"); + assertEq(uint256(B20FactoryLib.B20_SECURITY_CREATE_PARAMS_VERSION), 1, "version must be 1"); } } diff --git a/test/unit/storage/B20SecurityFullLayout.t.sol b/test/unit/storage/B20SecurityFullLayout.t.sol index b13699f..819d1d0 100644 --- a/test/unit/storage/B20SecurityFullLayout.t.sol +++ b/test/unit/storage/B20SecurityFullLayout.t.sol @@ -30,8 +30,8 @@ contract B20SecurityFullLayoutTest is B20SecurityTest { /// @notice Cross-cuts every field of the security-variant namespace in /// one populated snapshot. /// @dev Field coverage for `base.b20.security` (slots 0..3): - /// - 0: multiplier - /// - 1: decimals (factory-written at creation) + /// - 0: decimals (factory-written at creation) + /// - 1: multiplier /// - 2: usedAnnouncementIds[id] /// - 3: identifiers[identifierType] (FIGI mutated) function test_b20SecurityLayout_success_populatedSnapshotMatchesAllSlots() public { @@ -40,20 +40,20 @@ contract B20SecurityFullLayoutTest is B20SecurityTest { address tokenAddr = address(token); - // ---------- multiplier (slot 0) ---------- - assertEq( - uint256(vm.load(tokenAddr, MockB20SecurityStorage.multiplierSlot())), - MULTIPLIER_MARKER, - "security slot 0: multiplier must hold the written value" - ); - - // ---------- decimals (slot 1) ---------- + // ---------- decimals (slot 0) ---------- // The default `_securityParams()` helper passes MIN_ASSET_DECIMALS (6), // and the factory writes that as a one-word `uint256` (low byte). assertEq( uint256(vm.load(tokenAddr, MockB20SecurityStorage.decimalsSlot())), uint256(B20Constants.MIN_ASSET_DECIMALS), - "security slot 1: decimals must hold the factory-written value" + "security slot 0: decimals must hold the factory-written value" + ); + + // ---------- multiplier (slot 1) ---------- + assertEq( + uint256(vm.load(tokenAddr, MockB20SecurityStorage.multiplierSlot())), + MULTIPLIER_MARKER, + "security slot 1: multiplier must hold the written value" ); // ---------- usedAnnouncementIds[id] (slot 2, hashed by id) ---------- From b99bad32c25e7901fd82355b9e7d8fa8c2858a95 Mon Sep 17 00:00:00 2001 From: Conner Swenberg Date: Mon, 1 Jun 2026 22:56:53 -0400 Subject: [PATCH 3/3] docs(MockB20SecurityStorage): remove unnecessary comment (review feedback) Drops a redundant inline comment per stevieraykatz review on PR #125. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/lib/mocks/MockB20Storage.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index dd3bb58..bc886cb 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -392,9 +392,6 @@ library MockB20SecurityStorage { // unrelated locations); the factory uses these as base slots when // deriving member slots via `keccak256(abi.encode(key, baseSlot))`. - // `decimals` (a `uint8`) sits alone in slot 0; the remaining 31 - // bytes are reserved for future small variant-immutable fields - // that can pack into the same slot. uint256 internal constant DECIMALS_OFFSET = 0; uint256 internal constant MULTIPLIER_OFFSET = 1; uint256 internal constant USED_ANNOUNCEMENT_IDS_OFFSET = 2;