Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/B20/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
6 changes: 4 additions & 2 deletions docs/B20/Security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
16 changes: 15 additions & 1 deletion src/interfaces/IB20Factory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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.
///
Expand Down
10 changes: 10 additions & 0 deletions src/lib/B20Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
10 changes: 8 additions & 2 deletions src/lib/B20FactoryLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,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
})
);
}
Expand Down
31 changes: 27 additions & 4 deletions test/lib/B20FactoryTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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_
});
}

Expand All @@ -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);
}
Expand Down
12 changes: 8 additions & 4 deletions test/lib/mocks/MockB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,14 @@ abstract contract MockB20 is IB20 {
return MockB20Storage.layout().symbol;
}

/// @notice Default-variant decimals are fixed at 18.
function decimals() external pure 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;
Expand Down
27 changes: 24 additions & 3 deletions test/lib/mocks/MockB20Factory.sol

@ilikesymmetry ilikesymmetry Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also add new uint8 decimals to B20Created event and hardcode to 6 for stablecoin variant

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already in place. The B20Created event was updated in this PR to include uint8 decimals (see src/interfaces/IB20Factory.sol line 113), and MockB20Factory.createB20 hardcodes decimals = 6 on the stablecoin branch (line 147) and threads it through the emit (line 192). Stablecoin decimals coverage is pinned in test/unit/B20Factory/createB20_security_decimals.t.sol::test_createB20_success_stablecoin_decimalsStillHardcoded. Let me know if you wanted something more — happy to add a dedicated event-pin test for the stablecoin path if useful.

Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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_);
}

Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions test/lib/mocks/MockB20Security.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

// ============================================================
Expand Down
25 changes: 22 additions & 3 deletions test/lib/mocks/MockB20Storage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)`,
Expand All @@ -350,6 +358,15 @@ library MockB20Storage {
library MockB20SecurityStorage {
/// @custom:storage-location erc7201:base.b20.security
struct Layout {
// ---------- 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. 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;
Comment on lines +361 to +369

@ilikesymmetry ilikesymmetry Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's actually make decimals the first offset in the struct (above share ratio) and move everything down one versus inserting in the middle

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4ff6636decimals is now the first field of MockB20SecurityStorage.Layout (slot 0), with sharesToTokensRatio moved to slot 1 and the offset constants / natspec updated to match. Also reordered the assertions in test/unit/storage/B20SecurityFullLayout.t.sol and updated its slot-coverage comment block. USED_ANNOUNCEMENT_IDS_OFFSET and IDENTIFIERS_OFFSET stayed at 2 / 3 (mappings re-derive, not a layout break).

// ---------- Multiplier ----------
// Scaled by WAD_PRECISION (1e18). Stored value of 0 is
// interpreted as WAD by the read surface.
Expand All @@ -375,9 +392,10 @@ 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;
uint256 internal constant USED_ANNOUNCEMENT_IDS_OFFSET = 1;
uint256 internal constant IDENTIFIERS_OFFSET = 2;
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;

/// @notice Absolute slot for a top-level field of `Layout`.
function slotOf(uint256 offset) internal pure returns (bytes32) {
Expand All @@ -402,6 +420,7 @@ library MockB20SecurityStorage {
// ============================================================

// forgefmt: disable-start
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); }
Expand Down
Loading
Loading