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
52 changes: 51 additions & 1 deletion src/interfaces/IB20Factory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
//////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/lib/B20FactoryLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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
//////////////////////////////////////////////////////////////*/
Expand Down
15 changes: 14 additions & 1 deletion test/lib/mocks/MockB20Factory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
97 changes: 93 additions & 4 deletions test/unit/B20Factory/createToken.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions test/unit/B20FactoryLib/encodeStablecoinEventParams.t.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading