Skip to content
Open
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
6 changes: 5 additions & 1 deletion test/lib/mocks/MockB20Factory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,12 @@ contract MockB20Factory is IB20Factory {
if (p.version != B20FactoryLib.B20_STABLECOIN_CREATE_PARAMS_VERSION) {
revert UnsupportedVersion(p.version, variant);
}
// Format check: every byte must be an uppercase ASCII letter (A-Z).
// Empty currency must be rejected explicitly: the format-check loop below has
// no bytes to inspect on empty input and would vacuously succeed otherwise.
// Reverts InvalidCurrency("") to match the Rust precompile's selector.
bytes memory cb = bytes(p.currency);
if (cb.length == 0) revert InvalidCurrency(p.currency);
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.

I think the only question here is whether we want this to revert with "InvalidCurrency" which is what the rust code does, or "MissingRequiredField"

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.

Recommendation: stick with InvalidCurrency and match rust. more informative error.

// Format check: every byte must be an uppercase ASCII letter (A-Z).
for (uint256 i = 0; i < cb.length; ++i) {
if (cb[i] < 0x41 || cb[i] > 0x5A) revert InvalidCurrency(p.currency);
}
Expand Down
15 changes: 11 additions & 4 deletions test/lib/mocks/MockB20Security.sol
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ contract MockB20Security is MockB20, IB20Security {
if (accounts.length == 0) revert EmptyBatch();
if (_isPaused(PausableFeature.BURN)) revert ContractPaused(PausableFeature.BURN);
for (uint256 i = 0; i < accounts.length; i++) {
// Zero amounts are allowed per ERC-20 conventions (`transfer(0)` is valid);
// `_burnRaw` is a no-op for amount == 0 and emits `Transfer(account, 0, 0)`.
// Callers that want all-non-zero semantics can validate upstream.
_burnRaw(accounts[i], amounts[i]);
}
}
Expand Down Expand Up @@ -295,10 +298,14 @@ contract MockB20Security is MockB20, IB20Security {
ratio = _sharesToTokensRatio();
uint256 shares = (amount * ratio) / WAD_PRECISION;
uint256 minimum = $.minimumRedeemable;
// Always reject zero-share redemptions, even if the configured
// minimum is 0 — burning token dust that resolves to no shares
// is never the holder's intent.
if (shares == 0 || shares < minimum) revert BelowMinimumRedeemable(shares, minimum);
// Zero amounts are allowed per ERC-20 conventions (`transfer(0)` is valid).
// For amount > 0, reject dust burns that round to zero shares OR fall below the
// configured minimum — burning a positive amount that resolves to no shares is
// never the holder's intent. The `amount > 0` guard is what keeps explicit
// zero-amount redemptions from being absorbed by the dust path.
if (amount > 0 && (shares == 0 || shares < minimum)) {
revert BelowMinimumRedeemable(shares, minimum);
}
_burnRaw(msg.sender, amount);
}

Expand Down
6 changes: 4 additions & 2 deletions test/unit/B20/erc20/allowance.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ contract B20AllowanceTest is B20Test {
// transferFrom only consumes allowance when msg.sender != from (see MockB20._transferFrom);
// owner == spender skips the consumption path, so filter it out.
vm.assume(owner != spender);
// Bound spendAmount to <= allowanceAmount, both above zero so the transfer is meaningful.
// allowanceAmount > 0 keeps the allowance setup meaningful; spendAmount includes 0
// so the assertion (allowance decreases by spendAmount) is exercised across the full
// valid input domain, including the no-op zero-spend case.
allowanceAmount = bound(allowanceAmount, 1, type(uint128).max);
spendAmount = bound(spendAmount, 1, allowanceAmount);
spendAmount = bound(spendAmount, 0, allowanceAmount);

_mint(owner, spendAmount);
vm.prank(owner);
Expand Down
8 changes: 6 additions & 2 deletions test/unit/B20/erc20/transferFrom.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,9 @@ contract B20TransferFromTest is B20Test {
allowanceAmount = bound(allowanceAmount, 1, type(uint128).max);
// Cap below type(uint256).max so we exercise the consume path (not the infinite-allowance bypass).
vm.assume(allowanceAmount != type(uint256).max);
spendAmount = bound(spendAmount, 1, allowanceAmount);
// spendAmount includes 0 so the assertion (allowance decreases by spendAmount) is
// exercised across the full valid input domain, including the no-op zero-spend case.
spendAmount = bound(spendAmount, 0, allowanceAmount);

_mint(from, spendAmount);
vm.prank(from);
Expand Down Expand Up @@ -230,7 +232,9 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(to);
vm.assume(caller != from);
vm.assume(from != to);
amount = bound(amount, 1, type(uint128).max);
// Include amount = 0: the infinite-allowance invariant must hold across the full
// valid input domain, including the no-op zero-transfer case.
amount = bound(amount, 0, type(uint128).max);

_mint(from, amount);
vm.prank(from);
Expand Down
4 changes: 3 additions & 1 deletion test/unit/B20/memo/transferFromWithMemo.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ contract B20TransferFromWithMemoTest is B20Test {
_assumeValidActor(to);
vm.assume(caller != from);
vm.assume(from != to);
amount = bound(amount, 1, type(uint128).max);
// Include amount = 0: the balance/allowance invariants must hold across the full
// valid input domain, including the no-op zero-transfer case.
amount = bound(amount, 0, type(uint128).max);

_mint(from, amount);
vm.prank(from);
Expand Down
22 changes: 22 additions & 0 deletions test/unit/B20/permit/permit.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,28 @@ contract B20PermitTest is B20Test {
token.permit(bob, spender, amount, deadline, v, r, s);
}

/// @notice Verifies permit reverts for malformed (v, r, s) that causes recovery to fail
/// @dev Backend-parity test for the recovered == address(0) path. EVM's `ecrecover` returns
/// `address(0)` on malformed signatures (invalid `v`, etc.); alloy in the Rust
/// precompile returns `Err` from `recover_address_from_prehash` on the same input,
/// which is mapped to `InvalidSigner(address(0), owner)`. Both backends therefore
/// revert with the same selector on a malformed signature, but via different code
/// paths. This test pins that parity so neither side can silently drift.
///
/// We pick v = 0 (only 27 and 28 are valid), so ecrecover deterministically returns
/// `address(0)` regardless of r and s.
function test_permit_revert_malformedSignature(address spender, uint256 amount) public {
vm.assume(spender != address(0));
uint256 deadline = type(uint256).max;
address owner = alice;
uint8 v = 0; // invalid: only 27 and 28 produce a valid recovery
bytes32 r = bytes32(uint256(1));
bytes32 s = bytes32(uint256(2));

vm.expectRevert(abi.encodeWithSelector(IB20.InvalidSigner.selector, address(0), owner));
token.permit(owner, spender, amount, deadline, v, r, s);
}

/// @notice Verifies permit reverts when the same signature is replayed
/// @dev Nonce monotonicity guards replay: after the first call advances the nonce,
/// the second call's digest is computed against the OLD nonce, so ecrecover
Expand Down
22 changes: 20 additions & 2 deletions test/unit/B20Factory/createToken.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ contract B20FactoryCreateB20Test is B20FactoryTest {

// STABLECOIN currency validation: every byte must be an uppercase ASCII letter (A-Z).

/// @notice Any string containing a non-`A`–`Z` byte reverts with `InvalidCurrency(code)`.
/// @notice Any non-empty string containing a non-`A`–`Z` byte reverts with `InvalidCurrency(code)`.
/// @dev Subsumes every point case (lowercase, digits, symbols, multi-byte UTF-8) via
/// `vm.assume(!_isValidFiatCode)`. Length is unconstrained.
/// `vm.assume(!_isValidFiatCode)`. Empty input is covered by
/// `test_createB20_revert_missingCurrency` (rejected with MissingRequiredField).
function test_createB20_revert_currency_rejectsInvalidFormat(string memory code, address caller, bytes32 salt)
public
{
_assumeValidCaller(caller);
vm.assume(bytes(code).length > 0);
vm.assume(!_isValidFiatCode(code));
IB20Factory.B20StablecoinCreateParams memory p = _stablecoinParams("Test", "TST", admin, code);
vm.prank(caller);
Expand All @@ -106,6 +108,10 @@ contract B20FactoryCreateB20Test is B20FactoryTest {

function _isValidFiatCode(string memory code) private pure returns (bool) {
bytes memory b = bytes(code);
// Empty is not a valid format. Returning true here would make every fuzz test
// that filters with `vm.assume(!_isValidFiatCode(code))` silently discard empty,
// hiding the missing-currency case from the suite.
if (b.length == 0) return false;
for (uint256 i = 0; i < b.length; ++i) {
if (b[i] < 0x41 || b[i] > 0x5A) return false;
}
Expand Down Expand Up @@ -136,6 +142,18 @@ contract B20FactoryCreateB20Test is B20FactoryTest {
factory.createB20(IB20Factory.B20Variant.SECURITY, salt, abi.encode(p), new bytes[](0));
}

/// @notice Verifies stablecoin createToken reverts when currency is the empty string
/// @dev The format-check loop on `currency` is vacuously safe on empty input (no bytes
/// to inspect), so an explicit length check is required to reject empty up front.
/// Checks InvalidCurrency("") error, matching the Rust precompile's selector.
function test_createB20_revert_emptyCurrency(address caller, bytes32 salt) public {
_assumeValidCaller(caller);
IB20Factory.B20StablecoinCreateParams memory p = _stablecoinParams("Stablecoin Test", "USD", admin, "");
vm.prank(caller);
vm.expectRevert(abi.encodeWithSelector(IB20Factory.InvalidCurrency.selector, ""));
factory.createB20(IB20Factory.B20Variant.STABLECOIN, salt, abi.encode(p), new bytes[](0));
}

/// @notice Verifies createToken reverts when (variant, sender, salt) collides
/// @dev Deterministic-address uniqueness; checks TokenAlreadyExists(token) error
function test_createB20_revert_tokenAlreadyExists(address caller, bytes32 salt) public {
Expand Down
16 changes: 16 additions & 0 deletions test/unit/B20Factory/getTokenAddress.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,20 @@ contract B20FactoryGetTokenAddressTest is B20FactoryTest {
uint8 byteAt11 = uint8(uint160(a) >> 64);
assertEq(byteAt11, expectedByte11, "address byte [11] must come from tail entropy");
}

/// @notice Verifies getB20Address rejects raw variant bytes outside the B20Variant enum range
/// @dev Mirrors the createB20 raw-bytes test. B20Variant has no "NONE" sentinel; typed
/// callers cannot construct an out-of-range value, so this path is only reachable via
/// raw calldata. Solidity rejects via the ABI decoder with Panic(0x21); the Rust
/// precompile rejects with the typed `InvalidVariant()` revert. The observable
/// contract from a raw-bytes caller is "the call reverts" on both backends; the
/// specific selector differs because Solidity has no opportunity to run a function
/// body before the decoder rejects.
function test_getB20Address_revert_outOfRangeVariant(address sender, bytes32 salt, uint8 badVariant) public {
badVariant = uint8(bound(uint256(badVariant), uint256(type(IB20Factory.B20Variant).max) + 1, 255));
vm.expectRevert();
(bool ok,) =
address(factory).call(abi.encodeWithSelector(IB20Factory.getB20Address.selector, badVariant, sender, salt));
ok; // silence unused warning; the revert is asserted via vm.expectRevert.
}
}
25 changes: 25 additions & 0 deletions test/unit/B20Security/batch/batchBurn.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,31 @@ contract B20SecurityBatchBurnTest is B20SecurityTest {
security().batchBurn(_singletonAddresses(alice), _singletonUints(amount));
}

/// @notice Verifies batchBurn treats zero-amount elements as valid no-ops
/// @dev Mirrors ERC-20 conventions (transfer(0) is valid). A zero in any slot is a no-op:
/// the balance for that element is unchanged, and `_burnRaw` emits the canonical
/// `Transfer(account, 0, 0)` for it. Non-zero elements in the same batch are processed
/// normally. The "all-or-nothing for non-zero failures" invariant is preserved by the
/// InsufficientBalance check inside `_burnRaw`.
function test_batchBurn_success_zeroAmountElementsAreNoOps() public {
_grantBurnFrom();
_mint(alice, 100);
_mint(bob, 100);

address[] memory accounts = new address[](2);
accounts[0] = alice;
accounts[1] = bob;
uint256[] memory amounts = new uint256[](2);
amounts[0] = 50;
amounts[1] = 0; // zero is a valid no-op per ERC-20 conventions

vm.prank(burnFromActor);
security().batchBurn(accounts, amounts);

assertEq(token.balanceOf(alice), 50, "alice debited by 50");
assertEq(token.balanceOf(bob), 100, "bob balance unchanged by zero-amount element");
}

/// @notice Verifies batchBurn surfaces per-element InsufficientBalance reverts
/// @dev Per-element invariant: `_burnRaw` reverts InsufficientBalance(account, bal, amount)
/// if any element exceeds the account's balance. All-or-nothing atomicity.
Expand Down
16 changes: 16 additions & 0 deletions test/unit/B20Security/redeem/redeem.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ contract B20SecurityRedeemTest is B20SecurityTest {
security().redeem(amount);
}

/// @notice Verifies redeem treats amount == 0 as a valid no-op
/// @dev Mirrors ERC-20 conventions (transfer(0) is valid). The dust-prevention guard
/// (`shares == 0 || shares < minimum`) only fires for amount > 0, so an explicit
/// zero-amount redemption succeeds without burning or reverting. Emits the canonical
/// `Transfer(caller, 0, 0)` (from `_burnRaw`) followed by `Redeemed(caller, 0, ratio)`.
function test_redeem_success_zeroAmountIsNoOp() public {
uint256 balanceBefore = token.balanceOf(alice);
uint256 supplyBefore = token.totalSupply();

vm.prank(alice);
security().redeem(0);

assertEq(token.balanceOf(alice), balanceBefore, "balance unchanged by zero-amount redeem");
assertEq(token.totalSupply(), supplyBefore, "totalSupply unchanged by zero-amount redeem");
}

/// @notice Verifies redeem reverts when the resulting share count is below the configured floor
/// @dev Error message reports the computed shares and the configured minimum. Test sets the
/// floor above the requested share count and checks BelowMinimumRedeemable(shares, minimum).
Expand Down
15 changes: 15 additions & 0 deletions test/unit/B20Security/shareRatio/sharesOf.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,19 @@ contract B20SecuritySharesOfTest is B20SecurityTest {
"sharesOf must apply balance * ratio / WAD"
);
}

/// @notice Verifies sharesOf applies the WAD fallback when the stored ratio is explicitly zero
/// @dev A stored `sharesToTokensRatio` of zero is documented to resolve as `WAD_PRECISION` on
/// the read surface, both pre-write (fresh slot) and post-explicit-zero-write. Tests the
/// latter at the derived-function level so a refactor that reads the slot directly here
/// would fail. See test_sharesToTokensRatio_success_zeroRestoresWadFallback for the
/// base-level fallback assertion.
function test_sharesOf_success_explicitZeroRatioFallsBackToWad(address account, uint256 amount) public {
_assumeValidActor(account);
amount = bound(amount, 0, type(uint128).max);
if (amount > 0) _mint(account, amount);
_updateShareRatio(5e18); // seed a non-zero value first
_updateShareRatio(0); // then explicitly clear back to zero
assertEq(security().sharesOf(account), amount, "stored zero ratio must produce identity (WAD fallback)");
}
}
13 changes: 13 additions & 0 deletions test/unit/B20Security/shareRatio/toShares.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,17 @@ contract B20SecurityToSharesTest is B20SecurityTest {
_updateShareRatio(ratio);
assertEq(security().toShares(0), 0, "zero balance must produce zero shares");
}

/// @notice Verifies toShares applies the WAD fallback when the stored ratio is explicitly zero
/// @dev A stored `sharesToTokensRatio` of zero is documented to resolve as `WAD_PRECISION` on
/// the read surface, both pre-write (fresh slot) and post-explicit-zero-write. Tests the
/// latter: after writing zero, toShares must behave as if the ratio were WAD (identity).
/// Cross-references test_sharesToTokensRatio_success_zeroRestoresWadFallback at the
/// derived-function level so a refactor that reads the slot directly here would fail.
function test_toShares_success_explicitZeroRatioFallsBackToWad(uint256 balance) public {
balance = bound(balance, 0, type(uint128).max);
_updateShareRatio(5e18); // seed a non-zero value first
_updateShareRatio(0); // then explicitly clear back to zero
assertEq(security().toShares(balance), balance, "stored zero ratio must produce identity (WAD fallback)");
}
}
Loading