From 50f240822ffe31b2323e30b1758578528b4ea87e Mon Sep 17 00:00:00 2001 From: katzman Date: Fri, 15 May 2026 10:11:21 -0700 Subject: [PATCH 1/2] claude spike on impl --- src/impls/B20.sol | 660 ++++++++++++++++++++++++++++++ src/interfaces/ISecurityToken.sol | 17 +- 2 files changed, 664 insertions(+), 13 deletions(-) create mode 100644 src/impls/B20.sol diff --git a/src/impls/B20.sol b/src/impls/B20.sol new file mode 100644 index 0000000..675eb1d --- /dev/null +++ b/src/impls/B20.sol @@ -0,0 +1,660 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Capabilities} from "../interfaces/Capabilities.sol"; +import {IDefaultToken} from "../interfaces/IDefaultToken.sol"; +import {IPolicyRegistry} from "../interfaces/IPolicyRegistry.sol"; +import {PauseVectors} from "../interfaces/PauseVectors.sol"; + +/// @title B20 +/// @author Coinbase +/// @notice Reference implementation of `IDefaultToken`. Combines ERC-20 with +/// memo'd transfer/mint/burn/redeem variants, OpenZeppelin +/// AccessControl-style role management (hand-ported, no inheritance), +/// granular pause vectors, supply cap, EIP-2612 permit (EOA only), +/// and ERC-7572 contract URI. +/// +/// @dev Policy enforcement strategy: +/// - Built-in policy IDs `0` (always-reject) and `1` (always-allow) +/// are trapped inside this contract, so a token configured with +/// `transferPolicyId == 1` works even before the policy registry +/// precompile is deployed. +/// - For any other policy ID, the contract staticcalls +/// `POLICY_REGISTRY`. A failing call OR a call returning `false` +/// is treated as deny; this is the safe default until the +/// singleton registry is live. +/// +/// Constructor signature mirrors `ITokenFactory.CreateDefaultTokenParams` +/// positionally; this is a best-stab until the factory contract is +/// specified. Bootstrap (initial-supply) mint at construction +/// bypasses the transfer-policy check per the NatSpec on +/// `CreateDefaultTokenParams.initialSupply`. +contract B20 is IDefaultToken { + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + /// @dev OZ AccessControl convention: the default admin role is + /// `bytes32(0)`, and is its own admin role. + bytes32 public constant DEFAULT_ADMIN_ROLE = bytes32(0); + + bytes32 public constant MINT_ROLE = keccak256("MINT_ROLE"); + bytes32 public constant BURN_ROLE = keccak256("BURN_ROLE"); + bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); + bytes32 public constant UNPAUSE_ROLE = keccak256("UNPAUSE_ROLE"); + + /// @dev TODO: replace with the official Base policy-registry precompile + /// address once specified. The implementation is graceful when no + /// contract lives here: built-in IDs (0/1) still work, and any + /// other ID is treated as deny. + IPolicyRegistry private constant _POLICY_REGISTRY = IPolicyRegistry(address(0x42)); + + /// @dev Built-in always-reject ID per `IPolicyRegistry`. + uint64 private constant _POLICY_REJECT = 0; + + /// @dev Built-in always-allow ID per `IPolicyRegistry`. + uint64 private constant _POLICY_ALLOW = 1; + + /// @dev EIP-712 type hash for the EIP-2612 `Permit` struct. + bytes32 private constant _PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + /// @dev EIP-712 domain type hash. Per `IDefaultToken` NatSpec, the + /// domain carries `chainId` and `verifyingContract` ONLY (no + /// name, no version); the type hash drops those fields to stay + /// consistent with `eip712Domain().fields == 0x0c`. + /// + /// TODO: confirm with interface author whether the empty-name / + /// empty-version intent was "still use the 5-field type hash with + /// empty strings" (OZ-compatible, wallets verify) or "really drop + /// them" (ERC-5267 pedantic, wallets that hard-code the canonical + /// domain fail to verify). This implementation follows the latter. + bytes32 private constant _EIP712_DOMAIN_TYPEHASH = + keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); + + /// @dev ERC-5267 `fields` value: bits 2 and 3 set, i.e. only `chainId` + /// and `verifyingContract` are populated. + bytes1 private constant _EIP712_FIELDS = 0x0c; + + /// @dev `secp256k1n / 2` upper bound for canonical signatures (low-`s`). + uint256 private constant _MAX_S = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0; + + /*////////////////////////////////////////////////////////////// + STORAGE + //////////////////////////////////////////////////////////////*/ + + /// @dev Mirrors OZ AccessControl's `RoleData`. + struct RoleData { + mapping(address account => bool hasRole) members; + bytes32 adminRole; + } + + string private _name; + string private _symbol; + string private _contractURI; + + uint8 private immutable _DECIMALS; + uint256 private immutable _CAPABILITIES; + uint256 private immutable _CACHED_CHAIN_ID; + bytes32 private immutable _CACHED_DOMAIN_SEPARATOR; + + uint256 private _totalSupply; + uint256 private _supplyCap; + uint256 private _minimumRedeemable; + uint256 private _pausedVectors; + uint256 private _defaultAdminCount; + + uint64 private _transferPolicyId; + + mapping(address account => uint256 balance) private _balances; + mapping(address owner => mapping(address spender => uint256 amount)) private _allowances; + mapping(address owner => uint256 nonce) private _nonces; + mapping(bytes32 role => RoleData data) private _roles; + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + /// @notice Construct a Default-variant B-20 token. + /// @dev Argument order mirrors `ITokenFactory.CreateDefaultTokenParams` + /// (less `salt`, which the factory consumes). When the factory is + /// specified this should be revisited; either the factory passes + /// the struct directly or the order here gets re-pinned. + constructor( + string memory name_, + string memory symbol_, + uint8 decimals_, + address admin, + uint256 capabilities_, + uint256 initialSupply, + address initialSupplyRecipient, + uint64 transferPolicyId_, + uint256 supplyCap_, + uint256 minimumRedeemable_, + string memory contractURI_ + ) { + if (admin == address(0)) revert InvalidReceiver(admin); + if (initialSupply > supplyCap_) revert SupplyCapExceeded({cap: supplyCap_, attempted: initialSupply}); + + _name = name_; + _symbol = symbol_; + _contractURI = contractURI_; + _DECIMALS = decimals_; + _CAPABILITIES = capabilities_; + _supplyCap = supplyCap_; + _minimumRedeemable = minimumRedeemable_; + _transferPolicyId = transferPolicyId_; + + _CACHED_CHAIN_ID = block.chainid; + _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(); + + _grantRoleInternal({role: DEFAULT_ADMIN_ROLE, account: admin}); + + if (initialSupply > 0) { + if (initialSupplyRecipient == address(0)) revert InvalidReceiver(initialSupplyRecipient); + _totalSupply = initialSupply; + unchecked { + _balances[initialSupplyRecipient] = initialSupply; + } + emit Transfer({from: address(0), to: initialSupplyRecipient, amount: initialSupply}); + } + } + + /*////////////////////////////////////////////////////////////// + CAPABILITIES + //////////////////////////////////////////////////////////////*/ + + function capabilities() external view returns (uint256) { + return _CAPABILITIES; + } + + function isPausable() external view returns (bool) { + return _hasCapability(Capabilities.PAUSABLE); + } + + function isCapMutable() external view returns (bool) { + return _hasCapability(Capabilities.CAP_MUTABLE); + } + + /*////////////////////////////////////////////////////////////// + ERC-20 + //////////////////////////////////////////////////////////////*/ + + function name() external view returns (string memory) { + return _name; + } + + function symbol() external view returns (string memory) { + return _symbol; + } + + function decimals() external view returns (uint8) { + return _DECIMALS; + } + + function totalSupply() external view returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) external view returns (uint256) { + return _balances[account]; + } + + function allowance(address owner, address spender) external view returns (uint256) { + return _allowances[owner][spender]; + } + + function transfer(address to, uint256 amount) external returns (bool) { + _transfer({from: msg.sender, to: to, spender: msg.sender, amount: amount}); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + if (from == address(0)) revert InvalidSender(from); + _spendAllowance({owner: from, spender: msg.sender, amount: amount}); + _transfer({from: from, to: to, spender: msg.sender, amount: amount}); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + _approve({owner: msg.sender, spender: spender, amount: amount}); + return true; + } + + /*////////////////////////////////////////////////////////////// + METADATA UPDATES + //////////////////////////////////////////////////////////////*/ + + function setName(string calldata newName) external { + _requireRole(DEFAULT_ADMIN_ROLE); + _name = newName; + emit NameUpdated({updater: msg.sender, newName: newName}); + } + + function setSymbol(string calldata newSymbol) external { + _requireRole(DEFAULT_ADMIN_ROLE); + _symbol = newSymbol; + emit SymbolUpdated({updater: msg.sender, newSymbol: newSymbol}); + } + + /*////////////////////////////////////////////////////////////// + MEMO TRANSFER VARIANTS + //////////////////////////////////////////////////////////////*/ + + function transferWithMemo(address to, uint256 amount, bytes32 memo) external returns (bool) { + _transfer({from: msg.sender, to: to, spender: msg.sender, amount: amount}); + emit Memo(memo); + return true; + } + + function transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo) external returns (bool) { + if (from == address(0)) revert InvalidSender(from); + _spendAllowance({owner: from, spender: msg.sender, amount: amount}); + _transfer({from: from, to: to, spender: msg.sender, amount: amount}); + emit Memo(memo); + return true; + } + + /*////////////////////////////////////////////////////////////// + MINT / BURN + //////////////////////////////////////////////////////////////*/ + + function mint(address to, uint256 amount) external { + _mint({to: to, amount: amount}); + } + + function mintWithMemo(address to, uint256 amount, bytes32 memo) external { + _mint({to: to, amount: amount}); + emit Memo(memo); + } + + function burn(uint256 amount) external { + _burn({from: msg.sender, amount: amount}); + } + + function burnWithMemo(uint256 amount, bytes32 memo) external { + _burn({from: msg.sender, amount: amount}); + emit Memo(memo); + } + + /*////////////////////////////////////////////////////////////// + REDEEM + //////////////////////////////////////////////////////////////*/ + + function redeem(uint256 amount) external { + _redeem(amount); + } + + function redeemWithMemo(uint256 amount, bytes32 memo) external { + _redeem(amount); + emit Memo(memo); + } + + function minimumRedeemable() external view returns (uint256) { + return _minimumRedeemable; + } + + function setMinimumRedeemable(uint256 newMinimum) external { + _requireRole(DEFAULT_ADMIN_ROLE); + uint256 old = _minimumRedeemable; + _minimumRedeemable = newMinimum; + emit MinimumRedeemableUpdated({updater: msg.sender, oldMinimum: old, newMinimum: newMinimum}); + } + + /*////////////////////////////////////////////////////////////// + ROLES + //////////////////////////////////////////////////////////////*/ + + function hasRole(bytes32 role, address account) public view returns (bool) { + return _roles[role].members[account]; + } + + function getRoleAdmin(bytes32 role) public view returns (bytes32) { + return _roles[role].adminRole; + } + + function grantRole(bytes32 role, address account) external { + _requireRole(getRoleAdmin(role)); + _grantRoleInternal({role: role, account: account}); + } + + function revokeRole(bytes32 role, address account) external { + _requireRole(getRoleAdmin(role)); + _revokeRoleInternal({role: role, account: account}); + } + + function renounceRole(bytes32 role, address callerConfirmation) external { + if (callerConfirmation != msg.sender) revert AccessControlBadConfirmation(); + if (!_roles[role].members[msg.sender]) return; + if (role == DEFAULT_ADMIN_ROLE && _defaultAdminCount == 1) revert LastAdminCannotRenounce(); + _revokeRoleInternal({role: role, account: msg.sender}); + } + + function setRoleAdmin(bytes32 role, bytes32 newAdminRole) external { + bytes32 previousAdminRole = getRoleAdmin(role); + _requireRole(previousAdminRole); + _roles[role].adminRole = newAdminRole; + emit RoleAdminChanged({role: role, previousAdminRole: previousAdminRole, newAdminRole: newAdminRole}); + } + + /*////////////////////////////////////////////////////////////// + PAUSE + //////////////////////////////////////////////////////////////*/ + + function paused() public view returns (uint256) { + if (!_hasCapability(Capabilities.PAUSABLE)) return 0; + return _pausedVectors; + } + + function isPaused(uint256 vector) external view returns (bool) { + return (paused() & vector) != 0; + } + + function pause(uint256 vectors) external { + _requireCapability(Capabilities.PAUSABLE); + _requireRole(PAUSE_ROLE); + if (vectors == 0) revert InvalidAmount(); + _pausedVectors |= vectors; + emit Paused({updater: msg.sender, vectors: vectors}); + } + + function unpause() external { + _requireCapability(Capabilities.PAUSABLE); + _requireRole(UNPAUSE_ROLE); + _pausedVectors = 0; + emit Unpaused(msg.sender); + } + + /*////////////////////////////////////////////////////////////// + POLICY + //////////////////////////////////////////////////////////////*/ + + function transferPolicyId() external view returns (uint64) { + return _transferPolicyId; + } + + function changeTransferPolicyId(uint64 newPolicyId) external { + _requireRole(DEFAULT_ADMIN_ROLE); + if (newPolicyId != _POLICY_REJECT && newPolicyId != _POLICY_ALLOW) { + if (!_policyExists(newPolicyId)) revert PolicyNotFound(newPolicyId); + } + uint64 old = _transferPolicyId; + _transferPolicyId = newPolicyId; + emit TransferPolicyUpdated({updater: msg.sender, oldPolicyId: old, newPolicyId: newPolicyId}); + } + + /*////////////////////////////////////////////////////////////// + SUPPLY CAP + //////////////////////////////////////////////////////////////*/ + + function supplyCap() external view returns (uint256) { + return _supplyCap; + } + + function setSupplyCap(uint256 newSupplyCap) external { + _requireCapability(Capabilities.CAP_MUTABLE); + _requireRole(DEFAULT_ADMIN_ROLE); + if (newSupplyCap < _totalSupply) { + revert InvalidSupplyCap({currentSupply: _totalSupply, proposedCap: newSupplyCap}); + } + uint256 old = _supplyCap; + _supplyCap = newSupplyCap; + emit SupplyCapUpdated({updater: msg.sender, oldSupplyCap: old, newSupplyCap: newSupplyCap}); + } + + /*////////////////////////////////////////////////////////////// + PERMIT (EIP-2612 + ERC-5267) + //////////////////////////////////////////////////////////////*/ + + function DOMAIN_SEPARATOR() public view returns (bytes32) { + if (block.chainid == _CACHED_CHAIN_ID) return _CACHED_DOMAIN_SEPARATOR; + return _buildDomainSeparator(); + } + + function nonces(address owner) external view returns (uint256) { + return _nonces[owner]; + } + + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external + { + if (block.timestamp > deadline) revert ExpiredSignature(deadline); + + uint256 nonce = _nonces[owner]; + unchecked { + _nonces[owner] = nonce + 1; + } + bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, nonce, deadline)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)); + + address recovered = _recover({digest: digest, v: v, r: r, s: s}); + if (recovered != owner) revert InvalidSigner({signer: recovered, owner: owner}); + + _approve({owner: owner, spender: spender, amount: value}); + } + + function eip712Domain() + external + view + returns ( + bytes1 fields, + string memory name_, + string memory version_, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ) + { + return (_EIP712_FIELDS, "", "", block.chainid, address(this), bytes32(0), new uint256[](0)); + } + + /*////////////////////////////////////////////////////////////// + CONTRACT URI (ERC-7572) + //////////////////////////////////////////////////////////////*/ + + function contractURI() external view returns (string memory) { + return _contractURI; + } + + function setContractURI(string calldata newURI) external { + _requireRole(DEFAULT_ADMIN_ROLE); + _contractURI = newURI; + emit ContractURIUpdated(); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL: ERC-20 PRIMITIVES + //////////////////////////////////////////////////////////////*/ + + function _transfer(address from, address to, address spender, uint256 amount) private { + if (to == address(0)) revert InvalidReceiver(to); + if (_pausedVectors & PauseVectors.TRANSFER != 0) revert ContractPaused(PauseVectors.TRANSFER); + + _checkTransferPolicy({from: from, to: to, spender: spender}); + + uint256 fromBalance = _balances[from]; + if (fromBalance < amount) { + revert InsufficientBalance({sender: from, balance: fromBalance, needed: amount}); + } + unchecked { + _balances[from] = fromBalance - amount; + _balances[to] += amount; + } + emit Transfer({from: from, to: to, amount: amount}); + } + + function _mint(address to, uint256 amount) private { + _requireRole(MINT_ROLE); + if (to == address(0)) revert InvalidReceiver(to); + if (_pausedVectors & PauseVectors.MINT != 0) revert ContractPaused(PauseVectors.MINT); + + uint256 newSupply = _totalSupply + amount; + if (newSupply > _supplyCap) revert SupplyCapExceeded({cap: _supplyCap, attempted: newSupply}); + + if (!_isAuthorizedMintRecipient(to)) revert PolicyForbids(_transferPolicyId); + + _totalSupply = newSupply; + unchecked { + _balances[to] += amount; + } + emit Transfer({from: address(0), to: to, amount: amount}); + } + + function _burn(address from, uint256 amount) private { + _requireRole(BURN_ROLE); + if (_pausedVectors & PauseVectors.BURN != 0) revert ContractPaused(PauseVectors.BURN); + + uint256 fromBalance = _balances[from]; + if (fromBalance < amount) { + revert InsufficientBalance({sender: from, balance: fromBalance, needed: amount}); + } + unchecked { + _balances[from] = fromBalance - amount; + _totalSupply -= amount; + } + emit Transfer({from: from, to: address(0), amount: amount}); + } + + function _redeem(uint256 amount) private { + uint256 minimum = _minimumRedeemable; + if (amount == 0 || amount < minimum) revert MinimumRedeemableNotMet({amount: amount, minimum: minimum}); + if (_pausedVectors & PauseVectors.REDEEM != 0) revert ContractPaused(PauseVectors.REDEEM); + + // TODO: IPolicyRegistry.CompoundPolicyData has no redeemer slot yet + // (sender / recipient / mintRecipient only). The IDefaultToken NatSpec + // says redeem consults the "redeemer slot of a compound policy"; + // until the registry interface grows that slot we temporarily map + // the redeemer check onto isAuthorizedSender, which yields the + // closest behavior (a sanctioned holder cannot destroy supply to + // claim off-chain). + if (!_isAuthorizedSender(msg.sender)) revert PolicyForbids(_transferPolicyId); + + uint256 fromBalance = _balances[msg.sender]; + if (fromBalance < amount) { + revert InsufficientBalance({sender: msg.sender, balance: fromBalance, needed: amount}); + } + unchecked { + _balances[msg.sender] = fromBalance - amount; + _totalSupply -= amount; + } + emit Transfer({from: msg.sender, to: address(0), amount: amount}); + emit Redeemed({holder: msg.sender, amount: amount}); + } + + function _approve(address owner, address spender, uint256 amount) private { + if (owner == address(0)) revert InvalidApprover(owner); + if (spender == address(0)) revert InvalidSpender(spender); + _allowances[owner][spender] = amount; + emit Approval({owner: owner, spender: spender, amount: amount}); + } + + function _spendAllowance(address owner, address spender, uint256 amount) private { + uint256 current = _allowances[owner][spender]; + if (current == type(uint256).max) return; + if (current < amount) revert InsufficientAllowance({spender: spender, allowance: current, needed: amount}); + unchecked { + _allowances[owner][spender] = current - amount; + } + emit Approval({owner: owner, spender: spender, amount: current - amount}); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL: ACCESS CONTROL + //////////////////////////////////////////////////////////////*/ + + function _grantRoleInternal(bytes32 role, address account) private { + if (_roles[role].members[account]) return; + _roles[role].members[account] = true; + if (role == DEFAULT_ADMIN_ROLE) { + unchecked { + _defaultAdminCount += 1; + } + } + emit RoleGranted({role: role, account: account, sender: msg.sender}); + } + + function _revokeRoleInternal(bytes32 role, address account) private { + if (!_roles[role].members[account]) return; + _roles[role].members[account] = false; + if (role == DEFAULT_ADMIN_ROLE) { + unchecked { + _defaultAdminCount -= 1; + } + } + emit RoleRevoked({role: role, account: account, sender: msg.sender}); + } + + function _requireRole(bytes32 role) private view { + if (!_roles[role].members[msg.sender]) { + revert AccessControlUnauthorizedAccount({account: msg.sender, neededRole: role}); + } + } + + function _requireCapability(uint256 capability) private view { + if ((_CAPABILITIES & capability) == 0) revert FeatureDisabled(capability); + } + + function _hasCapability(uint256 capability) private view returns (bool) { + return (_CAPABILITIES & capability) != 0; + } + + /*////////////////////////////////////////////////////////////// + INTERNAL: POLICY + //////////////////////////////////////////////////////////////*/ + + function _checkTransferPolicy(address from, address to, address spender) private view { + if (!_isAuthorizedSender(from)) revert PolicyForbids(_transferPolicyId); + if (!_isAuthorizedRecipient(to)) revert PolicyForbids(_transferPolicyId); + if (spender != from && !_isAuthorizedSender(spender)) revert PolicyForbids(_transferPolicyId); + } + + function _isAuthorizedSender(address user) private view returns (bool) { + uint64 policyId = _transferPolicyId; + if (policyId == _POLICY_ALLOW) return true; + if (policyId == _POLICY_REJECT) return false; + return _staticcallPolicyBool(abi.encodeCall(IPolicyRegistry.isAuthorizedSender, (policyId, user))); + } + + function _isAuthorizedRecipient(address user) private view returns (bool) { + uint64 policyId = _transferPolicyId; + if (policyId == _POLICY_ALLOW) return true; + if (policyId == _POLICY_REJECT) return false; + return _staticcallPolicyBool(abi.encodeCall(IPolicyRegistry.isAuthorizedRecipient, (policyId, user))); + } + + function _isAuthorizedMintRecipient(address user) private view returns (bool) { + uint64 policyId = _transferPolicyId; + if (policyId == _POLICY_ALLOW) return true; + if (policyId == _POLICY_REJECT) return false; + return _staticcallPolicyBool(abi.encodeCall(IPolicyRegistry.isAuthorizedMintRecipient, (policyId, user))); + } + + function _policyExists(uint64 policyId) private view returns (bool) { + return _staticcallPolicyBool(abi.encodeCall(IPolicyRegistry.policyExists, (policyId))); + } + + /// @dev Staticcalls the policy registry and decodes a single bool. + /// Returns `false` on any failure (revert, no-contract, malformed + /// return). Safe-by-default: when the registry is missing or + /// misbehaving, all non-built-in policy IDs deny. + function _staticcallPolicyBool(bytes memory data) private view returns (bool) { + (bool ok, bytes memory ret) = address(_POLICY_REGISTRY).staticcall(data); + if (!ok || ret.length < 32) return false; + return abi.decode(ret, (bool)); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL: EIP-712 + //////////////////////////////////////////////////////////////*/ + + function _buildDomainSeparator() private view returns (bytes32) { + return keccak256(abi.encode(_EIP712_DOMAIN_TYPEHASH, block.chainid, address(this))); + } + + function _recover(bytes32 digest, uint8 v, bytes32 r, bytes32 s) private pure returns (address) { + if (uint256(s) > _MAX_S) return address(0); + if (v != 27 && v != 28) return address(0); + return ecrecover(digest, v, r, s); + } +} diff --git a/src/interfaces/ISecurityToken.sol b/src/interfaces/ISecurityToken.sol index 786b9dc..90474d4 100644 --- a/src/interfaces/ISecurityToken.sol +++ b/src/interfaces/ISecurityToken.sol @@ -109,9 +109,7 @@ interface ISecurityToken is IDefaultToken { /// @notice A security identifier (ISIN, CUSIP, FIGI, etc.) was set, /// changed, or removed. `value` is the empty string on /// removal. - event SecurityIdentifierUpdated( - address indexed caller, string announcementId, string identifierType, string value - ); + event SecurityIdentifierUpdated(address indexed caller, string announcementId, string identifierType, string value); /// @notice Supply created via the compliant issuance path. event Created(address indexed to, uint256 amount); @@ -240,11 +238,8 @@ interface ISecurityToken is IDefaultToken { /// `announcementId`. Subject to the inherited `supplyCap`. /// Reverts atomically if any single recipient fails; /// partial mints are not possible. - function adminMint( - string calldata announcementId, - address[] calldata recipients, - uint256[] calldata amounts - ) external; + function adminMint(string calldata announcementId, address[] calldata recipients, uint256[] calldata amounts) + external; /// @notice Cold-path batch burn. Used for cold-path corporate /// actions (reverse-tender settlement, mass-corrections @@ -256,11 +251,7 @@ interface ISecurityToken is IDefaultToken { /// `announcementId`. Reverts atomically if any single /// account lacks sufficient balance; partial burns are not /// possible. - function adminBurn( - string calldata announcementId, - address[] calldata accounts, - uint256[] calldata amounts - ) external; + function adminBurn(string calldata announcementId, address[] calldata accounts, uint256[] calldata amounts) external; /*////////////////////////////////////////////////////////////// SECURITY IDENTIFIERS From 9d6b8099f2f6cb8e399ecf570352951391f799fa Mon Sep 17 00:00:00 2001 From: katzman Date: Mon, 18 May 2026 09:26:26 -0700 Subject: [PATCH 2/2] cleanup, rebasee --- src/impls/B20.sol | 46 ++++++++++++------------------- src/interfaces/ISecurityToken.sol | 17 +++++++++--- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/impls/B20.sol b/src/impls/B20.sol index 675eb1d..422427d 100644 --- a/src/impls/B20.sol +++ b/src/impls/B20.sol @@ -225,14 +225,12 @@ contract B20 is IDefaultToken { METADATA UPDATES //////////////////////////////////////////////////////////////*/ - function setName(string calldata newName) external { - _requireRole(DEFAULT_ADMIN_ROLE); + function setName(string calldata newName) external onlyRole(DEFAULT_ADMIN_ROLE) { _name = newName; emit NameUpdated({updater: msg.sender, newName: newName}); } - function setSymbol(string calldata newSymbol) external { - _requireRole(DEFAULT_ADMIN_ROLE); + function setSymbol(string calldata newSymbol) external onlyRole(DEFAULT_ADMIN_ROLE) { _symbol = newSymbol; emit SymbolUpdated({updater: msg.sender, newSymbol: newSymbol}); } @@ -259,20 +257,20 @@ contract B20 is IDefaultToken { MINT / BURN //////////////////////////////////////////////////////////////*/ - function mint(address to, uint256 amount) external { + function mint(address to, uint256 amount) external onlyRole(MINT_ROLE) { _mint({to: to, amount: amount}); } - function mintWithMemo(address to, uint256 amount, bytes32 memo) external { + function mintWithMemo(address to, uint256 amount, bytes32 memo) external onlyRole(MINT_ROLE) { _mint({to: to, amount: amount}); emit Memo(memo); } - function burn(uint256 amount) external { + function burn(uint256 amount) external onlyRole(BURN_ROLE) { _burn({from: msg.sender, amount: amount}); } - function burnWithMemo(uint256 amount, bytes32 memo) external { + function burnWithMemo(uint256 amount, bytes32 memo) external onlyRole(BURN_ROLE) { _burn({from: msg.sender, amount: amount}); emit Memo(memo); } @@ -294,8 +292,7 @@ contract B20 is IDefaultToken { return _minimumRedeemable; } - function setMinimumRedeemable(uint256 newMinimum) external { - _requireRole(DEFAULT_ADMIN_ROLE); + function setMinimumRedeemable(uint256 newMinimum) external onlyRole(DEFAULT_ADMIN_ROLE) { uint256 old = _minimumRedeemable; _minimumRedeemable = newMinimum; emit MinimumRedeemableUpdated({updater: msg.sender, oldMinimum: old, newMinimum: newMinimum}); @@ -313,13 +310,11 @@ contract B20 is IDefaultToken { return _roles[role].adminRole; } - function grantRole(bytes32 role, address account) external { - _requireRole(getRoleAdmin(role)); + function grantRole(bytes32 role, address account) external onlyRole(getRoleAdmin(role)) { _grantRoleInternal({role: role, account: account}); } - function revokeRole(bytes32 role, address account) external { - _requireRole(getRoleAdmin(role)); + function revokeRole(bytes32 role, address account) external onlyRole(getRoleAdmin(role)) { _revokeRoleInternal({role: role, account: account}); } @@ -330,9 +325,8 @@ contract B20 is IDefaultToken { _revokeRoleInternal({role: role, account: msg.sender}); } - function setRoleAdmin(bytes32 role, bytes32 newAdminRole) external { + function setRoleAdmin(bytes32 role, bytes32 newAdminRole) external onlyRole(getRoleAdmin(role)) { bytes32 previousAdminRole = getRoleAdmin(role); - _requireRole(previousAdminRole); _roles[role].adminRole = newAdminRole; emit RoleAdminChanged({role: role, previousAdminRole: previousAdminRole, newAdminRole: newAdminRole}); } @@ -350,17 +344,15 @@ contract B20 is IDefaultToken { return (paused() & vector) != 0; } - function pause(uint256 vectors) external { + function pause(uint256 vectors) external onlyRole(PAUSE_ROLE) { _requireCapability(Capabilities.PAUSABLE); - _requireRole(PAUSE_ROLE); if (vectors == 0) revert InvalidAmount(); _pausedVectors |= vectors; emit Paused({updater: msg.sender, vectors: vectors}); } - function unpause() external { + function unpause() external onlyRole(UNPAUSE_ROLE) { _requireCapability(Capabilities.PAUSABLE); - _requireRole(UNPAUSE_ROLE); _pausedVectors = 0; emit Unpaused(msg.sender); } @@ -373,8 +365,7 @@ contract B20 is IDefaultToken { return _transferPolicyId; } - function changeTransferPolicyId(uint64 newPolicyId) external { - _requireRole(DEFAULT_ADMIN_ROLE); + function changeTransferPolicyId(uint64 newPolicyId) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newPolicyId != _POLICY_REJECT && newPolicyId != _POLICY_ALLOW) { if (!_policyExists(newPolicyId)) revert PolicyNotFound(newPolicyId); } @@ -391,9 +382,8 @@ contract B20 is IDefaultToken { return _supplyCap; } - function setSupplyCap(uint256 newSupplyCap) external { + function setSupplyCap(uint256 newSupplyCap) external onlyRole(DEFAULT_ADMIN_ROLE) { _requireCapability(Capabilities.CAP_MUTABLE); - _requireRole(DEFAULT_ADMIN_ROLE); if (newSupplyCap < _totalSupply) { revert InvalidSupplyCap({currentSupply: _totalSupply, proposedCap: newSupplyCap}); } @@ -457,8 +447,7 @@ contract B20 is IDefaultToken { return _contractURI; } - function setContractURI(string calldata newURI) external { - _requireRole(DEFAULT_ADMIN_ROLE); + function setContractURI(string calldata newURI) external onlyRole(DEFAULT_ADMIN_ROLE) { _contractURI = newURI; emit ContractURIUpdated(); } @@ -485,7 +474,6 @@ contract B20 is IDefaultToken { } function _mint(address to, uint256 amount) private { - _requireRole(MINT_ROLE); if (to == address(0)) revert InvalidReceiver(to); if (_pausedVectors & PauseVectors.MINT != 0) revert ContractPaused(PauseVectors.MINT); @@ -502,7 +490,6 @@ contract B20 is IDefaultToken { } function _burn(address from, uint256 amount) private { - _requireRole(BURN_ROLE); if (_pausedVectors & PauseVectors.BURN != 0) revert ContractPaused(PauseVectors.BURN); uint256 fromBalance = _balances[from]; @@ -585,10 +572,11 @@ contract B20 is IDefaultToken { emit RoleRevoked({role: role, account: account, sender: msg.sender}); } - function _requireRole(bytes32 role) private view { + modifier onlyRole(bytes32 role) { if (!_roles[role].members[msg.sender]) { revert AccessControlUnauthorizedAccount({account: msg.sender, neededRole: role}); } + _; } function _requireCapability(uint256 capability) private view { diff --git a/src/interfaces/ISecurityToken.sol b/src/interfaces/ISecurityToken.sol index 90474d4..786b9dc 100644 --- a/src/interfaces/ISecurityToken.sol +++ b/src/interfaces/ISecurityToken.sol @@ -109,7 +109,9 @@ interface ISecurityToken is IDefaultToken { /// @notice A security identifier (ISIN, CUSIP, FIGI, etc.) was set, /// changed, or removed. `value` is the empty string on /// removal. - event SecurityIdentifierUpdated(address indexed caller, string announcementId, string identifierType, string value); + event SecurityIdentifierUpdated( + address indexed caller, string announcementId, string identifierType, string value + ); /// @notice Supply created via the compliant issuance path. event Created(address indexed to, uint256 amount); @@ -238,8 +240,11 @@ interface ISecurityToken is IDefaultToken { /// `announcementId`. Subject to the inherited `supplyCap`. /// Reverts atomically if any single recipient fails; /// partial mints are not possible. - function adminMint(string calldata announcementId, address[] calldata recipients, uint256[] calldata amounts) - external; + function adminMint( + string calldata announcementId, + address[] calldata recipients, + uint256[] calldata amounts + ) external; /// @notice Cold-path batch burn. Used for cold-path corporate /// actions (reverse-tender settlement, mass-corrections @@ -251,7 +256,11 @@ interface ISecurityToken is IDefaultToken { /// `announcementId`. Reverts atomically if any single /// account lacks sufficient balance; partial burns are not /// possible. - function adminBurn(string calldata announcementId, address[] calldata accounts, uint256[] calldata amounts) external; + function adminBurn( + string calldata announcementId, + address[] calldata accounts, + uint256[] calldata amounts + ) external; /*////////////////////////////////////////////////////////////// SECURITY IDENTIFIERS