diff --git a/src/interfaces/IB20Factory.sol b/src/interfaces/IB20Factory.sol index ff031318..31cce00c 100644 --- a/src/interfaces/IB20Factory.sol +++ b/src/interfaces/IB20Factory.sol @@ -162,6 +162,37 @@ interface IB20Factory { uint256 minimumRedeemable; } + /// @notice Event payload carried in the `variantEventParams` field of + /// `B20Created` for STABLECOIN-variant tokens. ABI-encoded + /// and emitted with a leading `version` byte so stream-based + /// indexers can decode by `(variant, version)` without an + /// RPC call to read storage. + /// @param version Event-encoding version. Currently `1`. + /// Independent of `B20StablecoinCreateParams.version`; + /// the event payload schema can evolve separately + /// from the create-call payload schema. + /// @param currency The immutable stablecoin currency identifier, same + /// value that was passed in + /// `B20StablecoinCreateParams.currency`. Surfaced here + /// because `currency` has no setter and no other + /// event ever emits it; indexers that cannot make + /// mid-handler view calls would + /// otherwise have no way to recover it from the + /// event stream. + /// @dev DEFAULT and SECURITY variants currently emit an empty + /// `variantEventParams` byte string: DEFAULT has no extra + /// immutable identity fields beyond what's already in + /// `B20Created`, and SECURITY's `isin` and + /// `minimumRedeemable` are mutable and surfaced via their + /// own `SecurityIdentifierUpdated` / + /// `MinimumRedeemableUpdated` events. Either variant can + /// promote from empty to a non-empty payload in a future + /// version without re-issuing a new event type. + struct B20StablecoinEventParams { + uint8 version; + string currency; + } + /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -211,7 +242,26 @@ interface IB20Factory { /// `B20Created` is the token-identity signal only. The /// "demonstrate no owner" path (`initialAdmin == address(0)`) /// skips the grant and emits no `RoleGranted` at bootstrap. - event B20Created(address indexed token, B20Variant indexed variant, string name, string symbol, uint8 decimals); + /// @param token Address of the newly-created token. + /// @param variant Which `B20Variant` was created. + /// @param name ERC-20 token name. + /// @param symbol ERC-20 token symbol. + /// @param decimals ERC-20 decimals (fixed per variant). + /// @param variantEventParams ABI-encoded variant-specific immutable identity + /// fields, leading with a version byte. Empty + /// (`""`) for DEFAULT and SECURITY (no extra + /// immutable fields not already covered); for + /// STABLECOIN, carries `abi.encode(B20StablecoinEventParams)` + /// with `currency`. Indexers decode by + /// `(variant, leading version byte)`. + event B20Created( + address indexed token, + B20Variant indexed variant, + string name, + string symbol, + uint8 decimals, + bytes variantEventParams + ); /*////////////////////////////////////////////////////////////// CREATE diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index c1b06437..d2e6e7ab 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -60,6 +60,14 @@ library B20FactoryLib { /// Independent of the other variants' versions. uint8 internal constant B20_SECURITY_CREATE_PARAMS_VERSION = 1; + /// @notice Current encoding version for `B20StablecoinEventParams`, + /// the payload carried in the `variantEventParams` field of the + /// `B20Created` event for STABLECOIN-variant tokens. + /// Independent of `B20_STABLECOIN_CREATE_PARAMS_VERSION` — + /// the event payload schema can evolve separately from the + /// create-call payload schema. + uint8 internal constant B20_STABLECOIN_EVENT_PARAMS_VERSION = 1; + /// @notice Two parallel arrays passed to a `build*` helper had /// different lengths. /// @@ -217,6 +225,24 @@ library B20FactoryLib { ); } + /// @notice Encodes a `B20StablecoinEventParams` as the `variantEventParams` + /// blob the factory emits in the `B20Created` event when + /// `variant == B20Variant.STABLECOIN`. The leading byte is + /// `B20_STABLECOIN_EVENT_PARAMS_VERSION`. Indexers decode + /// this blob by `(variant, leading version byte)` to recover + /// the immutable `currency` without an RPC call. + /// + /// @param currency ISO 4217 fiat code this stablecoin tracks; same + /// value passed to `encodeStablecoinCreateParams` + /// at creation time. + /// + /// @return The ABI-encoded `B20StablecoinEventParams` blob. + function encodeStablecoinEventParams(string memory currency) internal pure returns (bytes memory) { + return abi.encode( + IB20Factory.B20StablecoinEventParams({version: B20_STABLECOIN_EVENT_PARAMS_VERSION, currency: currency}) + ); + } + /*////////////////////////////////////////////////////////////// INIT-CALL SETTER ENCODERS //////////////////////////////////////////////////////////////*/ diff --git a/test/lib/mocks/MockB20Factory.sol b/test/lib/mocks/MockB20Factory.sol index d11becda..01817658 100644 --- a/test/lib/mocks/MockB20Factory.sol +++ b/test/lib/mocks/MockB20Factory.sol @@ -177,7 +177,20 @@ contract MockB20Factory is IB20Factory { // -- 6. Emit B20Created. Identity-only signal; admin role // assignment is announced via the standard RoleGranted // event from step 7. - emit B20Created(token, variant, name_, symbol_, decimals); + // + // The `variantEventParams` field carries variant-specific + // immutable identity that isn't already covered by the + // fixed event fields. STABLECOIN emits an ABI-encoded + // `B20StablecoinEventParams` so stream-based indexers + // can recover the immutable `currency` + // without an RPC call. DEFAULT and SECURITY emit empty + // bytes (SECURITY's `isin` / `minimumRedeemable` are + // mutable and surfaced via their own update events). + bytes memory variantEventParams; + if (variant == B20Variant.STABLECOIN) { + variantEventParams = B20FactoryLib.encodeStablecoinEventParams(currency_); + } + emit B20Created(token, variant, name_, symbol_, decimals, variantEventParams); // -- 7. Grant the initial admin role via the canonical path. // msg.sender at the token is address(this) == factory, diff --git a/test/unit/B20Factory/createToken.t.sol b/test/unit/B20Factory/createToken.t.sol index c8ef5fa1..7cdc4a0e 100644 --- a/test/unit/B20Factory/createToken.t.sol +++ b/test/unit/B20Factory/createToken.t.sol @@ -7,6 +7,7 @@ import {IB20} from "src/interfaces/IB20.sol"; import {IB20Stablecoin} from "src/interfaces/IB20Stablecoin.sol"; import {IB20Security} from "src/interfaces/IB20Security.sol"; import {IB20Factory} from "src/interfaces/IB20Factory.sol"; +import {B20FactoryLib} from "src/lib/B20FactoryLib.sol"; import {MockB20, B20Constants} from "test/lib/mocks/MockB20.sol"; import {MockB20Security} from "test/lib/mocks/MockB20Security.sol"; @@ -118,6 +119,18 @@ contract B20FactoryCreateB20Test is B20FactoryTest { return true; } + /// @dev Deterministic seed → 3-letter uppercase ASCII fiat code, guaranteed + /// to pass `_isValidFiatCode`. Use in success-path fuzz tests instead of + /// `vm.assume(_isValidFiatCode(...))` over a raw `string` input, which + /// would hit foundry's rejection-rate ceiling. + function _make3LetterUppercase(uint256 seed) private pure returns (string memory) { + bytes memory b = new bytes(3); + b[0] = bytes1(uint8(0x41 + (seed % 26))); + b[1] = bytes1(uint8(0x41 + ((seed >> 8) % 26))); + b[2] = bytes1(uint8(0x41 + ((seed >> 16) % 26))); + return string(b); + } + /// @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. function test_createB20_revert_unsupportedVersion_security(address caller, uint8 badVersion, bytes32 salt) public { @@ -461,30 +474,106 @@ contract B20FactoryCreateB20Test is B20FactoryTest { /// @notice Verifies createToken emits B20Created 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; - /// see test_createB20_success_emitsRoleGrantedForInitialAdmin for that. + /// see test_createB20_success_emitsRoleGrantedForInitialAdmin for that. DEFAULT variant + /// emits empty `variantEventParams` (no extra immutable identity fields beyond what's already + /// in the event). function test_createB20_success_emitsB20Created(address caller, bytes32 salt) public { _assumeValidCaller(caller); IB20Factory.B20CreateParams memory p = _b20Params("MyToken", "MYT", admin); address predicted = factory.getB20Address(IB20Factory.B20Variant.DEFAULT, caller, salt); vm.expectEmit(true, true, false, true, address(factory)); - emit IB20Factory.B20Created(predicted, IB20Factory.B20Variant.DEFAULT, "MyToken", "MYT", 18); + emit IB20Factory.B20Created(predicted, IB20Factory.B20Variant.DEFAULT, "MyToken", "MYT", 18, bytes("")); _createDefault(caller, salt, p, new bytes[](0)); } /// @notice Verifies createToken emits B20Created with decimals=6 for the security variant /// @dev Variant-specific dedicated event test: the security arm pins decimals=6 the same - /// way the default emitter test pins decimals=18. + /// way the default emitter test pins decimals=18. SECURITY variant emits empty + /// `variantEventParams` (its `isin` and `minimumRedeemable` are mutable and surfaced via + /// their own update events). function test_createB20_success_emitsB20Created_security(address caller, bytes32 salt) public { _assumeValidCaller(caller); IB20Factory.B20SecurityCreateParams memory p = _securityParams("Security Test", "SEC", admin, DEFAULT_ISIN, 0); 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", 6); + emit IB20Factory.B20Created(predicted, IB20Factory.B20Variant.SECURITY, "Security Test", "SEC", 6, bytes("")); _createSecurity(caller, salt, p, new bytes[](0)); } + /// @notice Verifies createToken emits B20Created with decimals=6 and a non-empty + /// `variantEventParams` payload for the stablecoin variant + /// @dev STABLECOIN-only behavior: the `variantEventParams` field carries + /// `abi.encode(B20StablecoinEventParams { version, currency })` so stream-based + /// indexers can recover the immutable `currency` without an RPC + /// call to `currency()`. + function test_createB20_success_emitsB20Created_stablecoin(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + string memory currency = "USD"; + IB20Factory.B20StablecoinCreateParams memory p = _stablecoinParams("USD Stable", "USDS", admin, currency); + address predicted = factory.getB20Address(IB20Factory.B20Variant.STABLECOIN, caller, salt); + + bytes memory expectedVariantParams = abi.encode( + IB20Factory.B20StablecoinEventParams({ + version: B20FactoryLib.B20_STABLECOIN_EVENT_PARAMS_VERSION, currency: currency + }) + ); + + vm.expectEmit(true, true, false, true, address(factory)); + emit IB20Factory.B20Created( + predicted, IB20Factory.B20Variant.STABLECOIN, "USD Stable", "USDS", 6, expectedVariantParams + ); + _createStablecoin(caller, salt, p, new bytes[](0)); + } + + /// @notice Verifies the `variantEventParams` payload of `B20Created` for STABLECOIN decodes + /// back to a `B20StablecoinEventParams` whose `currency` round-trips the value + /// passed at creation and whose `version` matches the canonical event-encoding + /// version constant. + /// @dev Decode-level pin (complementary to `_emitsB20Created_stablecoin`'s + /// expectEmit-level pin). Catches a future regression that emits a payload with + /// the right SHAPE but wrong VERSION or CONTENT — for example, accidentally + /// re-using `B20_STABLECOIN_CREATE_PARAMS_VERSION` instead of + /// `B20_STABLECOIN_EVENT_PARAMS_VERSION`, or echoing the `name` field instead of + /// the `currency` field. Recorded-logs replay so the assertion runs against the + /// exact bytes the factory actually emitted, not a re-computed expectation. + function test_createB20_success_b20CreatedVariantParams_stablecoin_decodes( + address caller, + bytes32 salt, + uint256 currencySeed + ) public { + _assumeValidCaller(caller); + // Generate a guaranteed-valid 3-letter uppercase fiat code from the fuzz seed. + // Avoids vm.assume rejection-rate exhaustion that filtering random strings would hit. + string memory currency = _make3LetterUppercase(currencySeed); + IB20Factory.B20StablecoinCreateParams memory p = _stablecoinParams("Stable", "STB", admin, currency); + + vm.recordLogs(); + _createStablecoin(caller, salt, p, new bytes[](0)); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 selector = IB20Factory.B20Created.selector; + bytes memory variantEventParams; + 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). + (,,, variantEventParams) = abi.decode(logs[i].data, (string, string, uint8, bytes)); + break; + } + } + assertGt(variantEventParams.length, 0, "STABLECOIN variantEventParams must be non-empty"); + + IB20Factory.B20StablecoinEventParams memory decoded = + abi.decode(variantEventParams, (IB20Factory.B20StablecoinEventParams)); + assertEq( + decoded.version, + B20FactoryLib.B20_STABLECOIN_EVENT_PARAMS_VERSION, + "decoded version must equal B20_STABLECOIN_EVENT_PARAMS_VERSION" + ); + assertEq(decoded.currency, currency, "decoded currency must round-trip the value passed at creation"); + } + /// @notice Verifies createToken executes each entry in initCalls during the bootstrap window /// @dev The bootstrap-window auth bypass is bound to the call site (msg.sender == factory && !initialized), /// not to RBAC. The factory is never granted any role on the token. We verify the bypass by passing diff --git a/test/unit/B20FactoryLib/encodeStablecoinEventParams.t.sol b/test/unit/B20FactoryLib/encodeStablecoinEventParams.t.sol new file mode 100644 index 00000000..08646c0f --- /dev/null +++ b/test/unit/B20FactoryLib/encodeStablecoinEventParams.t.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20FactoryLib} from "src/lib/B20FactoryLib.sol"; +import {IB20Factory} from "src/interfaces/IB20Factory.sol"; + +import {B20FactoryLibTest} from "test/lib/B20FactoryLibTest.sol"; + +contract B20FactoryLibEncodeStablecoinEventParamsTest is B20FactoryLibTest { + /// @notice Verifies the output decodes back to a `B20StablecoinEventParams` + /// with the caller's currency and the current event-encoding version byte. + /// @dev Round-trips through `abi.decode` to pin the wire format the + /// `B20Created` `variantEventParams` field carries for STABLECOIN. The + /// event-params version is independent of the create-params version, + /// so this test pins against `B20_STABLECOIN_EVENT_PARAMS_VERSION` + /// specifically (not `B20_STABLECOIN_CREATE_PARAMS_VERSION`). + function test_encodeStablecoinEventParams_success_roundTripsThroughDecode(string memory currency) public pure { + bytes memory blob = B20FactoryLib.encodeStablecoinEventParams(currency); + IB20Factory.B20StablecoinEventParams memory decoded = abi.decode(blob, (IB20Factory.B20StablecoinEventParams)); + + assertEq( + decoded.version, + B20FactoryLib.B20_STABLECOIN_EVENT_PARAMS_VERSION, + "version byte must match library constant" + ); + assertEq(decoded.currency, currency, "currency must round-trip"); + } + + /// @notice Verifies the encoded blob is byte-identical to a hand-encoded + /// `B20StablecoinEventParams` struct. + /// @dev Pins the encoding shape so future field reordering on the + /// struct is caught against an explicit reference. + function test_encodeStablecoinEventParams_success_matchesHandEncodedStruct(string memory currency) public pure { + bytes memory expected = abi.encode( + IB20Factory.B20StablecoinEventParams({ + version: B20FactoryLib.B20_STABLECOIN_EVENT_PARAMS_VERSION, currency: currency + }) + ); + bytes memory actual = B20FactoryLib.encodeStablecoinEventParams(currency); + assertEq(actual, expected, "encoded blob must match hand-encoded struct byte-for-byte"); + } +}