From 57629a7b1b8ac62d41f248f1fd6854edf1ac1395 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Thu, 10 Sep 2026 17:17:52 +0200 Subject: [PATCH 1/5] feat(b20): add issuer-approved operator allowances Co-authored-by: OpenCode --- CHANGELOG.md | 26 ++++ changelog/03_Denim_B20_operator_allowance.md | 92 ++++++++++++ changelog/README.md | 10 ++ docs/concepts/multipliers.md | 3 +- docs/concepts/policies.md | 3 +- docs/concepts/roles-and-pause.md | 7 +- docs/concepts/token-types.md | 6 +- docs/guides/announcing-corporate-actions.md | 2 + docs/guides/scheduling-stock-splits.md | 2 + docs/overview.md | 4 +- docs/reference/constants.md | 2 +- docs/reference/errors.md | 2 +- src/interfaces/IB20.sol | 16 +- src/interfaces/IB20Asset.sol | 10 -- src/lib/B20FactoryLib.sol | 3 +- test/lib/B20AssetTest.sol | 37 +---- test/lib/B20Test.sol | 16 +- test/lib/mocks/MockB20.sol | 28 ++-- test/lib/mocks/MockB20Asset.sol | 2 - test/unit/B20/erc20/allowance.t.sol | 34 +++++ test/unit/B20/erc20/transferFrom.t.sol | 138 ++++++++++++++++++ .../transferFromWithMemo_revertOrder.t.sol | 3 +- .../B20/erc20/transferFrom_revertOrder.t.sol | 2 +- test/unit/B20/memo/transferFromWithMemo.t.sol | 57 ++++++++ test/unit/B20/roles/getRoleAdmin.t.sol | 10 ++ test/unit/B20/roles/roleConstants.t.sol | 9 +- test/unit/B20/roles/setRoleAdmin.t.sol | 22 +++ test/unit/B20Stablecoin/erc20/allowance.t.sol | 24 +++ .../B20Stablecoin/erc20/transferFrom.t.sol | 99 +++++++++++++ .../memo/transferFromWithMemo.t.sol | 59 ++++++++ .../B20Stablecoin/roles/roleConstants.t.sol | 14 ++ 31 files changed, 657 insertions(+), 85 deletions(-) create mode 100644 changelog/03_Denim_B20_operator_allowance.md create mode 100644 test/unit/B20Stablecoin/erc20/allowance.t.sol create mode 100644 test/unit/B20Stablecoin/erc20/transferFrom.t.sol create mode 100644 test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol create mode 100644 test/unit/B20Stablecoin/roles/roleConstants.t.sol diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a0d09d..c826eb46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ Each section is a complete summary of that hardfork's changes. For selector-leve function selectors, event topics, error codes, and edge-case behavior), see the corresponding entry in [`changelog/`](changelog/README.md). +## Denim + +### Status + +Denim has not activated yet. Operator allowance behavior remains unavailable until Denim selects B20 logic v3. + +### Compatibility + +Denim changes the behavior of existing `allowance`, `transferFrom`, and `transferFromWithMemo` selectors. It also moves the existing Asset `OPERATOR_ROLE()` getter onto the shared `IB20` surface, which makes that selector available on Stablecoin. + +### Summary of changes + +| Product | Feature | Change | Details | +| --- | --- | --- | --- | +| B20 (Asset and Stablecoin) | Issuer-approved operators | An account with `OPERATOR_ROLE` reads as having infinite allowance from every holder. Operator transfers do not consume stored allowances, but pause and transfer policies remain active. | [03_Denim_B20_operator_allowance](changelog/03_Denim_B20_operator_allowance.md) | + +### Migration guidance + +#### Issuers + +Audit every existing `OPERATOR_ROLE` holder before Denim activates. Asset already uses this role for announcements and multiplier administration, so those accounts gain authority to move holder balances. Stablecoin issuers must also audit generic grants of the same role hash. Revoke any assignment that should not gain this authority. + +#### Wallets and integrators + +Treat `allowance(owner, spender) == type(uint256).max` as possible role-based authority. A holder cannot revoke that authority with `approve(spender, 0)`; only the role administrator can remove it. Continue to enforce transfer policy failures and paused-transfer failures for operator calls. + ## Cobalt ### Status diff --git a/changelog/03_Denim_B20_operator_allowance.md b/changelog/03_Denim_B20_operator_allowance.md new file mode 100644 index 00000000..b502e8da --- /dev/null +++ b/changelog/03_Denim_B20_operator_allowance.md @@ -0,0 +1,92 @@ +# Denim: Issuer-Approved Operators + +- **Feature Name**: operator_allowance +- **Start Date**: 2026-09-10 +- **Title**: Issuer-approved infinite allowances through `OPERATOR_ROLE` + +## Summary + +Denim lets a B20 issuer grant an account permission to spend from every holder without holder approvals. The issuer uses the existing role system. Denim adds no implicit grants; any existing or future grant of the `OPERATOR_ROLE` hash receives this authority. + +## Motivation + +Some token integrations need one contract, such as a router or settlement system, to spend from every holder. Requiring each holder to call `approve` adds a transaction and prevents the integration from working for holders that cannot make an approval call. + +## Specs + +### Interface changes + +`OPERATOR_ROLE()` moves to the shared [`IB20`](../src/interfaces/IB20.sol) interface. + +| Function | Selector | Denim change | +| --- | --- | --- | +| `OPERATOR_ROLE()` | `0xf5b541a6` | Already available on Asset; newly available on Stablecoin through `IB20`. | +| `allowance(address,address)` | `0xdd62ed3e` | Returns `type(uint256).max` when `spender` holds `OPERATOR_ROLE`. | +| `transferFrom(address,address,uint256)` | `0x23b872dd` | Skips allowance validation and consumption when the caller holds `OPERATOR_ROLE`. | +| `transferFromWithMemo(address,address,uint256,bytes32)` | `0x929c2539` | Applies the same operator behavior as `transferFrom`. | + +The role value remains: + +```solidity +keccak256("OPERATOR_ROLE") +// 0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929 +``` + +No new mutator, event, error, or storage slot is added. Issuers manage membership with `grantRole`, `revokeRole`, and `renounceRole`. `getRoleAdmin(OPERATOR_ROLE)` defaults to `DEFAULT_ADMIN_ROLE` and remains delegable through `setRoleAdmin`. + +### Behavioral changes + +For a caller that holds `OPERATOR_ROLE`: + +- `allowance(owner, caller)` returns `type(uint256).max` for every `owner`. +- `transferFrom` and `transferFromWithMemo` do not read or decrement the stored allowance. +- A finite stored allowance remains unchanged and becomes visible again if the role is revoked. +- `approve(caller, 0)` does not opt the holder out. +- `TRANSFER_EXECUTOR_POLICY`, `TRANSFER_SENDER_POLICY`, and `TRANSFER_RECEIVER_POLICY` still run. +- The `TRANSFER` pause vector and balance checks still run. + +For any other caller, allowance behavior remains unchanged. A finite allowance decrements by the transferred amount, and `type(uint256).max` remains the non-decrementing ERC-20 sentinel. + +### Storage layout + +There is no storage change. Operator membership uses the existing role mapping. Holder allowances remain in their existing slots, including while the spender holds `OPERATOR_ROLE`. + +## Example + +```solidity +bytes32 operatorRole = token.OPERATOR_ROLE(); +token.grantRole(operatorRole, address(router)); + +// Returns type(uint256).max even when alice never approved the router. +uint256 effectiveAllowance = token.allowance(alice, address(router)); + +// The router calls token.transferFrom(alice, recipient, amount) +// from its own execution context. +``` + +## Design Decisions + +- Reuse the existing RBAC set instead of adding an operator registry or policy scope. +- Keep the set empty by default. No address, including Permit2, receives implicit authority. +- Grant infinite authority only. Per-operator caps are not supported. +- Do not add holder opt-out state. Issuer role revocation is the removal path. +- Reuse `DEFAULT_ADMIN_ROLE` as the default role administrator. +- Waive only allowance checks. Compliance policies and pause remain independent controls. + +## Migration Steps + +### Issuers + +1. Enumerate historical `RoleGranted` and `RoleRevoked` events for `OPERATOR_ROLE`. +2. Revoke Asset operators that must not gain holder-spending authority before Denim activates. +3. Check Stablecoin tokens for generic grants of the same role hash, even though the getter was not previously on the Stablecoin interface. +4. Check `getRoleAdmin(OPERATOR_ROLE)` because an earlier `setRoleAdmin` call may have delegated role administration. +5. Grant the role only to contracts and accounts that may move every holder's balance. + +### Integrators + +1. Do not assume that `allowance == type(uint256).max` came from holder approval. +2. Do not present `approve(operator, 0)` as a revocation path for role-based authority. +3. Continue to handle `ContractPaused`, `PolicyForbids`, and `InsufficientBalance` on operator transfers. + +This change is ABI-compatible for Asset but behaviorally breaking for existing operator assignments. Stablecoin gains the additive `OPERATOR_ROLE()` selector and the same behavioral change on existing ERC-20 selectors. diff --git a/changelog/README.md b/changelog/README.md index 703cd9d7..1c45e205 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -16,11 +16,21 @@ See [AGENTS.md](AGENTS.md) for how to name and write a new entry. | --- | --- | --- | | `01` | Beryl | Live | | `02` | Cobalt | Upcoming | +| `03` | Denim | Upcoming | ## Index Grouped by hardfork, one collapsible section per hardfork, newest first. +
+Denim (upcoming) - ordinal 03 + +| Product(s) | Change | Affected interfaces | Entry | +| --- | --- | --- | --- | +| B20 Asset, B20 Stablecoin | Issuer-approved operators | `src/interfaces/IB20.sol` (shared surface) inherited by `src/interfaces/IB20Asset.sol`, `src/interfaces/IB20Stablecoin.sol` | [03_Denim_B20_operator_allowance](03_Denim_B20_operator_allowance.md) | + +
+
Cobalt (upcoming) — ordinal 02 diff --git a/docs/concepts/multipliers.md b/docs/concepts/multipliers.md index 06fd4d4f..b2185b6b 100644 --- a/docs/concepts/multipliers.md +++ b/docs/concepts/multipliers.md @@ -46,7 +46,7 @@ Convert a single amount with `toUIAmount(raw)` and `fromUIAmount(ui)` at that ef ### What it preserves -`balanceOf`, `transfer` amounts, `totalSupply`, and allowances stay raw. Protocols that use that ERC-20 surface do not see the split. +`balanceOf`, `transfer` amounts, `totalSupply`, and stored allowances stay raw. Protocols that use that ERC-20 surface do not see the split. The separate `OPERATOR_ROLE` rule can make `allowance(owner, operator)` return `type(uint256).max`; that value does not use the UI multiplier. The UI views above are opt-in. Protocols that call `balanceOfUI`, `scaledBalanceOf`, or `totalSupplyUI` do see the split. @@ -101,4 +101,3 @@ A reverse split uses the same path. A 1-for-2 uses `5e17`. - [Roles and Pause](roles-and-pause.md) — `OPERATOR_ROLE`. - [Schedule a stock split](../guides/scheduling-stock-splits.md) — how to schedule, cancel, override; events and errors. - [Announce a corporate action](../guides/announcing-corporate-actions.md) — disclosure wrapper around the schedule. - diff --git a/docs/concepts/policies.md b/docs/concepts/policies.md index 1459b6e3..b0008502 100644 --- a/docs/concepts/policies.md +++ b/docs/concepts/policies.md @@ -204,6 +204,8 @@ Most scopes deny when `isAuthorized` is `false` and revert `PolicyForbids`. `SEI | `SEIZE_HOLDER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means no account is seizable. | `from` | `true` | `AccountNotSeizable` | | `SEIZE_RECEIVER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means seize may send to any destination. | `to` | `false` | `PolicyForbids` | +`OPERATOR_ROLE` does not bypass these scopes. An operator skips only the allowance check in `transferFrom` and `transferFromWithMemo`. The executor, sender, and receiver policy checks still run. + ## 4. Example Start with a receiver allowlist. Then combine it with a sanctions blocklist so a transfer requires both. @@ -347,4 +349,3 @@ If the issuer later needs the same KYC list or-ed with a token-specific partner | `InvalidChildPolicy(childPolicyId)` | A composite child is not an existing simple policy | | `NonPayable()` | ETH was attached to a registry call | - diff --git a/docs/concepts/roles-and-pause.md b/docs/concepts/roles-and-pause.md index 44a44a4d..d28a71e5 100644 --- a/docs/concepts/roles-and-pause.md +++ b/docs/concepts/roles-and-pause.md @@ -33,10 +33,12 @@ Two functions always require `DEFAULT_ADMIN_ROLE`: `updatePolicy` and `updateSup | `PAUSE_ROLE` | `pause` | | `UNPAUSE_ROLE` | `unpause` | | `METADATA_ROLE` | `updateName`, `updateSymbol`, `updateContractURI`; Asset also gates `updateExtraMetadata` | -| `OPERATOR_ROLE` | Asset-only: `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, deprecated `updateMultiplier` | +| `OPERATOR_ROLE` | Infinite `transferFrom` allowance from every holder; Asset also gates `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, deprecated `updateMultiplier` | -`OPERATOR_ROLE` exists only on Asset. See [Token Types](token-types.md). `approve` is not role-gated. Holder `transfer` is not role-gated. A holder can always move their own balance, subject to pause and policy. +`OPERATOR_ROLE` is shared by Asset and Stablecoin. For any holder and operator, `allowance(holder, operator)` returns `type(uint256).max`. The operator can call `transferFrom` or `transferFromWithMemo` without holder approval, and those calls do not change the holder's stored allowance. `approve(operator, 0)` does not opt the holder out. The role administrator must revoke `OPERATOR_ROLE` to remove the authority. + +Operator transfers still use the `TRANSFER` pause vector and all three transfer policy scopes. The role waives only the allowance check. On Asset, the same role also gates announcements and multiplier updates. `approve` and holder `transfer` are not role-gated. ### 2.3 Granting and revoking @@ -256,4 +258,3 @@ sequenceDiagram | `LastAdminCannotRenounce()` | `revokeRole`/`renounceRole` would remove the last `DEFAULT_ADMIN_ROLE` holder | | `NotSoleAdmin()` | `renounceLastAdmin` called while other admins still exist | - diff --git a/docs/concepts/token-types.md b/docs/concepts/token-types.md index 29a7087a..ede48aae 100644 --- a/docs/concepts/token-types.md +++ b/docs/concepts/token-types.md @@ -46,7 +46,7 @@ Asset is the general-purpose variant. That includes real-world assets (RWAs). It Creation sets immutable `decimals` in `[6, 18]`. Values outside that range revert `InvalidDecimals`. Asset has no `currency()`. -It adds the Asset-only calls: `announce` for a corporate-action disclosure with a single-use `id` and optional inner calls, scheduled `updateUIMultiplier` / `cancelUIMultiplierUpdate` ([ERC-8056](https://eips.ethereum.org/EIPS/eip-8056)), an extra-metadata key/value store, and `batchMint`. `OPERATOR_ROLE` is Asset-only and gates `announce` and multiplier updates. Name, symbol, contract URI, and extra metadata still use inherited `METADATA_ROLE`. +It adds the Asset-only calls: `announce` for a corporate-action disclosure with a single-use `id` and optional inner calls, scheduled `updateUIMultiplier` / `cancelUIMultiplierUpdate` ([ERC-8056](https://eips.ethereum.org/EIPS/eip-8056)), an extra-metadata key/value store, and `batchMint`. The inherited `OPERATOR_ROLE` also gates `announce` and multiplier updates. Name, symbol, contract URI, and extra metadata still use inherited `METADATA_ROLE`. Asset-specific state lives in `base.b20.asset`: `decimals`, `multiplier`, used announcement IDs, extra metadata, and the pending multiplier. Shared ERC-20, role, policy, and pause state stays in `base.b20`. @@ -58,7 +58,7 @@ Stablecoin is the fiat-pegged variant. `decimals` is hardcoded to `6`. The issuer does not pass decimals. -The extra surface on top of `IB20` is `currency()`. Stablecoin has no announce, multiplier, extra metadata, `batchMint`, or `OPERATOR_ROLE`. +The extra surface on top of `IB20` is `currency()`. Stablecoin has no announce, multiplier, extra metadata, or `batchMint`. It inherits the shared `OPERATOR_ROLE` allowance behavior from `IB20`. Stablecoin-specific state lives in `base.b20.stablecoin` (`currency` only). Shared ERC-20, role, policy, and pause state stays in `base.b20`. @@ -70,7 +70,7 @@ The same issuer can create both types. Different salts produce different address ### 6.1 Creating a Stablecoin -Predict the address with `getB20Address(STABLECOIN, sender, saltB)`. Then call `createB20` with `B20StablecoinCreateParams`: `version` `1`, name, symbol, `initialAdmin`, and `currency: "USD"`. +Predict the address with `getB20Address(STABLECOIN, sender, saltB)`. Then call `createB20` with `B20StablecoinCreateParams`: `version` `1`, name, symbol, `initialAdmin`, and `currency: "USD"`. Optional `initCalls` can grant `OPERATOR_ROLE` through the standard `grantRole` encoder. ```mermaid sequenceDiagram diff --git a/docs/guides/announcing-corporate-actions.md b/docs/guides/announcing-corporate-actions.md index 40e4aee2..6d169778 100644 --- a/docs/guides/announcing-corporate-actions.md +++ b/docs/guides/announcing-corporate-actions.md @@ -64,6 +64,8 @@ Grant roles, choose the disclosure, encode the inner calls, then announce. The s asset.grantRole(asset.OPERATOR_ROLE(), operator); ``` +`OPERATOR_ROLE` also grants infinite `transferFrom` authority from every holder. Grant it only to an account that may move holder balances. + Until this grant lands, every `announce` reverts `AccessControlUnauthorizedAccount`. Grant inner-call roles on the same operator when the wrapped call needs them. Mint needs `MINT_ROLE`. Burn needs `BURN_ROLE`. Multiplier setters already use `OPERATOR_ROLE`. diff --git a/docs/guides/scheduling-stock-splits.md b/docs/guides/scheduling-stock-splits.md index 568d1859..6b1127fc 100644 --- a/docs/guides/scheduling-stock-splits.md +++ b/docs/guides/scheduling-stock-splits.md @@ -97,6 +97,8 @@ This is the routine corporate-action path. asset.grantRole(asset.OPERATOR_ROLE(), operator); ``` +`OPERATOR_ROLE` also grants infinite `transferFrom` authority from every holder. Grant it only to an account that may move holder balances. + Until this grant lands, every multiplier setter reverts `AccessControlUnauthorizedAccount`. #### Call `updateUIMultiplier` diff --git a/docs/overview.md b/docs/overview.md index 09f92aba..10370d8e 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -78,10 +78,12 @@ The Activation Registry is a Base-operated safety switch that turns Factory and ## Configuring Roles -Roles let an issuer assign each privileged operation to a specific account. An admin can grant minting to a minter, seizing to a compliance operator, and pausing of a single feature (`TRANSFER`, `MINT`, `BURN`, or `SEIZE`) without pausing the rest of the token. +Roles let an issuer assign each privileged operation to a specific account. An admin can grant minting to a minter, seizing to a compliance operator, issuer-approved spending to an operator, and pausing of a single feature (`TRANSFER`, `MINT`, `BURN`, or `SEIZE`) without pausing the rest of the token. B20 implements this with [OpenZeppelin AccessControl](https://docs.openzeppelin.com/contracts/5.x/access-control) on the token. Roles are not a separate registry. One `DEFAULT_ADMIN_ROLE` holder grants and revokes the operating roles. A privileged call checks the role first, then the matching pause vector. Holder `transfer` skips the role check; it still hits the `TRANSFER` pause vector and policy. +`OPERATOR_ROLE` gives its holder an infinite allowance from every token holder. `allowance(owner, operator)` returns `type(uint256).max`, and `transferFrom` does not consume the holder's stored allowance. The transfer pause vector and sender, receiver, and executor policies still apply. A holder cannot opt out by approving zero; the role admin must revoke the role. + The full role list and what each role gates is in [Roles](./concepts/roles.md). A role-gated call looks like this: ```mermaid diff --git a/docs/reference/constants.md b/docs/reference/constants.md index 23f40a8d..370bfd74 100644 --- a/docs/reference/constants.md +++ b/docs/reference/constants.md @@ -26,7 +26,7 @@ | `PAUSE_ROLE` | `keccak256("PAUSE_ROLE")`
`0x139c2898040ef16910dc9f44dc697df79363da767d8bc92f2e310312b816e46d` | Required to call `pause`. | | `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")`
`0x265b220c5a8891efdd9e1b1b7fa72f257bd5169f8d87e319cf3dad6ff52b94ae` | Required to call `unpause`. | | `METADATA_ROLE` | `keccak256("METADATA_ROLE")`
`0x6bd6b5318a46e5fff572d5e4258a20774aab40cc35ac7680654b9081fcc82f80` | Required to call `updateName`, `updateSymbol`, `updateContractURI`, and `updateExtraMetadata`. | -| `OPERATOR_ROLE` | `keccak256("OPERATOR_ROLE")`
`0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | B20Asset-only. Required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `updateMultiplier`. | +| `OPERATOR_ROLE` | `keccak256("OPERATOR_ROLE")`
`0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | Grants infinite allowance from every holder. On B20Asset, also required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `updateMultiplier`. | ## Policy types diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 08a2c793..374b255f 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -12,7 +12,7 @@ | `AccessControlUnauthorizedAccount(address account, bytes32 neededRole)` | `0xe2517d3f` | `account` does not hold `neededRole`. | | `Unauthorized()` | `0x82b42900` | Caller failed a positional authorization check that isn't expressible as "missing role X". | | `ContractPaused(uint8 feature)` | `0xfd8c4245` | The `PausableFeature` covering the operation is currently paused. | -| `InsufficientAllowance(address spender, uint256 allowance, uint256 needed)` | `0x192b9e4e` | `spender`'s allowance is less than `needed` for the requested `transferFrom`. | +| `InsufficientAllowance(address spender, uint256 allowance, uint256 needed)` | `0x192b9e4e` | `spender` does not hold `OPERATOR_ROLE`, and its allowance is less than `needed` for the requested `transferFrom`. | | `InsufficientBalance(address sender, uint256 balance, uint256 needed)` | `0xdb42144d` | `sender`'s balance is less than `needed` for the requested transfer or burn. | | `InvalidSender(address sender)` | `0x4c14f64c` | The transfer's source address is invalid (typically `address(0)`). | | `InvalidReceiver(address receiver)` | `0x9cfea583` | The transfer's destination address is invalid (typically `address(0)`). | diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 60d96619..0d833f87 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -232,6 +232,10 @@ interface IB20 { /// @return Role constant. function METADATA_ROLE() external view returns (bytes32); + /// @notice Grants an infinite allowance from every holder for `transferFrom` and `transferFromWithMemo`. + /// @return Role constant. + function OPERATOR_ROLE() external view returns (bytes32); + /*////////////////////////////////////////////////////////////// POLICY TYPE CONSTANTS //////////////////////////////////////////////////////////////*/ @@ -304,7 +308,8 @@ interface IB20 { /// @return Current balance. function balanceOf(address account) external view returns (uint256); - /// @notice Allowance granted by `owner` to `spender`. + /// @notice Allowance granted by `owner` to `spender`. Returns `type(uint256).max` when `spender` holds + /// `OPERATOR_ROLE`, regardless of the stored allowance. /// /// @param owner Allowance owner. /// @param spender Allowance spender. @@ -327,12 +332,14 @@ interface IB20 { /// @return Always `true` on success. function transfer(address to, uint256 amount) external returns (bool); - /// @notice Transfers `amount` from `from` to `to` using `msg.sender`'s allowance. Emits `Transfer`. + /// @notice Transfers `amount` from `from` to `to` using `msg.sender`'s allowance or `OPERATOR_ROLE`. + /// Emits `Transfer`. /// /// @dev Reverts with `ContractPaused(TRANSFER)` when `TRANSFER` is paused. /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. /// @dev Reverts with `InvalidSender` when `from == address(0)`. - /// @dev Reverts with `InsufficientAllowance` when the caller's allowance from `from` is below `amount`. + /// @dev Reverts with `InsufficientAllowance` when the caller does not hold `OPERATOR_ROLE` and its allowance + /// from `from` is below `amount`. /// @dev Reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender != from` and `msg.sender` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `from` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. @@ -345,7 +352,8 @@ interface IB20 { /// @return Always `true` on success. function transferFrom(address from, address to, uint256 amount) external returns (bool); - /// @notice Sets `spender`'s allowance to `amount`. Not gated by any policy or by pause. Emits `Approval`. + /// @notice Sets `spender`'s stored allowance to `amount`. Not gated by any policy or by pause. Emits `Approval`. + /// This does not limit a spender that holds `OPERATOR_ROLE`. /// /// @dev Reverts with `InvalidApprover` when `msg.sender == address(0)`. /// @dev Reverts with `InvalidSpender` when `spender == address(0)`. diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 6afd4bd8..21f1421d 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -109,16 +109,6 @@ interface IB20Asset is /// @notice Emitted by `announce` to close the bracket opened by the paired `Announcement` with the same `id`. event EndAnnouncement(string id); - /*////////////////////////////////////////////////////////////// - ROLE CONSTANTS - //////////////////////////////////////////////////////////////*/ - - /// @notice Required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and - /// `updateUIMultiplier`. The metadata setters (`updateName`, `updateSymbol`, - /// `updateExtraMetadata`) are gated by the inherited `METADATA_ROLE` instead. - /// @return Role constant. - function OPERATOR_ROLE() external view returns (bytes32); - /*////////////////////////////////////////////////////////////// PRECISION //////////////////////////////////////////////////////////////*/ diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index 4b620530..7426c155 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -34,6 +34,7 @@ library B20FactoryLib { /// `address(0)` fields are skipped at bootstrap. /// /// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20StablecoinCreateParams.initialAdmin`, not this struct. + /// @dev Append `encodeGrantRole(B20Constants.OPERATOR_ROLE, operator)` when the Stablecoin needs an operator. struct B20RoleHolders { /// @dev Account granted `MINT_ROLE`. address minter; @@ -50,7 +51,7 @@ library B20FactoryLib { } /// @notice Bootstrap role-grant bundle for `B20Variant.ASSET`. Superset of `B20RoleHolders` - /// with an `OPERATOR_ROLE` slot. + /// with an `OPERATOR_ROLE` convenience slot. /// /// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20AssetCreateParams.initialAdmin`, not this struct. struct B20AssetRoleHolders { diff --git a/test/lib/B20AssetTest.sol b/test/lib/B20AssetTest.sol index 087bfd96..18a4b087 100644 --- a/test/lib/B20AssetTest.sol +++ b/test/lib/B20AssetTest.sol @@ -10,17 +10,13 @@ import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; /// Extends `B20Test` for the inherited test surface (actors, labels, /// setUp wiring, the `_singleFeature` helper, the `_grantRole` / /// `_mint` / `_pause` action wrappers, and the asset-variant token -/// deployed by `_deployToken`). Adds the variant-specific role holder -/// (`operator`) plus helpers for the announcement, multiplier, -/// and extra-metadata surfaces. +/// deployed by `_deployToken`). Adds helpers for the announcement, +/// multiplier, and extra-metadata surfaces. /// /// The inherited `token` member is typed `IB20`. Tests that need the /// variant-only surface (`announce`, `batchMint`, etc.) cast inline via /// the `asset` view-helper. contract B20AssetTest is B20Test { - // -- Asset-variant role-holder actors -- - address internal operator = makeAddr("operator"); - // ============================================================ // ASSET-VARIANT EXTRA-METADATA FIXTURES // ============================================================ @@ -42,12 +38,6 @@ contract B20AssetTest is B20Test { /// @notice Example metadata-entry key #3. string internal constant METADATA_EXAMPLE_3 = "reference"; - // -- Setup -- - function setUp() public virtual override { - super.setUp(); - vm.label(operator, "operator"); - } - // ============================================================ // VARIANT CAST CONVENIENCE // ============================================================ @@ -58,17 +48,6 @@ contract B20AssetTest is B20Test { return IB20Asset(address(token)); } - // ============================================================ - // ASSET-ROLE HELPERS - // ============================================================ - - /// @notice Grants `OPERATOR_ROLE` to the `operator` actor as - /// the admin, idempotent. - function _grantOperator() internal { - bytes32 role = asset().OPERATOR_ROLE(); - if (!token.hasRole(role, operator)) _grantRole(role, operator); - } - // ============================================================ // MULTIPLIER HELPERS // ============================================================ @@ -144,16 +123,4 @@ contract B20AssetTest is B20Test { blobs = new bytes[](1); blobs[0] = blob; } - - // ============================================================ - // VARIANT-ONLY CONSTANTS - // ============================================================ - // Compile-time copies of the contract's variant-only constants. - // Tests reference these when they need the value in a context that - // can't make a contract call (e.g. inside a struct literal). The - // values match `asset().OPERATOR_ROLE()` etc. by construction; - // the per-constant test in `test/unit/B20Asset/constants/` pins - // that down. - - bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); } diff --git a/test/lib/B20Test.sol b/test/lib/B20Test.sol index cc517c96..79ad2ab6 100644 --- a/test/lib/B20Test.sol +++ b/test/lib/B20Test.sol @@ -19,7 +19,7 @@ import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; /// /// On top of the inherited factory actors, this contract adds the /// token-specific role-holders (`minter`, `burner`, `pauser`, -/// `unpauser`, `burnBlocker`) so role-gated tests have explicit named +/// `unpauser`, `burnBlocker`, `operator`) so role-gated tests have explicit named /// accounts to grant roles to in setUp's initCalls. contract B20Test is B20FactoryTest { // Role constants (DEFAULT_ADMIN_ROLE, MINT_ROLE, BURN_ROLE, @@ -27,7 +27,8 @@ contract B20Test is B20FactoryTest { // policy-type constants (TRANSFER_SENDER_POLICY, TRANSFER_RECEIVER_POLICY, // TRANSFER_EXECUTOR_POLICY, MINT_RECEIVER_POLICY) are NOT redeclared here. // Tests reference them directly from MockB20 as `MINT_ROLE` - // etc. — single source of truth, no drift risk. + // etc. — single source of truth, no drift risk. The local OPERATOR_ROLE + // copy remains for Asset test call sites that need a compile-time value. // // Built-in policy sentinel IDs likewise live on MockPolicyRegistry as // `ALWAYS_ALLOW_ID` / `ALWAYS_BLOCK_ID`. @@ -38,6 +39,9 @@ contract B20Test is B20FactoryTest { address internal pauser = makeAddr("pauser"); address internal unpauser = makeAddr("unpauser"); address internal burnBlocker = makeAddr("burnBlocker"); + address internal operator = makeAddr("operator"); + + bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); // -- Token under test -- /// @notice Asset-variant `IB20` token deployed in `setUp`. @@ -52,6 +56,7 @@ contract B20Test is B20FactoryTest { vm.label(pauser, "pauser"); vm.label(unpauser, "unpauser"); vm.label(burnBlocker, "burnBlocker"); + vm.label(operator, "operator"); token = _deployToken(); vm.label(address(token), "token"); @@ -107,6 +112,13 @@ contract B20Test is B20FactoryTest { token.grantRole(role, account); } + /// @notice Grants `OPERATOR_ROLE` to the `operator` actor as the admin, idempotently. + function _grantOperator() internal { + if (!token.hasRole(B20Constants.OPERATOR_ROLE, operator)) { + _grantRole(B20Constants.OPERATOR_ROLE, operator); + } + } + /// @notice Mints `amount` to `to`, lazily granting `MINT_ROLE` to the /// `minter` actor on first call. /// @dev Most balance-setup needs in tests reduce to "give this account some diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 05142a2d..151f5813 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -89,6 +89,7 @@ abstract contract MockB20 is IB20 { bytes32 public constant PAUSE_ROLE = B20Constants.PAUSE_ROLE; bytes32 public constant UNPAUSE_ROLE = B20Constants.UNPAUSE_ROLE; bytes32 public constant METADATA_ROLE = B20Constants.METADATA_ROLE; + bytes32 public constant OPERATOR_ROLE = B20Constants.OPERATOR_ROLE; /// @notice Policy-type constants. Same `keccak256` convention as roles. bytes32 public constant TRANSFER_SENDER_POLICY = B20Constants.TRANSFER_SENDER_POLICY; @@ -177,6 +178,7 @@ abstract contract MockB20 is IB20 { } function allowance(address owner, address spender) external view returns (uint256) { + if (hasRole(OPERATOR_ROLE, spender)) return type(uint256).max; return MockB20Storage.layout().allowances[owner][spender]; } @@ -196,12 +198,8 @@ abstract contract MockB20 is IB20 { returns (bool) { _requireNonZeroActors(from, to); - // Allowance is consumed unconditionally — including during the factory - // bootstrap window (`_isPrivileged()`). Matches the Rust precompile, - // which carves no `privileged` exception for allowance accounting; - // only the executor-policy check below is bypassed - // for a privileged caller. An infinite allowance is still not - // decremented (handled inside `_consumeAllowance`). + // Factory privilege does not bypass allowance accounting. OPERATOR_ROLE + // and the infinite-allowance sentinel do bypass it inside `_consumeAllowance`. _consumeAllowance(from, msg.sender, amount); if (!_isPrivileged() && msg.sender != from) { // Read the executor policy ID out of the transfer-side packed @@ -246,10 +244,8 @@ abstract contract MockB20 is IB20 { returns (bool) { _requireNonZeroActors(from, to); - // Allowance is consumed unconditionally — including during the factory - // bootstrap window — matching the Rust precompile. - // Only the executor-policy check below is bypassed for a privileged - // caller; infinite allowance is still not decremented. + // Factory privilege does not bypass allowance accounting. OPERATOR_ROLE + // and the infinite-allowance sentinel do bypass it inside `_consumeAllowance`. _consumeAllowance(from, msg.sender, amount); if (!_isPrivileged() && msg.sender != from) { uint64 executorPolicyId = MockB20Storage.layout().transferPolicyIds.executor; @@ -729,6 +725,8 @@ abstract contract MockB20 is IB20 { } function _consumeAllowance(address owner, address spender, uint256 amount) internal { + if (hasRole(OPERATOR_ROLE, spender)) return; + uint256 current = MockB20Storage.layout().allowances[owner][spender]; if (current != type(uint256).max) { if (current < amount) revert InsufficientAllowance(spender, current, amount); @@ -752,12 +750,10 @@ abstract contract MockB20 is IB20 { /// every external caller (`transfer`, `transferFrom`, /// `transferWithMemo`, `transferFromWithMemo`) before reaching /// this helper. `transferFrom` / `transferFromWithMemo` - /// additionally consume the allowance (unconditionally — - /// including in the bootstrap window, matching the Rust - /// precompile) and check the executor - /// policy in their bodies before calling here; only the - /// executor-policy check honors the bootstrap bypass, - /// consistent with the sender/receiver policy bypass below. + /// additionally consume the allowance unless the caller holds + /// `OPERATOR_ROLE`, and check the executor policy in their bodies + /// before calling here. Only the policy checks honor the bootstrap + /// bypass. function _transfer(address from, address to, uint256 amount) internal { if (!_isPrivileged()) { // One SLOAD pulls both policy IDs we need for the transfer diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 4902e099..7df55f5d 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -62,8 +62,6 @@ contract MockB20Asset is MockB20, IB20Asset { // CONSTANTS // ============================================================ - bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); - /// @notice Fixed-point precision for the multiplier. `1e18` (one /// WAD) is the standard DeFi convention; `toScaledBalance` /// and `scaledBalanceOf` divide by this after multiplying diff --git a/test/unit/B20/erc20/allowance.t.sol b/test/unit/B20/erc20/allowance.t.sol index 1112bf45..13c823d2 100644 --- a/test/unit/B20/erc20/allowance.t.sol +++ b/test/unit/B20/erc20/allowance.t.sol @@ -2,6 +2,8 @@ pragma solidity ^0.8.20; import {B20Test} from "base-std-test/lib/B20Test.sol"; +import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; +import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; contract B20AllowanceTest is B20Test { /// @notice Verifies allowance returns zero for any unconfigured (owner, spender) pair @@ -55,4 +57,36 @@ contract B20AllowanceTest is B20Test { token.allowance(owner, spender), allowanceAmount - spendAmount, "allowance must decrease by spent amount" ); } + + /// @notice Verifies every holder reports an infinite allowance for an operator + /// @dev Role membership overrides the allowance view without changing the stored allowance. + function test_allowance_success_operatorReadsAsInfinite(address owner, uint256 storedAllowance) public { + _assumeValidActor(owner); + + vm.prank(owner); + token.approve(operator, storedAllowance); + _grantOperator(); + + assertEq(token.allowance(owner, operator), type(uint256).max, "operator allowance must read as infinite"); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, operator))), + storedAllowance, + "operator role must not overwrite stored allowance" + ); + } + + /// @notice Verifies revoking OPERATOR_ROLE restores the holder's stored allowance + /// @dev Role revocation removes only the synthetic infinite allowance. + function test_allowance_success_revokedOperatorReadsStoredAllowance(address owner, uint256 storedAllowance) public { + _assumeValidActor(owner); + + vm.prank(owner); + token.approve(operator, storedAllowance); + _grantOperator(); + + vm.prank(admin); + token.revokeRole(B20Constants.OPERATOR_ROLE, operator); + + assertEq(token.allowance(owner, operator), storedAllowance, "revoked operator must read stored allowance"); + } } diff --git a/test/unit/B20/erc20/transferFrom.t.sol b/test/unit/B20/erc20/transferFrom.t.sol index 0b3197a9..b2f94112 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -57,6 +57,28 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } + /// @notice Verifies OPERATOR_ROLE does not bypass TRANSFER_EXECUTOR_POLICY + /// @dev The role waives allowance only; executor policy remains active. + function test_transferFrom_revert_operatorExecutorPolicyForbids(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(operator != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantOperator(); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(operator); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transferFrom(from, to, amount); + } + /// @notice Verifies transferFrom reverts when from is not authorized under TRANSFER_SENDER_POLICY /// @dev Sender-side policy guard fires inside _transfer. function test_transferFrom_revert_senderPolicyForbids(address caller, address from, address to, uint256 amount) @@ -83,6 +105,27 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } + /// @notice Verifies OPERATOR_ROLE does not bypass TRANSFER_SENDER_POLICY + /// @dev The role waives allowance only; sender policy remains active. + function test_transferFrom_revert_operatorSenderPolicyForbids(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantOperator(); + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(operator); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_SENDER_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transferFrom(from, to, amount); + } + /// @notice Verifies transferFrom reverts when to is not authorized under TRANSFER_RECEIVER_POLICY /// @dev Receiver-side policy guard fires inside _transfer. function test_transferFrom_revert_receiverPolicyForbids(address caller, address from, address to, uint256 amount) @@ -109,6 +152,27 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } + /// @notice Verifies OPERATOR_ROLE does not bypass TRANSFER_RECEIVER_POLICY + /// @dev The role waives allowance only; receiver policy remains active. + function test_transferFrom_revert_operatorReceiverPolicyForbids(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantOperator(); + _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(operator); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_RECEIVER_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transferFrom(from, to, amount); + } + /// @notice Verifies transferFrom reverts when caller's allowance is insufficient /// @dev Allowance precondition fires first when caller != from; checks InsufficientAllowance function test_transferFrom_revert_insufficientAllowance(address caller, address from, address to, uint256 amount) @@ -126,6 +190,24 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } + /// @notice Verifies a revoked operator must use the holder's stored allowance + /// @dev Revocation removes the allowance bypass immediately. + function test_transferFrom_revert_revokedOperatorInsufficientAllowance(address from, address to, uint256 amount) + public + { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 1, type(uint256).max); + + _grantOperator(); + vm.prank(admin); + token.revokeRole(B20Constants.OPERATOR_ROLE, operator); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, operator, 0, amount)); + token.transferFrom(from, to, amount); + } + /// @notice Verifies transferFrom reverts when from's balance is insufficient /// @dev Balance precondition fires inside _transfer, after allowance consumption. function test_transferFrom_revert_insufficientBalance(address caller, address from, address to, uint256 amount) @@ -252,6 +334,62 @@ contract B20TransferFromTest is B20Test { ); } + /// @notice Verifies approving zero does not opt a holder out of operator spending + /// @dev OPERATOR_ROLE supplies independent infinite authority over every holder balance. + function test_transferFrom_success_operatorSpendsAfterHolderApprovesZero(address from, address to, uint256 amount) + public + { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _mint(from, amount); + vm.prank(from); + token.approve(operator, 0); + _grantOperator(); + + vm.prank(operator); + token.transferFrom(from, to, amount); + + assertEq(token.balanceOf(to), amount, "operator must spend despite zero stored allowance"); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + 0, + "zero stored allowance must remain unchanged" + ); + } + + /// @notice Verifies operator spending does not consume a finite stored allowance + /// @dev Role authority and holder-managed allowance accounting remain independent. + function test_transferFrom_success_operatorPreservesStoredAllowance( + address from, + address to, + uint256 storedAllowance, + uint256 amount + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + storedAllowance = bound(storedAllowance, 0, type(uint256).max - 1); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _mint(from, amount); + vm.prank(from); + token.approve(operator, storedAllowance); + _grantOperator(); + + vm.prank(operator); + token.transferFrom(from, to, amount); + + assertEq(token.allowance(from, operator), type(uint256).max, "operator allowance must remain infinite"); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + storedAllowance, + "stored allowance must not be consumed" + ); + } + /// @notice Verifies transferFrom emits Transfer(from, to, amount) /// @dev Event integrity for the transferFrom path; canonical Transfer test lives in transfer.t.sol function test_transferFrom_success_emitsTransfer(address caller, address from, address to, uint256 amount) public { diff --git a/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol b/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol index faed358e..c735fb43 100644 --- a/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol +++ b/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol @@ -12,7 +12,8 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// @notice `transferFromWithMemo` carries the same preconditions as /// `transferFrom`; the memo parameter adds no new revert conditions. /// -/// **Canonical order (Solidity reference, when `msg.sender != from`):** +/// **Canonical order (Solidity reference, when `msg.sender != from` and the caller lacks +/// `OPERATOR_ROLE`):** /// 1. PAUSE (`whenNotPaused(TRANSFER)` modifier) → `ContractPaused` /// 2. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 3. ZERO-SENDER (`from == address(0)`) → `InvalidSender` diff --git a/test/unit/B20/erc20/transferFrom_revertOrder.t.sol b/test/unit/B20/erc20/transferFrom_revertOrder.t.sol index ba84c27a..eacae3de 100644 --- a/test/unit/B20/erc20/transferFrom_revertOrder.t.sol +++ b/test/unit/B20/erc20/transferFrom_revertOrder.t.sol @@ -16,7 +16,7 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// work in the entrypoint body. /// /// **Canonical order (Solidity reference, when -/// `msg.sender != from`):** +/// `msg.sender != from` and the caller lacks `OPERATOR_ROLE`):** /// 1. PAUSE (`whenNotPaused(TRANSFER)` modifier) → `ContractPaused` /// 2. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 3. ZERO-SENDER (`from == address(0)`) → `InvalidSender` diff --git a/test/unit/B20/memo/transferFromWithMemo.t.sol b/test/unit/B20/memo/transferFromWithMemo.t.sol index 9a5a6e86..c993d768 100644 --- a/test/unit/B20/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20/memo/transferFromWithMemo.t.sol @@ -6,6 +6,7 @@ import {IB20} from "base-std/interfaces/IB20.sol"; import {B20Test} from "base-std-test/lib/B20Test.sol"; import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; +import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; contract B20TransferFromWithMemoTest is B20Test { /// @notice Verifies transferFromWithMemo inherits all transferFrom guards @@ -30,6 +31,33 @@ contract B20TransferFromWithMemoTest is B20Test { token.transferFromWithMemo(from, to, amount, memo); } + /// @notice Verifies OPERATOR_ROLE does not bypass the memo transfer's executor policy + /// @dev The memo variant preserves the same policy boundary as transferFrom. + function test_transferFromWithMemo_revert_operatorExecutorPolicyForbids( + address from, + address to, + uint256 amount, + bytes32 memo + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(operator != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantOperator(); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(operator); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transferFromWithMemo(from, to, amount, memo); + } + /// @notice Verifies transferFromWithMemo performs the same balance and allowance updates as transferFrom /// @dev Accounting and spend-tracking unchanged from transferFrom. /// Paired slot assertions confirm both balance slots and the @@ -127,6 +155,35 @@ contract B20TransferFromWithMemoTest is B20Test { assertTrue(token.transferFromWithMemo(from, to, amount, memo), "transferFromWithMemo must return true"); } + /// @notice Verifies approving zero does not opt a holder out of memo transfers by an operator + /// @dev OPERATOR_ROLE bypasses allowance without changing the stored zero value. + function test_transferFromWithMemo_success_operatorSpendsAfterHolderApprovesZero( + address from, + address to, + uint256 amount, + bytes32 memo + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _mint(from, amount); + vm.prank(from); + token.approve(operator, 0); + _grantOperator(); + + vm.prank(operator); + token.transferFromWithMemo(from, to, amount, memo); + + assertEq(token.balanceOf(to), amount, "operator memo transfer must move the balance"); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + 0, + "zero stored allowance must remain unchanged" + ); + } + // ============================================================ // REGRESSION: SELF-CALLER ALLOWANCE // ============================================================ diff --git a/test/unit/B20/roles/getRoleAdmin.t.sol b/test/unit/B20/roles/getRoleAdmin.t.sol index e430f851..ff5e8a58 100644 --- a/test/unit/B20/roles/getRoleAdmin.t.sol +++ b/test/unit/B20/roles/getRoleAdmin.t.sol @@ -5,6 +5,16 @@ import {B20Test} from "base-std-test/lib/B20Test.sol"; import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; contract B20GetRoleAdminTest is B20Test { + /// @notice Verifies OPERATOR_ROLE is administered by DEFAULT_ADMIN_ROLE on a fresh token + /// @dev Pins the selected governance default for issuer-approved operators. + function test_getRoleAdmin_success_operatorDefaultsToAdminRole() public view { + assertEq( + token.getRoleAdmin(B20Constants.OPERATOR_ROLE), + B20Constants.DEFAULT_ADMIN_ROLE, + "operator role must default to DEFAULT_ADMIN_ROLE" + ); + } + /// @notice Verifies getRoleAdmin returns DEFAULT_ADMIN_ROLE for any role that hasn't been customized /// @dev OZ AccessControl default: every role is administered by DEFAULT_ADMIN_ROLE unless overridden. /// DEFAULT_ADMIN_ROLE itself is its own admin (returns bytes32(0)), so filter that out here; diff --git a/test/unit/B20/roles/roleConstants.t.sol b/test/unit/B20/roles/roleConstants.t.sol index abeacf54..e219e151 100644 --- a/test/unit/B20/roles/roleConstants.t.sol +++ b/test/unit/B20/roles/roleConstants.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.20; import {B20Test} from "base-std-test/lib/B20Test.sol"; import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; -/// @notice Folds the seven trivial role-constant readers into one +/// @notice Folds the eight trivial role-constant readers into one /// file since each is a one-stub assertion against a fixed keccak /// digest. Substantive role-related functions (`grantRole`, /// `revokeRole`, etc.) live in their own files. @@ -59,4 +59,11 @@ contract B20RoleConstantsTest is B20Test { assertEq(token.METADATA_ROLE(), keccak256("METADATA_ROLE"), "B20Constants.METADATA_ROLE digest"); assertEq(token.METADATA_ROLE(), B20Constants.METADATA_ROLE, "must match B20Test's local constant"); } + + /// @notice Verifies OPERATOR_ROLE returns keccak256("OPERATOR_ROLE") + /// @dev Constant stability for operator allowance and Asset administration. + function test_OPERATOR_ROLE_success_matchesExpected() public view { + assertEq(token.OPERATOR_ROLE(), keccak256("OPERATOR_ROLE"), "B20Constants.OPERATOR_ROLE digest"); + assertEq(token.OPERATOR_ROLE(), B20Constants.OPERATOR_ROLE, "must match B20Test's local constant"); + } } diff --git a/test/unit/B20/roles/setRoleAdmin.t.sol b/test/unit/B20/roles/setRoleAdmin.t.sol index 716beda4..40cb6342 100644 --- a/test/unit/B20/roles/setRoleAdmin.t.sol +++ b/test/unit/B20/roles/setRoleAdmin.t.sol @@ -38,6 +38,28 @@ contract B20SetRoleAdminTest is B20Test { ); } + /// @notice Verifies OPERATOR_ROLE administration can be delegated from DEFAULT_ADMIN_ROLE + /// @dev Pins the selected governance model for the exact operator role. + function test_setRoleAdmin_success_delegatesOperatorRoleAdministration(address delegatedAdmin) public { + _assumeValidCaller(delegatedAdmin); + vm.assume(delegatedAdmin != admin); + bytes32 customAdminRole = keccak256("CUSTOM_ADMIN_ROLE"); + + vm.startPrank(admin); + token.grantRole(customAdminRole, delegatedAdmin); + token.setRoleAdmin(B20Constants.OPERATOR_ROLE, customAdminRole); + vm.stopPrank(); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, admin, customAdminRole)); + token.grantRole(B20Constants.OPERATOR_ROLE, operator); + + vm.prank(delegatedAdmin); + token.grantRole(B20Constants.OPERATOR_ROLE, operator); + + assertTrue(token.hasRole(B20Constants.OPERATOR_ROLE, operator), "delegated admin must grant operator role"); + } + /// @notice Verifies setRoleAdmin emits RoleAdminChanged(role, previousAdminRole, newAdminRole) /// @dev Event integrity; canonical RoleAdminChanged emission test. /// Every role on a fresh token is unconfigured, so the previous diff --git a/test/unit/B20Stablecoin/erc20/allowance.t.sol b/test/unit/B20Stablecoin/erc20/allowance.t.sol new file mode 100644 index 00000000..ed0ab718 --- /dev/null +++ b/test/unit/B20Stablecoin/erc20/allowance.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20StablecoinTest} from "base-std-test/lib/B20StablecoinTest.sol"; +import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; + +contract B20StablecoinAllowanceTest is B20StablecoinTest { + /// @notice Verifies Stablecoin reports infinite allowance for an operator + /// @dev Role membership overrides the view without changing the stored allowance. + function test_allowance_success_operatorReadsAsInfinite(address owner, uint256 storedAllowance) public { + _assumeValidActor(owner); + + vm.prank(owner); + token.approve(operator, storedAllowance); + _grantOperator(); + + assertEq(token.allowance(owner, operator), type(uint256).max, "operator allowance must be infinite"); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, operator))), + storedAllowance, + "stored allowance must remain unchanged" + ); + } +} diff --git a/test/unit/B20Stablecoin/erc20/transferFrom.t.sol b/test/unit/B20Stablecoin/erc20/transferFrom.t.sol new file mode 100644 index 00000000..99d15962 --- /dev/null +++ b/test/unit/B20Stablecoin/erc20/transferFrom.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IB20} from "base-std/interfaces/IB20.sol"; + +import {B20StablecoinTest} from "base-std-test/lib/B20StablecoinTest.sol"; +import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; +import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; +import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; + +contract B20StablecoinTransferFromTest is B20StablecoinTest { + /// @notice Verifies a Stablecoin operator remains subject to TRANSFER pause + /// @dev OPERATOR_ROLE waives allowance only. + function test_transferFrom_revert_operatorWhenTransferPaused(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantOperator(); + _pause(IB20.PausableFeature.TRANSFER); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.TRANSFER)); + token.transferFrom(from, to, amount); + } + + /// @notice Verifies a Stablecoin operator remains subject to TRANSFER_EXECUTOR_POLICY + /// @dev OPERATOR_ROLE waives allowance only. + function test_transferFrom_revert_operatorExecutorPolicyForbids(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(operator != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantOperator(); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(operator); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transferFrom(from, to, amount); + } + + /// @notice Verifies a Stablecoin operator can spend from a holder with zero allowance + /// @dev Confirms the shared allowance bypass applies to the Stablecoin variant. + function test_transferFrom_success_operatorSpendsWithoutAllowance(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _mint(from, amount); + _grantOperator(); + + vm.prank(operator); + token.transferFrom(from, to, amount); + + assertEq(token.balanceOf(to), amount, "operator must move holder balance"); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + 0, + "zero stored allowance must remain unchanged" + ); + } + + /// @notice Verifies a Stablecoin operator does not consume a finite stored allowance + /// @dev Role authority remains independent from holder-managed allowance state. + function test_transferFrom_success_operatorPreservesStoredAllowance( + address from, + address to, + uint256 storedAllowance, + uint256 amount + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + storedAllowance = bound(storedAllowance, 0, type(uint256).max - 1); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _mint(from, amount); + vm.prank(from); + token.approve(operator, storedAllowance); + _grantOperator(); + + vm.prank(operator); + token.transferFrom(from, to, amount); + + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + storedAllowance, + "stored allowance must not be consumed" + ); + } +} diff --git a/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol b/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol new file mode 100644 index 00000000..e1ede010 --- /dev/null +++ b/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IB20} from "base-std/interfaces/IB20.sol"; + +import {B20StablecoinTest} from "base-std-test/lib/B20StablecoinTest.sol"; +import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; +import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; + +contract B20StablecoinTransferFromWithMemoTest is B20StablecoinTest { + /// @notice Verifies a Stablecoin operator memo transfer remains subject to executor policy + /// @dev The memo variant preserves the same policy boundary as transferFrom. + function test_transferFromWithMemo_revert_operatorExecutorPolicyForbids( + address from, + address to, + uint256 amount, + bytes32 memo + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(operator != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantOperator(); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(operator); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transferFromWithMemo(from, to, amount, memo); + } + + /// @notice Verifies a Stablecoin operator can spend with a memo and zero allowance + /// @dev Confirms the shared memo allowance bypass applies to the Stablecoin variant. + function test_transferFromWithMemo_success_operatorSpendsWithoutAllowance( + address from, + address to, + uint256 amount, + bytes32 memo + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _mint(from, amount); + _grantOperator(); + + vm.prank(operator); + token.transferFromWithMemo(from, to, amount, memo); + + assertEq(token.balanceOf(to), amount, "operator memo transfer must move holder balance"); + } +} diff --git a/test/unit/B20Stablecoin/roles/roleConstants.t.sol b/test/unit/B20Stablecoin/roles/roleConstants.t.sol new file mode 100644 index 00000000..89ff0899 --- /dev/null +++ b/test/unit/B20Stablecoin/roles/roleConstants.t.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20StablecoinTest} from "base-std-test/lib/B20StablecoinTest.sol"; +import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; + +contract B20StablecoinRoleConstantsTest is B20StablecoinTest { + /// @notice Verifies Stablecoin exposes the shared OPERATOR_ROLE constant + /// @dev Pins the shared selector and role value on the Stablecoin variant. + function test_OPERATOR_ROLE_success_matchesExpected() public view { + assertEq(token.OPERATOR_ROLE(), keccak256("OPERATOR_ROLE"), "OPERATOR_ROLE digest"); + assertEq(token.OPERATOR_ROLE(), B20Constants.OPERATOR_ROLE, "OPERATOR_ROLE library value"); + } +} From 6161f39766c9ffe9f6273cd08c63b67e6c8ec215 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Thu, 10 Sep 2026 19:43:54 +0200 Subject: [PATCH 2/5] refactor(b20): separate spender authority from operators Co-authored-by: OpenCode --- CHANGELOG.md | 10 +-- ....md => 03_Denim_B20_authorized_spender.md} | 54 +++++------ changelog/README.md | 2 +- docs/concepts/multipliers.md | 2 +- docs/concepts/policies.md | 3 +- docs/concepts/roles-and-pause.md | 8 +- docs/concepts/token-types.md | 6 +- docs/guides/announcing-corporate-actions.md | 2 - docs/guides/scheduling-stock-splits.md | 2 - docs/overview.md | 4 +- docs/reference/constants.md | 3 +- docs/reference/errors.md | 2 +- src/interfaces/IB20.sol | 14 +-- src/interfaces/IB20Asset.sol | 10 +++ src/lib/B20Constants.sol | 1 + src/lib/B20FactoryLib.sol | 3 +- test/lib/B20AssetTest.sol | 20 +++++ test/lib/B20Test.sol | 19 ++-- test/lib/mocks/MockB20.sol | 12 +-- test/lib/mocks/MockB20Asset.sol | 4 +- test/unit/B20/erc20/allowance.t.sol | 36 +++++--- test/unit/B20/erc20/transferFrom.t.sol | 90 +++++++++++-------- .../transferFromWithMemo_revertOrder.t.sol | 2 +- .../B20/erc20/transferFrom_revertOrder.t.sol | 2 +- test/unit/B20/memo/transferFromWithMemo.t.sol | 26 +++--- test/unit/B20/roles/getRoleAdmin.t.sol | 10 +-- test/unit/B20/roles/roleConstants.t.sol | 16 ++-- test/unit/B20/roles/setRoleAdmin.t.sol | 17 ++-- test/unit/B20Asset/erc20/allowance.t.sol | 15 ++++ test/unit/B20Asset/erc20/transferFrom.t.sol | 23 +++++ .../B20Asset/memo/transferFromWithMemo.t.sol | 26 ++++++ test/unit/B20Stablecoin/erc20/allowance.t.sol | 26 ++++-- .../B20Stablecoin/erc20/transferFrom.t.sol | 67 +++++++++----- .../memo/transferFromWithMemo.t.sol | 38 +++++--- .../B20Stablecoin/roles/roleConstants.t.sol | 21 ++++- 35 files changed, 394 insertions(+), 202 deletions(-) rename changelog/{03_Denim_B20_operator_allowance.md => 03_Denim_B20_authorized_spender.md} (50%) create mode 100644 test/unit/B20Asset/erc20/allowance.t.sol create mode 100644 test/unit/B20Asset/erc20/transferFrom.t.sol create mode 100644 test/unit/B20Asset/memo/transferFromWithMemo.t.sol diff --git a/CHANGELOG.md b/CHANGELOG.md index c826eb46..9018143a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,27 +9,27 @@ in [`changelog/`](changelog/README.md). ### Status -Denim has not activated yet. Operator allowance behavior remains unavailable until Denim selects B20 logic v3. +Denim has not activated yet. Authorized spender allowance behavior remains unavailable until Denim selects B20 logic v3. ### Compatibility -Denim changes the behavior of existing `allowance`, `transferFrom`, and `transferFromWithMemo` selectors. It also moves the existing Asset `OPERATOR_ROLE()` getter onto the shared `IB20` surface, which makes that selector available on Stablecoin. +Denim adds the shared `AUTHORIZED_SPENDER_ROLE()` getter and changes the behavior of existing `allowance`, `transferFrom`, and `transferFromWithMemo` selectors for accounts that hold this role. ### Summary of changes | Product | Feature | Change | Details | | --- | --- | --- | --- | -| B20 (Asset and Stablecoin) | Issuer-approved operators | An account with `OPERATOR_ROLE` reads as having infinite allowance from every holder. Operator transfers do not consume stored allowances, but pause and transfer policies remain active. | [03_Denim_B20_operator_allowance](changelog/03_Denim_B20_operator_allowance.md) | +| B20 (Asset and Stablecoin) | Issuer-authorized spenders | An account with `AUTHORIZED_SPENDER_ROLE` reads as having infinite allowance from every holder. Authorized spender transfers do not consume stored allowances, but pause and transfer policies remain active. | [03_Denim_B20_authorized_spender](changelog/03_Denim_B20_authorized_spender.md) | ### Migration guidance #### Issuers -Audit every existing `OPERATOR_ROLE` holder before Denim activates. Asset already uses this role for announcements and multiplier administration, so those accounts gain authority to move holder balances. Stablecoin issuers must also audit generic grants of the same role hash. Revoke any assignment that should not gain this authority. +Grant `AUTHORIZED_SPENDER_ROLE` only to contracts and accounts that may move every holder's balance. Existing Asset `OPERATOR_ROLE` assignments keep their announcement and multiplier capabilities and do not gain spending authority. #### Wallets and integrators -Treat `allowance(owner, spender) == type(uint256).max` as possible role-based authority. A holder cannot revoke that authority with `approve(spender, 0)`; only the role administrator can remove it. Continue to enforce transfer policy failures and paused-transfer failures for operator calls. +Treat `allowance(owner, spender) == type(uint256).max` as possible role-based authority. A holder cannot revoke that authority with `approve(spender, 0)`; only the role administrator can remove it. Continue to enforce transfer policy failures and paused-transfer failures for authorized spender calls. ## Cobalt diff --git a/changelog/03_Denim_B20_operator_allowance.md b/changelog/03_Denim_B20_authorized_spender.md similarity index 50% rename from changelog/03_Denim_B20_operator_allowance.md rename to changelog/03_Denim_B20_authorized_spender.md index b502e8da..8d03fe1b 100644 --- a/changelog/03_Denim_B20_operator_allowance.md +++ b/changelog/03_Denim_B20_authorized_spender.md @@ -1,12 +1,12 @@ -# Denim: Issuer-Approved Operators +# Denim: Issuer-Authorized Spenders -- **Feature Name**: operator_allowance +- **Feature Name**: authorized_spender - **Start Date**: 2026-09-10 -- **Title**: Issuer-approved infinite allowances through `OPERATOR_ROLE` +- **Title**: Issuer-authorized infinite allowances through `AUTHORIZED_SPENDER_ROLE` ## Summary -Denim lets a B20 issuer grant an account permission to spend from every holder without holder approvals. The issuer uses the existing role system. Denim adds no implicit grants; any existing or future grant of the `OPERATOR_ROLE` hash receives this authority. +Denim lets a B20 issuer grant an account permission to spend from every holder without holder approvals. The dedicated `AUTHORIZED_SPENDER_ROLE` keeps this authority separate from the Asset-only `OPERATOR_ROLE`. ## Motivation @@ -16,27 +16,27 @@ Some token integrations need one contract, such as a router or settlement system ### Interface changes -`OPERATOR_ROLE()` moves to the shared [`IB20`](../src/interfaces/IB20.sol) interface. +`AUTHORIZED_SPENDER_ROLE()` is added to the shared [`IB20`](../src/interfaces/IB20.sol) interface. | Function | Selector | Denim change | | --- | --- | --- | -| `OPERATOR_ROLE()` | `0xf5b541a6` | Already available on Asset; newly available on Stablecoin through `IB20`. | -| `allowance(address,address)` | `0xdd62ed3e` | Returns `type(uint256).max` when `spender` holds `OPERATOR_ROLE`. | -| `transferFrom(address,address,uint256)` | `0x23b872dd` | Skips allowance validation and consumption when the caller holds `OPERATOR_ROLE`. | -| `transferFromWithMemo(address,address,uint256,bytes32)` | `0x929c2539` | Applies the same operator behavior as `transferFrom`. | +| `AUTHORIZED_SPENDER_ROLE()` | `0xef97aa21` | New shared role getter on Asset and Stablecoin. | +| `allowance(address,address)` | `0xdd62ed3e` | Returns `type(uint256).max` when `spender` holds `AUTHORIZED_SPENDER_ROLE`. | +| `transferFrom(address,address,uint256)` | `0x23b872dd` | Skips allowance validation and consumption when the caller holds `AUTHORIZED_SPENDER_ROLE`. | +| `transferFromWithMemo(address,address,uint256,bytes32)` | `0x929c2539` | Applies the same authorized spender behavior as `transferFrom`. | -The role value remains: +The role value is: ```solidity -keccak256("OPERATOR_ROLE") -// 0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929 +keccak256("AUTHORIZED_SPENDER_ROLE") +// 0xb0e3ae34a3ebd864ed280a15abe71cbcaf59103e086737862f5bbccae6a44b37 ``` -No new mutator, event, error, or storage slot is added. Issuers manage membership with `grantRole`, `revokeRole`, and `renounceRole`. `getRoleAdmin(OPERATOR_ROLE)` defaults to `DEFAULT_ADMIN_ROLE` and remains delegable through `setRoleAdmin`. +No new mutator, event, error, or storage slot is added. Issuers manage membership with `grantRole`, `revokeRole`, and `renounceRole`. `getRoleAdmin(AUTHORIZED_SPENDER_ROLE)` defaults to `DEFAULT_ADMIN_ROLE` and remains delegable through `setRoleAdmin`. ### Behavioral changes -For a caller that holds `OPERATOR_ROLE`: +For a caller that holds `AUTHORIZED_SPENDER_ROLE`: - `allowance(owner, caller)` returns `type(uint256).max` for every `owner`. - `transferFrom` and `transferFromWithMemo` do not read or decrement the stored allowance. @@ -49,13 +49,13 @@ For any other caller, allowance behavior remains unchanged. A finite allowance d ### Storage layout -There is no storage change. Operator membership uses the existing role mapping. Holder allowances remain in their existing slots, including while the spender holds `OPERATOR_ROLE`. +There is no storage change. Authorized spender membership uses the existing role mapping. Holder allowances remain in their existing slots while the spender holds `AUTHORIZED_SPENDER_ROLE`. ## Example ```solidity -bytes32 operatorRole = token.OPERATOR_ROLE(); -token.grantRole(operatorRole, address(router)); +bytes32 spenderRole = token.AUTHORIZED_SPENDER_ROLE(); +token.grantRole(spenderRole, address(router)); // Returns type(uint256).max even when alice never approved the router. uint256 effectiveAllowance = token.allowance(alice, address(router)); @@ -66,9 +66,10 @@ uint256 effectiveAllowance = token.allowance(alice, address(router)); ## Design Decisions -- Reuse the existing RBAC set instead of adding an operator registry or policy scope. +- Add a dedicated role to keep holder-spending authority separate from Asset operations. +- Reuse the existing RBAC set instead of adding a spender registry or policy scope. - Keep the set empty by default. No address, including Permit2, receives implicit authority. -- Grant infinite authority only. Per-operator caps are not supported. +- Grant infinite authority only. Per-spender caps are not supported. - Do not add holder opt-out state. Issuer role revocation is the removal path. - Reuse `DEFAULT_ADMIN_ROLE` as the default role administrator. - Waive only allowance checks. Compliance policies and pause remain independent controls. @@ -77,16 +78,15 @@ uint256 effectiveAllowance = token.allowance(alice, address(router)); ### Issuers -1. Enumerate historical `RoleGranted` and `RoleRevoked` events for `OPERATOR_ROLE`. -2. Revoke Asset operators that must not gain holder-spending authority before Denim activates. -3. Check Stablecoin tokens for generic grants of the same role hash, even though the getter was not previously on the Stablecoin interface. -4. Check `getRoleAdmin(OPERATOR_ROLE)` because an earlier `setRoleAdmin` call may have delegated role administration. -5. Grant the role only to contracts and accounts that may move every holder's balance. +1. Check for any historical generic grant of the `AUTHORIZED_SPENDER_ROLE` hash. +2. Check `getRoleAdmin(AUTHORIZED_SPENDER_ROLE)` if the role hash was already configured. +3. Grant the role only to contracts and accounts that may move every holder's balance. +4. Keep `OPERATOR_ROLE` assignments unchanged unless the Asset operator itself also needs spending authority. ### Integrators 1. Do not assume that `allowance == type(uint256).max` came from holder approval. -2. Do not present `approve(operator, 0)` as a revocation path for role-based authority. -3. Continue to handle `ContractPaused`, `PolicyForbids`, and `InsufficientBalance` on operator transfers. +2. Do not present `approve(spender, 0)` as a revocation path for role-based authority. +3. Continue to handle `ContractPaused`, `PolicyForbids`, and `InsufficientBalance` on authorized spender transfers. -This change is ABI-compatible for Asset but behaviorally breaking for existing operator assignments. Stablecoin gains the additive `OPERATOR_ROLE()` selector and the same behavioral change on existing ERC-20 selectors. +Both variants gain the additive `AUTHORIZED_SPENDER_ROLE()` selector. Existing `OPERATOR_ROLE` assignments retain their previous capabilities. The existing ERC-20 selectors change behavior only for accounts that hold the new role hash. diff --git a/changelog/README.md b/changelog/README.md index 1c45e205..c21fdc44 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -27,7 +27,7 @@ Grouped by hardfork, one collapsible section per hardfork, newest first. | Product(s) | Change | Affected interfaces | Entry | | --- | --- | --- | --- | -| B20 Asset, B20 Stablecoin | Issuer-approved operators | `src/interfaces/IB20.sol` (shared surface) inherited by `src/interfaces/IB20Asset.sol`, `src/interfaces/IB20Stablecoin.sol` | [03_Denim_B20_operator_allowance](03_Denim_B20_operator_allowance.md) | +| B20 Asset, B20 Stablecoin | Issuer-authorized spenders | `src/interfaces/IB20.sol` (shared surface) inherited by `src/interfaces/IB20Asset.sol`, `src/interfaces/IB20Stablecoin.sol` | [03_Denim_B20_authorized_spender](03_Denim_B20_authorized_spender.md) |
diff --git a/docs/concepts/multipliers.md b/docs/concepts/multipliers.md index b2185b6b..9152a069 100644 --- a/docs/concepts/multipliers.md +++ b/docs/concepts/multipliers.md @@ -46,7 +46,7 @@ Convert a single amount with `toUIAmount(raw)` and `fromUIAmount(ui)` at that ef ### What it preserves -`balanceOf`, `transfer` amounts, `totalSupply`, and stored allowances stay raw. Protocols that use that ERC-20 surface do not see the split. The separate `OPERATOR_ROLE` rule can make `allowance(owner, operator)` return `type(uint256).max`; that value does not use the UI multiplier. +`balanceOf`, `transfer` amounts, `totalSupply`, and stored allowances stay raw. Protocols that use that ERC-20 surface do not see the split. The separate `AUTHORIZED_SPENDER_ROLE` rule can make `allowance(owner, spender)` return `type(uint256).max`; that value does not use the UI multiplier. The UI views above are opt-in. Protocols that call `balanceOfUI`, `scaledBalanceOf`, or `totalSupplyUI` do see the split. diff --git a/docs/concepts/policies.md b/docs/concepts/policies.md index b0008502..0b8c13c1 100644 --- a/docs/concepts/policies.md +++ b/docs/concepts/policies.md @@ -204,7 +204,7 @@ Most scopes deny when `isAuthorized` is `false` and revert `PolicyForbids`. `SEI | `SEIZE_HOLDER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means no account is seizable. | `from` | `true` | `AccountNotSeizable` | | `SEIZE_RECEIVER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means seize may send to any destination. | `to` | `false` | `PolicyForbids` | -`OPERATOR_ROLE` does not bypass these scopes. An operator skips only the allowance check in `transferFrom` and `transferFromWithMemo`. The executor, sender, and receiver policy checks still run. +`AUTHORIZED_SPENDER_ROLE` does not bypass these scopes. An authorized spender skips only the allowance check in `transferFrom` and `transferFromWithMemo`. The executor, sender, and receiver policy checks still run. ## 4. Example @@ -348,4 +348,3 @@ If the issuer later needs the same KYC list or-ed with a token-specific partner | `ChildPoliciesOutsideOfRange()` | A composite's child count is outside `[2, 4]` | | `InvalidChildPolicy(childPolicyId)` | A composite child is not an existing simple policy | | `NonPayable()` | ETH was attached to a registry call | - diff --git a/docs/concepts/roles-and-pause.md b/docs/concepts/roles-and-pause.md index d28a71e5..3613509d 100644 --- a/docs/concepts/roles-and-pause.md +++ b/docs/concepts/roles-and-pause.md @@ -33,12 +33,13 @@ Two functions always require `DEFAULT_ADMIN_ROLE`: `updatePolicy` and `updateSup | `PAUSE_ROLE` | `pause` | | `UNPAUSE_ROLE` | `unpause` | | `METADATA_ROLE` | `updateName`, `updateSymbol`, `updateContractURI`; Asset also gates `updateExtraMetadata` | -| `OPERATOR_ROLE` | Infinite `transferFrom` allowance from every holder; Asset also gates `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, deprecated `updateMultiplier` | +| `AUTHORIZED_SPENDER_ROLE` | Infinite `transferFrom` allowance from every holder | +| `OPERATOR_ROLE` | Asset-only: `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, deprecated `updateMultiplier` | -`OPERATOR_ROLE` is shared by Asset and Stablecoin. For any holder and operator, `allowance(holder, operator)` returns `type(uint256).max`. The operator can call `transferFrom` or `transferFromWithMemo` without holder approval, and those calls do not change the holder's stored allowance. `approve(operator, 0)` does not opt the holder out. The role administrator must revoke `OPERATOR_ROLE` to remove the authority. +`AUTHORIZED_SPENDER_ROLE` is shared by Asset and Stablecoin. For any holder and authorized spender, `allowance(holder, spender)` returns `type(uint256).max`. The authorized spender can call `transferFrom` or `transferFromWithMemo` without holder approval, and those calls do not change the holder's stored allowance. `approve(spender, 0)` does not opt the holder out. The role administrator must revoke `AUTHORIZED_SPENDER_ROLE` to remove the authority. -Operator transfers still use the `TRANSFER` pause vector and all three transfer policy scopes. The role waives only the allowance check. On Asset, the same role also gates announcements and multiplier updates. `approve` and holder `transfer` are not role-gated. +Authorized spender transfers still use the `TRANSFER` pause vector and all three transfer policy scopes. The role waives only the allowance check. `OPERATOR_ROLE` remains an Asset-only role for announcements and multiplier updates. `approve` and holder `transfer` are not role-gated. ### 2.3 Granting and revoking @@ -257,4 +258,3 @@ sequenceDiagram | `EmptyFeatureSet()` | `pause`/`unpause` called with an empty array | | `LastAdminCannotRenounce()` | `revokeRole`/`renounceRole` would remove the last `DEFAULT_ADMIN_ROLE` holder | | `NotSoleAdmin()` | `renounceLastAdmin` called while other admins still exist | - diff --git a/docs/concepts/token-types.md b/docs/concepts/token-types.md index ede48aae..ef6d6190 100644 --- a/docs/concepts/token-types.md +++ b/docs/concepts/token-types.md @@ -46,7 +46,7 @@ Asset is the general-purpose variant. That includes real-world assets (RWAs). It Creation sets immutable `decimals` in `[6, 18]`. Values outside that range revert `InvalidDecimals`. Asset has no `currency()`. -It adds the Asset-only calls: `announce` for a corporate-action disclosure with a single-use `id` and optional inner calls, scheduled `updateUIMultiplier` / `cancelUIMultiplierUpdate` ([ERC-8056](https://eips.ethereum.org/EIPS/eip-8056)), an extra-metadata key/value store, and `batchMint`. The inherited `OPERATOR_ROLE` also gates `announce` and multiplier updates. Name, symbol, contract URI, and extra metadata still use inherited `METADATA_ROLE`. +It adds the Asset-only calls: `announce` for a corporate-action disclosure with a single-use `id` and optional inner calls, scheduled `updateUIMultiplier` / `cancelUIMultiplierUpdate` ([ERC-8056](https://eips.ethereum.org/EIPS/eip-8056)), an extra-metadata key/value store, and `batchMint`. The Asset-only `OPERATOR_ROLE` gates `announce` and multiplier updates. Name, symbol, contract URI, and extra metadata still use inherited `METADATA_ROLE`. Asset-specific state lives in `base.b20.asset`: `decimals`, `multiplier`, used announcement IDs, extra metadata, and the pending multiplier. Shared ERC-20, role, policy, and pause state stays in `base.b20`. @@ -58,7 +58,7 @@ Stablecoin is the fiat-pegged variant. `decimals` is hardcoded to `6`. The issuer does not pass decimals. -The extra surface on top of `IB20` is `currency()`. Stablecoin has no announce, multiplier, extra metadata, or `batchMint`. It inherits the shared `OPERATOR_ROLE` allowance behavior from `IB20`. +The extra surface on top of `IB20` is `currency()`. Stablecoin has no announce, multiplier, extra metadata, `batchMint`, or `OPERATOR_ROLE`. It inherits the shared `AUTHORIZED_SPENDER_ROLE` allowance behavior from `IB20`. Stablecoin-specific state lives in `base.b20.stablecoin` (`currency` only). Shared ERC-20, role, policy, and pause state stays in `base.b20`. @@ -70,7 +70,7 @@ The same issuer can create both types. Different salts produce different address ### 6.1 Creating a Stablecoin -Predict the address with `getB20Address(STABLECOIN, sender, saltB)`. Then call `createB20` with `B20StablecoinCreateParams`: `version` `1`, name, symbol, `initialAdmin`, and `currency: "USD"`. Optional `initCalls` can grant `OPERATOR_ROLE` through the standard `grantRole` encoder. +Predict the address with `getB20Address(STABLECOIN, sender, saltB)`. Then call `createB20` with `B20StablecoinCreateParams`: `version` `1`, name, symbol, `initialAdmin`, and `currency: "USD"`. Optional `initCalls` can grant `AUTHORIZED_SPENDER_ROLE` through the standard `grantRole` encoder. ```mermaid sequenceDiagram diff --git a/docs/guides/announcing-corporate-actions.md b/docs/guides/announcing-corporate-actions.md index 6d169778..40e4aee2 100644 --- a/docs/guides/announcing-corporate-actions.md +++ b/docs/guides/announcing-corporate-actions.md @@ -64,8 +64,6 @@ Grant roles, choose the disclosure, encode the inner calls, then announce. The s asset.grantRole(asset.OPERATOR_ROLE(), operator); ``` -`OPERATOR_ROLE` also grants infinite `transferFrom` authority from every holder. Grant it only to an account that may move holder balances. - Until this grant lands, every `announce` reverts `AccessControlUnauthorizedAccount`. Grant inner-call roles on the same operator when the wrapped call needs them. Mint needs `MINT_ROLE`. Burn needs `BURN_ROLE`. Multiplier setters already use `OPERATOR_ROLE`. diff --git a/docs/guides/scheduling-stock-splits.md b/docs/guides/scheduling-stock-splits.md index 6b1127fc..568d1859 100644 --- a/docs/guides/scheduling-stock-splits.md +++ b/docs/guides/scheduling-stock-splits.md @@ -97,8 +97,6 @@ This is the routine corporate-action path. asset.grantRole(asset.OPERATOR_ROLE(), operator); ``` -`OPERATOR_ROLE` also grants infinite `transferFrom` authority from every holder. Grant it only to an account that may move holder balances. - Until this grant lands, every multiplier setter reverts `AccessControlUnauthorizedAccount`. #### Call `updateUIMultiplier` diff --git a/docs/overview.md b/docs/overview.md index 10370d8e..8ae10d86 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -78,11 +78,11 @@ The Activation Registry is a Base-operated safety switch that turns Factory and ## Configuring Roles -Roles let an issuer assign each privileged operation to a specific account. An admin can grant minting to a minter, seizing to a compliance operator, issuer-approved spending to an operator, and pausing of a single feature (`TRANSFER`, `MINT`, `BURN`, or `SEIZE`) without pausing the rest of the token. +Roles let an issuer assign each privileged operation to a specific account. An admin can grant minting to a minter, seizing to a compliance operator, issuer-approved spending to an authorized spender, and pausing of a single feature (`TRANSFER`, `MINT`, `BURN`, or `SEIZE`) without pausing the rest of the token. B20 implements this with [OpenZeppelin AccessControl](https://docs.openzeppelin.com/contracts/5.x/access-control) on the token. Roles are not a separate registry. One `DEFAULT_ADMIN_ROLE` holder grants and revokes the operating roles. A privileged call checks the role first, then the matching pause vector. Holder `transfer` skips the role check; it still hits the `TRANSFER` pause vector and policy. -`OPERATOR_ROLE` gives its holder an infinite allowance from every token holder. `allowance(owner, operator)` returns `type(uint256).max`, and `transferFrom` does not consume the holder's stored allowance. The transfer pause vector and sender, receiver, and executor policies still apply. A holder cannot opt out by approving zero; the role admin must revoke the role. +`AUTHORIZED_SPENDER_ROLE` gives its holder an infinite allowance from every token holder. `allowance(owner, spender)` returns `type(uint256).max`, and `transferFrom` does not consume the holder's stored allowance. The transfer pause vector and sender, receiver, and executor policies still apply. A holder cannot opt out by approving zero; the role admin must revoke the role. The full role list and what each role gates is in [Roles](./concepts/roles.md). A role-gated call looks like this: diff --git a/docs/reference/constants.md b/docs/reference/constants.md index 370bfd74..cbc77f06 100644 --- a/docs/reference/constants.md +++ b/docs/reference/constants.md @@ -26,7 +26,8 @@ | `PAUSE_ROLE` | `keccak256("PAUSE_ROLE")`
`0x139c2898040ef16910dc9f44dc697df79363da767d8bc92f2e310312b816e46d` | Required to call `pause`. | | `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")`
`0x265b220c5a8891efdd9e1b1b7fa72f257bd5169f8d87e319cf3dad6ff52b94ae` | Required to call `unpause`. | | `METADATA_ROLE` | `keccak256("METADATA_ROLE")`
`0x6bd6b5318a46e5fff572d5e4258a20774aab40cc35ac7680654b9081fcc82f80` | Required to call `updateName`, `updateSymbol`, `updateContractURI`, and `updateExtraMetadata`. | -| `OPERATOR_ROLE` | `keccak256("OPERATOR_ROLE")`
`0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | Grants infinite allowance from every holder. On B20Asset, also required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `updateMultiplier`. | +| `AUTHORIZED_SPENDER_ROLE` | `keccak256("AUTHORIZED_SPENDER_ROLE")`
`0xb0e3ae34a3ebd864ed280a15abe71cbcaf59103e086737862f5bbccae6a44b37` | Grants infinite allowance from every holder. | +| `OPERATOR_ROLE` | `keccak256("OPERATOR_ROLE")`
`0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | B20Asset-only. Required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `updateMultiplier`. | ## Policy types diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 374b255f..75ba974d 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -12,7 +12,7 @@ | `AccessControlUnauthorizedAccount(address account, bytes32 neededRole)` | `0xe2517d3f` | `account` does not hold `neededRole`. | | `Unauthorized()` | `0x82b42900` | Caller failed a positional authorization check that isn't expressible as "missing role X". | | `ContractPaused(uint8 feature)` | `0xfd8c4245` | The `PausableFeature` covering the operation is currently paused. | -| `InsufficientAllowance(address spender, uint256 allowance, uint256 needed)` | `0x192b9e4e` | `spender` does not hold `OPERATOR_ROLE`, and its allowance is less than `needed` for the requested `transferFrom`. | +| `InsufficientAllowance(address spender, uint256 allowance, uint256 needed)` | `0x192b9e4e` | `spender` does not hold `AUTHORIZED_SPENDER_ROLE`, and its allowance is less than `needed` for the requested `transferFrom`. | | `InsufficientBalance(address sender, uint256 balance, uint256 needed)` | `0xdb42144d` | `sender`'s balance is less than `needed` for the requested transfer or burn. | | `InvalidSender(address sender)` | `0x4c14f64c` | The transfer's source address is invalid (typically `address(0)`). | | `InvalidReceiver(address receiver)` | `0x9cfea583` | The transfer's destination address is invalid (typically `address(0)`). | diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 0d833f87..fe8e0ce2 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -234,7 +234,7 @@ interface IB20 { /// @notice Grants an infinite allowance from every holder for `transferFrom` and `transferFromWithMemo`. /// @return Role constant. - function OPERATOR_ROLE() external view returns (bytes32); + function AUTHORIZED_SPENDER_ROLE() external view returns (bytes32); /*////////////////////////////////////////////////////////////// POLICY TYPE CONSTANTS @@ -309,7 +309,7 @@ interface IB20 { function balanceOf(address account) external view returns (uint256); /// @notice Allowance granted by `owner` to `spender`. Returns `type(uint256).max` when `spender` holds - /// `OPERATOR_ROLE`, regardless of the stored allowance. + /// `AUTHORIZED_SPENDER_ROLE`, regardless of the stored allowance. /// /// @param owner Allowance owner. /// @param spender Allowance spender. @@ -332,14 +332,14 @@ interface IB20 { /// @return Always `true` on success. function transfer(address to, uint256 amount) external returns (bool); - /// @notice Transfers `amount` from `from` to `to` using `msg.sender`'s allowance or `OPERATOR_ROLE`. - /// Emits `Transfer`. + /// @notice Transfers `amount` from `from` to `to` using `msg.sender`'s allowance or + /// `AUTHORIZED_SPENDER_ROLE`. Emits `Transfer`. /// /// @dev Reverts with `ContractPaused(TRANSFER)` when `TRANSFER` is paused. /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. /// @dev Reverts with `InvalidSender` when `from == address(0)`. - /// @dev Reverts with `InsufficientAllowance` when the caller does not hold `OPERATOR_ROLE` and its allowance - /// from `from` is below `amount`. + /// @dev Reverts with `InsufficientAllowance` when the caller does not hold `AUTHORIZED_SPENDER_ROLE` and its + /// allowance from `from` is below `amount`. /// @dev Reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender != from` and `msg.sender` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `from` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. @@ -353,7 +353,7 @@ interface IB20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); /// @notice Sets `spender`'s stored allowance to `amount`. Not gated by any policy or by pause. Emits `Approval`. - /// This does not limit a spender that holds `OPERATOR_ROLE`. + /// This does not limit a spender that holds `AUTHORIZED_SPENDER_ROLE`. /// /// @dev Reverts with `InvalidApprover` when `msg.sender == address(0)`. /// @dev Reverts with `InvalidSpender` when `spender == address(0)`. diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 21f1421d..24445df7 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -109,6 +109,16 @@ interface IB20Asset is /// @notice Emitted by `announce` to close the bracket opened by the paired `Announcement` with the same `id`. event EndAnnouncement(string id); + /*////////////////////////////////////////////////////////////// + ROLE CONSTANTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and + /// `updateMultiplier`. The metadata setters (`updateName`, `updateSymbol`, + /// `updateExtraMetadata`) are gated by the inherited `METADATA_ROLE` instead. + /// @return Role constant. + function OPERATOR_ROLE() external view returns (bytes32); + /*////////////////////////////////////////////////////////////// PRECISION //////////////////////////////////////////////////////////////*/ diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index 1a27d1d6..fef12f9f 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -12,6 +12,7 @@ library B20Constants { bytes32 internal constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); bytes32 internal constant UNPAUSE_ROLE = keccak256("UNPAUSE_ROLE"); bytes32 internal constant METADATA_ROLE = keccak256("METADATA_ROLE"); + bytes32 internal constant AUTHORIZED_SPENDER_ROLE = keccak256("AUTHORIZED_SPENDER_ROLE"); bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); bytes32 internal constant TRANSFER_SENDER_POLICY = keccak256("TRANSFER_SENDER_POLICY"); diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index 7426c155..f90b7a3a 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -34,7 +34,7 @@ library B20FactoryLib { /// `address(0)` fields are skipped at bootstrap. /// /// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20StablecoinCreateParams.initialAdmin`, not this struct. - /// @dev Append `encodeGrantRole(B20Constants.OPERATOR_ROLE, operator)` when the Stablecoin needs an operator. + /// @dev Append `encodeGrantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, spender)` when needed. struct B20RoleHolders { /// @dev Account granted `MINT_ROLE`. address minter; @@ -54,6 +54,7 @@ library B20FactoryLib { /// with an `OPERATOR_ROLE` convenience slot. /// /// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20AssetCreateParams.initialAdmin`, not this struct. + /// @dev Append `encodeGrantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, spender)` when needed. struct B20AssetRoleHolders { /// @dev Account granted `MINT_ROLE`. address minter; diff --git a/test/lib/B20AssetTest.sol b/test/lib/B20AssetTest.sol index 18a4b087..9241abcc 100644 --- a/test/lib/B20AssetTest.sol +++ b/test/lib/B20AssetTest.sol @@ -17,6 +17,9 @@ import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; /// variant-only surface (`announce`, `batchMint`, etc.) cast inline via /// the `asset` view-helper. contract B20AssetTest is B20Test { + // -- Asset-variant role-holder actors -- + address internal operator = makeAddr("operator"); + // ============================================================ // ASSET-VARIANT EXTRA-METADATA FIXTURES // ============================================================ @@ -38,6 +41,12 @@ contract B20AssetTest is B20Test { /// @notice Example metadata-entry key #3. string internal constant METADATA_EXAMPLE_3 = "reference"; + // -- Setup -- + function setUp() public virtual override { + super.setUp(); + vm.label(operator, "operator"); + } + // ============================================================ // VARIANT CAST CONVENIENCE // ============================================================ @@ -48,6 +57,15 @@ contract B20AssetTest is B20Test { return IB20Asset(address(token)); } + // ============================================================ + // ASSET-ROLE HELPERS + // ============================================================ + + /// @notice Grants `OPERATOR_ROLE` to the `operator` actor as the admin, idempotently. + function _grantOperator() internal { + if (!token.hasRole(OPERATOR_ROLE, operator)) _grantRole(OPERATOR_ROLE, operator); + } + // ============================================================ // MULTIPLIER HELPERS // ============================================================ @@ -123,4 +141,6 @@ contract B20AssetTest is B20Test { blobs = new bytes[](1); blobs[0] = blob; } + + bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); } diff --git a/test/lib/B20Test.sol b/test/lib/B20Test.sol index 79ad2ab6..2e4b8bd6 100644 --- a/test/lib/B20Test.sol +++ b/test/lib/B20Test.sol @@ -19,7 +19,7 @@ import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; /// /// On top of the inherited factory actors, this contract adds the /// token-specific role-holders (`minter`, `burner`, `pauser`, -/// `unpauser`, `burnBlocker`, `operator`) so role-gated tests have explicit named +/// `unpauser`, `burnBlocker`, `authorizedSpender`) so role-gated tests have explicit named /// accounts to grant roles to in setUp's initCalls. contract B20Test is B20FactoryTest { // Role constants (DEFAULT_ADMIN_ROLE, MINT_ROLE, BURN_ROLE, @@ -27,8 +27,7 @@ contract B20Test is B20FactoryTest { // policy-type constants (TRANSFER_SENDER_POLICY, TRANSFER_RECEIVER_POLICY, // TRANSFER_EXECUTOR_POLICY, MINT_RECEIVER_POLICY) are NOT redeclared here. // Tests reference them directly from MockB20 as `MINT_ROLE` - // etc. — single source of truth, no drift risk. The local OPERATOR_ROLE - // copy remains for Asset test call sites that need a compile-time value. + // etc. — single source of truth, no drift risk. // // Built-in policy sentinel IDs likewise live on MockPolicyRegistry as // `ALWAYS_ALLOW_ID` / `ALWAYS_BLOCK_ID`. @@ -39,9 +38,9 @@ contract B20Test is B20FactoryTest { address internal pauser = makeAddr("pauser"); address internal unpauser = makeAddr("unpauser"); address internal burnBlocker = makeAddr("burnBlocker"); - address internal operator = makeAddr("operator"); + address internal authorizedSpender = makeAddr("authorizedSpender"); - bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + bytes32 internal constant AUTHORIZED_SPENDER_ROLE = keccak256("AUTHORIZED_SPENDER_ROLE"); // -- Token under test -- /// @notice Asset-variant `IB20` token deployed in `setUp`. @@ -56,7 +55,7 @@ contract B20Test is B20FactoryTest { vm.label(pauser, "pauser"); vm.label(unpauser, "unpauser"); vm.label(burnBlocker, "burnBlocker"); - vm.label(operator, "operator"); + vm.label(authorizedSpender, "authorizedSpender"); token = _deployToken(); vm.label(address(token), "token"); @@ -112,10 +111,10 @@ contract B20Test is B20FactoryTest { token.grantRole(role, account); } - /// @notice Grants `OPERATOR_ROLE` to the `operator` actor as the admin, idempotently. - function _grantOperator() internal { - if (!token.hasRole(B20Constants.OPERATOR_ROLE, operator)) { - _grantRole(B20Constants.OPERATOR_ROLE, operator); + /// @notice Grants `AUTHORIZED_SPENDER_ROLE` to the `authorizedSpender` actor as the admin, idempotently. + function _grantAuthorizedSpender() internal { + if (!token.hasRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender)) { + _grantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); } } diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 151f5813..5a3cef96 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -89,7 +89,7 @@ abstract contract MockB20 is IB20 { bytes32 public constant PAUSE_ROLE = B20Constants.PAUSE_ROLE; bytes32 public constant UNPAUSE_ROLE = B20Constants.UNPAUSE_ROLE; bytes32 public constant METADATA_ROLE = B20Constants.METADATA_ROLE; - bytes32 public constant OPERATOR_ROLE = B20Constants.OPERATOR_ROLE; + bytes32 public constant AUTHORIZED_SPENDER_ROLE = B20Constants.AUTHORIZED_SPENDER_ROLE; /// @notice Policy-type constants. Same `keccak256` convention as roles. bytes32 public constant TRANSFER_SENDER_POLICY = B20Constants.TRANSFER_SENDER_POLICY; @@ -178,7 +178,7 @@ abstract contract MockB20 is IB20 { } function allowance(address owner, address spender) external view returns (uint256) { - if (hasRole(OPERATOR_ROLE, spender)) return type(uint256).max; + if (hasRole(AUTHORIZED_SPENDER_ROLE, spender)) return type(uint256).max; return MockB20Storage.layout().allowances[owner][spender]; } @@ -198,7 +198,7 @@ abstract contract MockB20 is IB20 { returns (bool) { _requireNonZeroActors(from, to); - // Factory privilege does not bypass allowance accounting. OPERATOR_ROLE + // Factory privilege does not bypass allowance accounting. AUTHORIZED_SPENDER_ROLE // and the infinite-allowance sentinel do bypass it inside `_consumeAllowance`. _consumeAllowance(from, msg.sender, amount); if (!_isPrivileged() && msg.sender != from) { @@ -244,7 +244,7 @@ abstract contract MockB20 is IB20 { returns (bool) { _requireNonZeroActors(from, to); - // Factory privilege does not bypass allowance accounting. OPERATOR_ROLE + // Factory privilege does not bypass allowance accounting. AUTHORIZED_SPENDER_ROLE // and the infinite-allowance sentinel do bypass it inside `_consumeAllowance`. _consumeAllowance(from, msg.sender, amount); if (!_isPrivileged() && msg.sender != from) { @@ -725,7 +725,7 @@ abstract contract MockB20 is IB20 { } function _consumeAllowance(address owner, address spender, uint256 amount) internal { - if (hasRole(OPERATOR_ROLE, spender)) return; + if (hasRole(AUTHORIZED_SPENDER_ROLE, spender)) return; uint256 current = MockB20Storage.layout().allowances[owner][spender]; if (current != type(uint256).max) { @@ -751,7 +751,7 @@ abstract contract MockB20 is IB20 { /// `transferWithMemo`, `transferFromWithMemo`) before reaching /// this helper. `transferFrom` / `transferFromWithMemo` /// additionally consume the allowance unless the caller holds - /// `OPERATOR_ROLE`, and check the executor policy in their bodies + /// `AUTHORIZED_SPENDER_ROLE`, and check the executor policy in their bodies /// before calling here. Only the policy checks honor the bootstrap /// bypass. function _transfer(address from, address to, uint256 amount) internal { diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 7df55f5d..133c3e8f 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -11,7 +11,7 @@ import { IScaledUIAmountConversion } from "base-std/interfaces/IERC8056.sol"; -import {MockB20} from "base-std-test/lib/mocks/MockB20.sol"; +import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20AssetStorage, MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; /// @title MockB20Asset @@ -62,6 +62,8 @@ contract MockB20Asset is MockB20, IB20Asset { // CONSTANTS // ============================================================ + bytes32 public constant OPERATOR_ROLE = B20Constants.OPERATOR_ROLE; + /// @notice Fixed-point precision for the multiplier. `1e18` (one /// WAD) is the standard DeFi convention; `toScaledBalance` /// and `scaledBalanceOf` divide by this after multiplying diff --git a/test/unit/B20/erc20/allowance.t.sol b/test/unit/B20/erc20/allowance.t.sol index 13c823d2..4b8a48bc 100644 --- a/test/unit/B20/erc20/allowance.t.sol +++ b/test/unit/B20/erc20/allowance.t.sol @@ -58,35 +58,45 @@ contract B20AllowanceTest is B20Test { ); } - /// @notice Verifies every holder reports an infinite allowance for an operator + /// @notice Verifies every holder reports an infinite allowance for an authorized spender /// @dev Role membership overrides the allowance view without changing the stored allowance. - function test_allowance_success_operatorReadsAsInfinite(address owner, uint256 storedAllowance) public { + function test_allowance_success_authorizedSpenderReadsAsInfinite(address owner, uint256 storedAllowance) public { _assumeValidActor(owner); vm.prank(owner); - token.approve(operator, storedAllowance); - _grantOperator(); + token.approve(authorizedSpender, storedAllowance); + _grantAuthorizedSpender(); - assertEq(token.allowance(owner, operator), type(uint256).max, "operator allowance must read as infinite"); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, operator))), + token.allowance(owner, authorizedSpender), + type(uint256).max, + "authorized spender allowance must read as infinite" + ); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, authorizedSpender))), storedAllowance, - "operator role must not overwrite stored allowance" + "authorized spender role must not overwrite stored allowance" ); } - /// @notice Verifies revoking OPERATOR_ROLE restores the holder's stored allowance + /// @notice Verifies revoking AUTHORIZED_SPENDER_ROLE restores the holder's stored allowance /// @dev Role revocation removes only the synthetic infinite allowance. - function test_allowance_success_revokedOperatorReadsStoredAllowance(address owner, uint256 storedAllowance) public { + function test_allowance_success_revokedAuthorizedSpenderReadsStoredAllowance(address owner, uint256 storedAllowance) + public + { _assumeValidActor(owner); vm.prank(owner); - token.approve(operator, storedAllowance); - _grantOperator(); + token.approve(authorizedSpender, storedAllowance); + _grantAuthorizedSpender(); vm.prank(admin); - token.revokeRole(B20Constants.OPERATOR_ROLE, operator); + token.revokeRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); - assertEq(token.allowance(owner, operator), storedAllowance, "revoked operator must read stored allowance"); + assertEq( + token.allowance(owner, authorizedSpender), + storedAllowance, + "revoked authorized spender must read stored allowance" + ); } } diff --git a/test/unit/B20/erc20/transferFrom.t.sol b/test/unit/B20/erc20/transferFrom.t.sol index b2f94112..545eeda6 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -57,18 +57,20 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } - /// @notice Verifies OPERATOR_ROLE does not bypass TRANSFER_EXECUTOR_POLICY + /// @notice Verifies AUTHORIZED_SPENDER_ROLE does not bypass TRANSFER_EXECUTOR_POLICY /// @dev The role waives allowance only; executor policy remains active. - function test_transferFrom_revert_operatorExecutorPolicyForbids(address from, address to, uint256 amount) public { + function test_transferFrom_revert_authorizedSpenderExecutorPolicyForbids(address from, address to, uint256 amount) + public + { _assumeValidActor(from); _assumeValidActor(to); - vm.assume(operator != from); + vm.assume(authorizedSpender != from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantOperator(); + _grantAuthorizedSpender(); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(operator); + vm.prank(authorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -105,17 +107,19 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } - /// @notice Verifies OPERATOR_ROLE does not bypass TRANSFER_SENDER_POLICY + /// @notice Verifies AUTHORIZED_SPENDER_ROLE does not bypass TRANSFER_SENDER_POLICY /// @dev The role waives allowance only; sender policy remains active. - function test_transferFrom_revert_operatorSenderPolicyForbids(address from, address to, uint256 amount) public { + function test_transferFrom_revert_authorizedSpenderSenderPolicyForbids(address from, address to, uint256 amount) + public + { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantOperator(); + _grantAuthorizedSpender(); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(operator); + vm.prank(authorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -152,17 +156,19 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } - /// @notice Verifies OPERATOR_ROLE does not bypass TRANSFER_RECEIVER_POLICY + /// @notice Verifies AUTHORIZED_SPENDER_ROLE does not bypass TRANSFER_RECEIVER_POLICY /// @dev The role waives allowance only; receiver policy remains active. - function test_transferFrom_revert_operatorReceiverPolicyForbids(address from, address to, uint256 amount) public { + function test_transferFrom_revert_authorizedSpenderReceiverPolicyForbids(address from, address to, uint256 amount) + public + { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantOperator(); + _grantAuthorizedSpender(); _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(operator); + vm.prank(authorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -190,21 +196,23 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } - /// @notice Verifies a revoked operator must use the holder's stored allowance + /// @notice Verifies a revoked authorized spender must use the holder's stored allowance /// @dev Revocation removes the allowance bypass immediately. - function test_transferFrom_revert_revokedOperatorInsufficientAllowance(address from, address to, uint256 amount) - public - { + function test_transferFrom_revert_revokedAuthorizedSpenderInsufficientAllowance( + address from, + address to, + uint256 amount + ) public { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 1, type(uint256).max); - _grantOperator(); + _grantAuthorizedSpender(); vm.prank(admin); - token.revokeRole(B20Constants.OPERATOR_ROLE, operator); + token.revokeRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); - vm.prank(operator); - vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, operator, 0, amount)); + vm.prank(authorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, authorizedSpender, 0, amount)); token.transferFrom(from, to, amount); } @@ -334,11 +342,13 @@ contract B20TransferFromTest is B20Test { ); } - /// @notice Verifies approving zero does not opt a holder out of operator spending - /// @dev OPERATOR_ROLE supplies independent infinite authority over every holder balance. - function test_transferFrom_success_operatorSpendsAfterHolderApprovesZero(address from, address to, uint256 amount) - public - { + /// @notice Verifies approving zero does not opt a holder out of authorized spender transfers + /// @dev AUTHORIZED_SPENDER_ROLE supplies independent infinite authority over every holder balance. + function test_transferFrom_success_authorizedSpenderSpendsAfterHolderApprovesZero( + address from, + address to, + uint256 amount + ) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); @@ -346,23 +356,23 @@ contract B20TransferFromTest is B20Test { _mint(from, amount); vm.prank(from); - token.approve(operator, 0); - _grantOperator(); + token.approve(authorizedSpender, 0); + _grantAuthorizedSpender(); - vm.prank(operator); + vm.prank(authorizedSpender); token.transferFrom(from, to, amount); - assertEq(token.balanceOf(to), amount, "operator must spend despite zero stored allowance"); + assertEq(token.balanceOf(to), amount, "authorized spender must spend despite zero stored allowance"); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), 0, "zero stored allowance must remain unchanged" ); } - /// @notice Verifies operator spending does not consume a finite stored allowance + /// @notice Verifies authorized spender transfers do not consume a finite stored allowance /// @dev Role authority and holder-managed allowance accounting remain independent. - function test_transferFrom_success_operatorPreservesStoredAllowance( + function test_transferFrom_success_authorizedSpenderPreservesStoredAllowance( address from, address to, uint256 storedAllowance, @@ -376,15 +386,19 @@ contract B20TransferFromTest is B20Test { _mint(from, amount); vm.prank(from); - token.approve(operator, storedAllowance); - _grantOperator(); + token.approve(authorizedSpender, storedAllowance); + _grantAuthorizedSpender(); - vm.prank(operator); + vm.prank(authorizedSpender); token.transferFrom(from, to, amount); - assertEq(token.allowance(from, operator), type(uint256).max, "operator allowance must remain infinite"); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + token.allowance(from, authorizedSpender), + type(uint256).max, + "authorized spender allowance must remain infinite" + ); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), storedAllowance, "stored allowance must not be consumed" ); diff --git a/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol b/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol index c735fb43..093d9bd4 100644 --- a/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol +++ b/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol @@ -13,7 +13,7 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// `transferFrom`; the memo parameter adds no new revert conditions. /// /// **Canonical order (Solidity reference, when `msg.sender != from` and the caller lacks -/// `OPERATOR_ROLE`):** +/// `AUTHORIZED_SPENDER_ROLE`):** /// 1. PAUSE (`whenNotPaused(TRANSFER)` modifier) → `ContractPaused` /// 2. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 3. ZERO-SENDER (`from == address(0)`) → `InvalidSender` diff --git a/test/unit/B20/erc20/transferFrom_revertOrder.t.sol b/test/unit/B20/erc20/transferFrom_revertOrder.t.sol index eacae3de..ce3f1713 100644 --- a/test/unit/B20/erc20/transferFrom_revertOrder.t.sol +++ b/test/unit/B20/erc20/transferFrom_revertOrder.t.sol @@ -16,7 +16,7 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// work in the entrypoint body. /// /// **Canonical order (Solidity reference, when -/// `msg.sender != from` and the caller lacks `OPERATOR_ROLE`):** +/// `msg.sender != from` and the caller lacks `AUTHORIZED_SPENDER_ROLE`):** /// 1. PAUSE (`whenNotPaused(TRANSFER)` modifier) → `ContractPaused` /// 2. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 3. ZERO-SENDER (`from == address(0)`) → `InvalidSender` diff --git a/test/unit/B20/memo/transferFromWithMemo.t.sol b/test/unit/B20/memo/transferFromWithMemo.t.sol index c993d768..d867049b 100644 --- a/test/unit/B20/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20/memo/transferFromWithMemo.t.sol @@ -31,9 +31,9 @@ contract B20TransferFromWithMemoTest is B20Test { token.transferFromWithMemo(from, to, amount, memo); } - /// @notice Verifies OPERATOR_ROLE does not bypass the memo transfer's executor policy + /// @notice Verifies AUTHORIZED_SPENDER_ROLE does not bypass the memo transfer's executor policy /// @dev The memo variant preserves the same policy boundary as transferFrom. - function test_transferFromWithMemo_revert_operatorExecutorPolicyForbids( + function test_transferFromWithMemo_revert_authorizedSpenderExecutorPolicyForbids( address from, address to, uint256 amount, @@ -41,13 +41,13 @@ contract B20TransferFromWithMemoTest is B20Test { ) public { _assumeValidActor(from); _assumeValidActor(to); - vm.assume(operator != from); + vm.assume(authorizedSpender != from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantOperator(); + _grantAuthorizedSpender(); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(operator); + vm.prank(authorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -155,9 +155,9 @@ contract B20TransferFromWithMemoTest is B20Test { assertTrue(token.transferFromWithMemo(from, to, amount, memo), "transferFromWithMemo must return true"); } - /// @notice Verifies approving zero does not opt a holder out of memo transfers by an operator - /// @dev OPERATOR_ROLE bypasses allowance without changing the stored zero value. - function test_transferFromWithMemo_success_operatorSpendsAfterHolderApprovesZero( + /// @notice Verifies approving zero does not opt a holder out of memo transfers by an authorized spender + /// @dev AUTHORIZED_SPENDER_ROLE bypasses allowance without changing the stored zero value. + function test_transferFromWithMemo_success_authorizedSpenderSpendsAfterHolderApprovesZero( address from, address to, uint256 amount, @@ -170,15 +170,15 @@ contract B20TransferFromWithMemoTest is B20Test { _mint(from, amount); vm.prank(from); - token.approve(operator, 0); - _grantOperator(); + token.approve(authorizedSpender, 0); + _grantAuthorizedSpender(); - vm.prank(operator); + vm.prank(authorizedSpender); token.transferFromWithMemo(from, to, amount, memo); - assertEq(token.balanceOf(to), amount, "operator memo transfer must move the balance"); + assertEq(token.balanceOf(to), amount, "authorized spender memo transfer must move the balance"); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), 0, "zero stored allowance must remain unchanged" ); diff --git a/test/unit/B20/roles/getRoleAdmin.t.sol b/test/unit/B20/roles/getRoleAdmin.t.sol index ff5e8a58..fb4cae26 100644 --- a/test/unit/B20/roles/getRoleAdmin.t.sol +++ b/test/unit/B20/roles/getRoleAdmin.t.sol @@ -5,13 +5,13 @@ import {B20Test} from "base-std-test/lib/B20Test.sol"; import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; contract B20GetRoleAdminTest is B20Test { - /// @notice Verifies OPERATOR_ROLE is administered by DEFAULT_ADMIN_ROLE on a fresh token - /// @dev Pins the selected governance default for issuer-approved operators. - function test_getRoleAdmin_success_operatorDefaultsToAdminRole() public view { + /// @notice Verifies AUTHORIZED_SPENDER_ROLE is administered by DEFAULT_ADMIN_ROLE on a fresh token + /// @dev Pins the selected governance default for issuer-authorized spenders. + function test_getRoleAdmin_success_authorizedSpenderDefaultsToAdminRole() public view { assertEq( - token.getRoleAdmin(B20Constants.OPERATOR_ROLE), + token.getRoleAdmin(B20Constants.AUTHORIZED_SPENDER_ROLE), B20Constants.DEFAULT_ADMIN_ROLE, - "operator role must default to DEFAULT_ADMIN_ROLE" + "authorized spender role must default to DEFAULT_ADMIN_ROLE" ); } diff --git a/test/unit/B20/roles/roleConstants.t.sol b/test/unit/B20/roles/roleConstants.t.sol index e219e151..33b3174b 100644 --- a/test/unit/B20/roles/roleConstants.t.sol +++ b/test/unit/B20/roles/roleConstants.t.sol @@ -60,10 +60,16 @@ contract B20RoleConstantsTest is B20Test { assertEq(token.METADATA_ROLE(), B20Constants.METADATA_ROLE, "must match B20Test's local constant"); } - /// @notice Verifies OPERATOR_ROLE returns keccak256("OPERATOR_ROLE") - /// @dev Constant stability for operator allowance and Asset administration. - function test_OPERATOR_ROLE_success_matchesExpected() public view { - assertEq(token.OPERATOR_ROLE(), keccak256("OPERATOR_ROLE"), "B20Constants.OPERATOR_ROLE digest"); - assertEq(token.OPERATOR_ROLE(), B20Constants.OPERATOR_ROLE, "must match B20Test's local constant"); + /// @notice Verifies AUTHORIZED_SPENDER_ROLE returns keccak256("AUTHORIZED_SPENDER_ROLE") + /// @dev Constant stability for issuer-authorized spending. + function test_AUTHORIZED_SPENDER_ROLE_success_matchesExpected() public view { + assertEq( + token.AUTHORIZED_SPENDER_ROLE(), + keccak256("AUTHORIZED_SPENDER_ROLE"), + "B20Constants.AUTHORIZED_SPENDER_ROLE digest" + ); + assertEq( + token.AUTHORIZED_SPENDER_ROLE(), B20Constants.AUTHORIZED_SPENDER_ROLE, "must match B20Test's local constant" + ); } } diff --git a/test/unit/B20/roles/setRoleAdmin.t.sol b/test/unit/B20/roles/setRoleAdmin.t.sol index 40cb6342..70667b34 100644 --- a/test/unit/B20/roles/setRoleAdmin.t.sol +++ b/test/unit/B20/roles/setRoleAdmin.t.sol @@ -38,26 +38,29 @@ contract B20SetRoleAdminTest is B20Test { ); } - /// @notice Verifies OPERATOR_ROLE administration can be delegated from DEFAULT_ADMIN_ROLE - /// @dev Pins the selected governance model for the exact operator role. - function test_setRoleAdmin_success_delegatesOperatorRoleAdministration(address delegatedAdmin) public { + /// @notice Verifies AUTHORIZED_SPENDER_ROLE administration can be delegated from DEFAULT_ADMIN_ROLE + /// @dev Pins the selected governance model for the exact authorized spender role. + function test_setRoleAdmin_success_delegatesAuthorizedSpenderRoleAdministration(address delegatedAdmin) public { _assumeValidCaller(delegatedAdmin); vm.assume(delegatedAdmin != admin); bytes32 customAdminRole = keccak256("CUSTOM_ADMIN_ROLE"); vm.startPrank(admin); token.grantRole(customAdminRole, delegatedAdmin); - token.setRoleAdmin(B20Constants.OPERATOR_ROLE, customAdminRole); + token.setRoleAdmin(B20Constants.AUTHORIZED_SPENDER_ROLE, customAdminRole); vm.stopPrank(); vm.prank(admin); vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, admin, customAdminRole)); - token.grantRole(B20Constants.OPERATOR_ROLE, operator); + token.grantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); vm.prank(delegatedAdmin); - token.grantRole(B20Constants.OPERATOR_ROLE, operator); + token.grantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); - assertTrue(token.hasRole(B20Constants.OPERATOR_ROLE, operator), "delegated admin must grant operator role"); + assertTrue( + token.hasRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender), + "delegated admin must grant authorized spender role" + ); } /// @notice Verifies setRoleAdmin emits RoleAdminChanged(role, previousAdminRole, newAdminRole) diff --git a/test/unit/B20Asset/erc20/allowance.t.sol b/test/unit/B20Asset/erc20/allowance.t.sol new file mode 100644 index 00000000..7110c4da --- /dev/null +++ b/test/unit/B20Asset/erc20/allowance.t.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +contract B20AssetAllowanceTest is B20AssetTest { + /// @notice Verifies OPERATOR_ROLE does not grant an infinite allowance + /// @dev Asset operations and holder-spending authority use separate roles. + function test_allowance_success_operatorRoleDoesNotAuthorizeSpending(address owner) public { + _assumeValidActor(owner); + _grantOperator(); + + assertEq(token.allowance(owner, operator), 0, "Asset operator allowance must remain zero"); + } +} diff --git a/test/unit/B20Asset/erc20/transferFrom.t.sol b/test/unit/B20Asset/erc20/transferFrom.t.sol new file mode 100644 index 00000000..f4359d7a --- /dev/null +++ b/test/unit/B20Asset/erc20/transferFrom.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IB20} from "base-std/interfaces/IB20.sol"; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +contract B20AssetTransferFromTest is B20AssetTest { + /// @notice Verifies OPERATOR_ROLE cannot spend from a holder without allowance + /// @dev Asset operations and holder-spending authority use separate roles. + function test_transferFrom_revert_operatorRoleDoesNotAuthorizeSpending(address from, address to, uint256 amount) + public + { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 1, type(uint256).max); + _grantOperator(); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, operator, 0, amount)); + token.transferFrom(from, to, amount); + } +} diff --git a/test/unit/B20Asset/memo/transferFromWithMemo.t.sol b/test/unit/B20Asset/memo/transferFromWithMemo.t.sol new file mode 100644 index 00000000..4201b68c --- /dev/null +++ b/test/unit/B20Asset/memo/transferFromWithMemo.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IB20} from "base-std/interfaces/IB20.sol"; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +contract B20AssetTransferFromWithMemoTest is B20AssetTest { + /// @notice Verifies OPERATOR_ROLE cannot spend with a memo from a holder without allowance + /// @dev Asset operations and holder-spending authority use separate roles. + function test_transferFromWithMemo_revert_operatorRoleDoesNotAuthorizeSpending( + address from, + address to, + uint256 amount, + bytes32 memo + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 1, type(uint256).max); + _grantOperator(); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, operator, 0, amount)); + token.transferFromWithMemo(from, to, amount, memo); + } +} diff --git a/test/unit/B20Stablecoin/erc20/allowance.t.sol b/test/unit/B20Stablecoin/erc20/allowance.t.sol index ed0ab718..e5d2980c 100644 --- a/test/unit/B20Stablecoin/erc20/allowance.t.sol +++ b/test/unit/B20Stablecoin/erc20/allowance.t.sol @@ -2,21 +2,35 @@ pragma solidity ^0.8.20; import {B20StablecoinTest} from "base-std-test/lib/B20StablecoinTest.sol"; +import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; contract B20StablecoinAllowanceTest is B20StablecoinTest { - /// @notice Verifies Stablecoin reports infinite allowance for an operator + /// @notice Verifies a generic OPERATOR_ROLE grant does not create an infinite Stablecoin allowance + /// @dev The Asset-only role hash remains inert on the shared allowance path. + function test_allowance_success_operatorRoleDoesNotAuthorizeSpending(address owner) public { + _assumeValidActor(owner); + _grantRole(B20Constants.OPERATOR_ROLE, authorizedSpender); + + assertEq(token.allowance(owner, authorizedSpender), 0, "operator role allowance must remain zero"); + } + + /// @notice Verifies Stablecoin reports infinite allowance for an authorized spender /// @dev Role membership overrides the view without changing the stored allowance. - function test_allowance_success_operatorReadsAsInfinite(address owner, uint256 storedAllowance) public { + function test_allowance_success_authorizedSpenderReadsAsInfinite(address owner, uint256 storedAllowance) public { _assumeValidActor(owner); vm.prank(owner); - token.approve(operator, storedAllowance); - _grantOperator(); + token.approve(authorizedSpender, storedAllowance); + _grantAuthorizedSpender(); - assertEq(token.allowance(owner, operator), type(uint256).max, "operator allowance must be infinite"); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, operator))), + token.allowance(owner, authorizedSpender), + type(uint256).max, + "authorized spender allowance must be infinite" + ); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, authorizedSpender))), storedAllowance, "stored allowance must remain unchanged" ); diff --git a/test/unit/B20Stablecoin/erc20/transferFrom.t.sol b/test/unit/B20Stablecoin/erc20/transferFrom.t.sol index 99d15962..39542300 100644 --- a/test/unit/B20Stablecoin/erc20/transferFrom.t.sol +++ b/test/unit/B20Stablecoin/erc20/transferFrom.t.sol @@ -9,33 +9,52 @@ import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; contract B20StablecoinTransferFromTest is B20StablecoinTest { - /// @notice Verifies a Stablecoin operator remains subject to TRANSFER pause - /// @dev OPERATOR_ROLE waives allowance only. - function test_transferFrom_revert_operatorWhenTransferPaused(address from, address to, uint256 amount) public { + /// @notice Verifies a generic OPERATOR_ROLE grant cannot spend from a Stablecoin holder + /// @dev The Asset-only role hash remains inert on the shared transferFrom path. + function test_transferFrom_revert_operatorRoleDoesNotAuthorizeSpending(address from, address to, uint256 amount) + public + { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 1, type(uint256).max); + _grantRole(B20Constants.OPERATOR_ROLE, authorizedSpender); + + vm.prank(authorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, authorizedSpender, 0, amount)); + token.transferFrom(from, to, amount); + } + + /// @notice Verifies a Stablecoin authorized spender remains subject to TRANSFER pause + /// @dev AUTHORIZED_SPENDER_ROLE waives allowance only. + function test_transferFrom_revert_authorizedSpenderWhenTransferPaused(address from, address to, uint256 amount) + public + { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantOperator(); + _grantAuthorizedSpender(); _pause(IB20.PausableFeature.TRANSFER); - vm.prank(operator); + vm.prank(authorizedSpender); vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.TRANSFER)); token.transferFrom(from, to, amount); } - /// @notice Verifies a Stablecoin operator remains subject to TRANSFER_EXECUTOR_POLICY - /// @dev OPERATOR_ROLE waives allowance only. - function test_transferFrom_revert_operatorExecutorPolicyForbids(address from, address to, uint256 amount) public { + /// @notice Verifies a Stablecoin authorized spender remains subject to TRANSFER_EXECUTOR_POLICY + /// @dev AUTHORIZED_SPENDER_ROLE waives allowance only. + function test_transferFrom_revert_authorizedSpenderExecutorPolicyForbids(address from, address to, uint256 amount) + public + { _assumeValidActor(from); _assumeValidActor(to); - vm.assume(operator != from); + vm.assume(authorizedSpender != from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantOperator(); + _grantAuthorizedSpender(); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(operator); + vm.prank(authorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -46,31 +65,33 @@ contract B20StablecoinTransferFromTest is B20StablecoinTest { token.transferFrom(from, to, amount); } - /// @notice Verifies a Stablecoin operator can spend from a holder with zero allowance + /// @notice Verifies a Stablecoin authorized spender can spend from a holder with zero allowance /// @dev Confirms the shared allowance bypass applies to the Stablecoin variant. - function test_transferFrom_success_operatorSpendsWithoutAllowance(address from, address to, uint256 amount) public { + function test_transferFrom_success_authorizedSpenderSpendsWithoutAllowance(address from, address to, uint256 amount) + public + { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); - _grantOperator(); + _grantAuthorizedSpender(); - vm.prank(operator); + vm.prank(authorizedSpender); token.transferFrom(from, to, amount); - assertEq(token.balanceOf(to), amount, "operator must move holder balance"); + assertEq(token.balanceOf(to), amount, "authorized spender must move holder balance"); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), 0, "zero stored allowance must remain unchanged" ); } - /// @notice Verifies a Stablecoin operator does not consume a finite stored allowance + /// @notice Verifies a Stablecoin authorized spender does not consume a finite stored allowance /// @dev Role authority remains independent from holder-managed allowance state. - function test_transferFrom_success_operatorPreservesStoredAllowance( + function test_transferFrom_success_authorizedSpenderPreservesStoredAllowance( address from, address to, uint256 storedAllowance, @@ -84,14 +105,14 @@ contract B20StablecoinTransferFromTest is B20StablecoinTest { _mint(from, amount); vm.prank(from); - token.approve(operator, storedAllowance); - _grantOperator(); + token.approve(authorizedSpender, storedAllowance); + _grantAuthorizedSpender(); - vm.prank(operator); + vm.prank(authorizedSpender); token.transferFrom(from, to, amount); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, operator))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), storedAllowance, "stored allowance must not be consumed" ); diff --git a/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol b/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol index e1ede010..c8e3464f 100644 --- a/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol @@ -8,9 +8,27 @@ import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; contract B20StablecoinTransferFromWithMemoTest is B20StablecoinTest { - /// @notice Verifies a Stablecoin operator memo transfer remains subject to executor policy + /// @notice Verifies a generic OPERATOR_ROLE grant cannot spend with a memo from a Stablecoin holder + /// @dev The Asset-only role hash remains inert on the shared memo transfer path. + function test_transferFromWithMemo_revert_operatorRoleDoesNotAuthorizeSpending( + address from, + address to, + uint256 amount, + bytes32 memo + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 1, type(uint256).max); + _grantRole(B20Constants.OPERATOR_ROLE, authorizedSpender); + + vm.prank(authorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, authorizedSpender, 0, amount)); + token.transferFromWithMemo(from, to, amount, memo); + } + + /// @notice Verifies a Stablecoin authorized spender memo transfer remains subject to executor policy /// @dev The memo variant preserves the same policy boundary as transferFrom. - function test_transferFromWithMemo_revert_operatorExecutorPolicyForbids( + function test_transferFromWithMemo_revert_authorizedSpenderExecutorPolicyForbids( address from, address to, uint256 amount, @@ -18,13 +36,13 @@ contract B20StablecoinTransferFromWithMemoTest is B20StablecoinTest { ) public { _assumeValidActor(from); _assumeValidActor(to); - vm.assume(operator != from); + vm.assume(authorizedSpender != from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantOperator(); + _grantAuthorizedSpender(); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(operator); + vm.prank(authorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -35,9 +53,9 @@ contract B20StablecoinTransferFromWithMemoTest is B20StablecoinTest { token.transferFromWithMemo(from, to, amount, memo); } - /// @notice Verifies a Stablecoin operator can spend with a memo and zero allowance + /// @notice Verifies a Stablecoin authorized spender can spend with a memo and zero allowance /// @dev Confirms the shared memo allowance bypass applies to the Stablecoin variant. - function test_transferFromWithMemo_success_operatorSpendsWithoutAllowance( + function test_transferFromWithMemo_success_authorizedSpenderSpendsWithoutAllowance( address from, address to, uint256 amount, @@ -49,11 +67,11 @@ contract B20StablecoinTransferFromWithMemoTest is B20StablecoinTest { amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); - _grantOperator(); + _grantAuthorizedSpender(); - vm.prank(operator); + vm.prank(authorizedSpender); token.transferFromWithMemo(from, to, amount, memo); - assertEq(token.balanceOf(to), amount, "operator memo transfer must move holder balance"); + assertEq(token.balanceOf(to), amount, "authorized spender memo transfer must move holder balance"); } } diff --git a/test/unit/B20Stablecoin/roles/roleConstants.t.sol b/test/unit/B20Stablecoin/roles/roleConstants.t.sol index 89ff0899..7a83813e 100644 --- a/test/unit/B20Stablecoin/roles/roleConstants.t.sol +++ b/test/unit/B20Stablecoin/roles/roleConstants.t.sol @@ -5,10 +5,23 @@ import {B20StablecoinTest} from "base-std-test/lib/B20StablecoinTest.sol"; import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; contract B20StablecoinRoleConstantsTest is B20StablecoinTest { - /// @notice Verifies Stablecoin exposes the shared OPERATOR_ROLE constant + /// @notice Verifies Stablecoin does not expose the Asset-only OPERATOR_ROLE getter + /// @dev Pins the separation between Asset operations and shared spending authority. + function test_OPERATOR_ROLE_revert_notExposed() public view { + (bool success,) = address(token).staticcall(abi.encodeWithSignature("OPERATOR_ROLE()")); + assertFalse(success, "Stablecoin must not expose OPERATOR_ROLE"); + } + + /// @notice Verifies Stablecoin exposes the shared AUTHORIZED_SPENDER_ROLE constant /// @dev Pins the shared selector and role value on the Stablecoin variant. - function test_OPERATOR_ROLE_success_matchesExpected() public view { - assertEq(token.OPERATOR_ROLE(), keccak256("OPERATOR_ROLE"), "OPERATOR_ROLE digest"); - assertEq(token.OPERATOR_ROLE(), B20Constants.OPERATOR_ROLE, "OPERATOR_ROLE library value"); + function test_AUTHORIZED_SPENDER_ROLE_success_matchesExpected() public view { + assertEq( + token.AUTHORIZED_SPENDER_ROLE(), keccak256("AUTHORIZED_SPENDER_ROLE"), "AUTHORIZED_SPENDER_ROLE digest" + ); + assertEq( + token.AUTHORIZED_SPENDER_ROLE(), + B20Constants.AUTHORIZED_SPENDER_ROLE, + "AUTHORIZED_SPENDER_ROLE library value" + ); } } From aad0b9a3e9a0e188d5121549d64a5a4e4cd0bef0 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Thu, 10 Sep 2026 19:56:30 +0200 Subject: [PATCH 3/5] chore: remove unrelated operator changes Co-authored-by: OpenCode --- docs/concepts/policies.md | 1 + docs/concepts/roles-and-pause.md | 1 + docs/concepts/token-types.md | 2 +- src/interfaces/IB20Asset.sol | 2 +- src/lib/B20FactoryLib.sol | 2 +- test/lib/B20AssetTest.sol | 21 +++++++++++++++++---- test/lib/B20Test.sol | 2 -- test/lib/mocks/MockB20Asset.sol | 4 ++-- 8 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/concepts/policies.md b/docs/concepts/policies.md index 0b8c13c1..7f1df4b3 100644 --- a/docs/concepts/policies.md +++ b/docs/concepts/policies.md @@ -348,3 +348,4 @@ If the issuer later needs the same KYC list or-ed with a token-specific partner | `ChildPoliciesOutsideOfRange()` | A composite's child count is outside `[2, 4]` | | `InvalidChildPolicy(childPolicyId)` | A composite child is not an existing simple policy | | `NonPayable()` | ETH was attached to a registry call | + diff --git a/docs/concepts/roles-and-pause.md b/docs/concepts/roles-and-pause.md index 3613509d..c33980cb 100644 --- a/docs/concepts/roles-and-pause.md +++ b/docs/concepts/roles-and-pause.md @@ -258,3 +258,4 @@ sequenceDiagram | `EmptyFeatureSet()` | `pause`/`unpause` called with an empty array | | `LastAdminCannotRenounce()` | `revokeRole`/`renounceRole` would remove the last `DEFAULT_ADMIN_ROLE` holder | | `NotSoleAdmin()` | `renounceLastAdmin` called while other admins still exist | + diff --git a/docs/concepts/token-types.md b/docs/concepts/token-types.md index ef6d6190..bacf7735 100644 --- a/docs/concepts/token-types.md +++ b/docs/concepts/token-types.md @@ -46,7 +46,7 @@ Asset is the general-purpose variant. That includes real-world assets (RWAs). It Creation sets immutable `decimals` in `[6, 18]`. Values outside that range revert `InvalidDecimals`. Asset has no `currency()`. -It adds the Asset-only calls: `announce` for a corporate-action disclosure with a single-use `id` and optional inner calls, scheduled `updateUIMultiplier` / `cancelUIMultiplierUpdate` ([ERC-8056](https://eips.ethereum.org/EIPS/eip-8056)), an extra-metadata key/value store, and `batchMint`. The Asset-only `OPERATOR_ROLE` gates `announce` and multiplier updates. Name, symbol, contract URI, and extra metadata still use inherited `METADATA_ROLE`. +It adds the Asset-only calls: `announce` for a corporate-action disclosure with a single-use `id` and optional inner calls, scheduled `updateUIMultiplier` / `cancelUIMultiplierUpdate` ([ERC-8056](https://eips.ethereum.org/EIPS/eip-8056)), an extra-metadata key/value store, and `batchMint`. `OPERATOR_ROLE` is Asset-only and gates `announce` and multiplier updates. Name, symbol, contract URI, and extra metadata still use inherited `METADATA_ROLE`. Asset-specific state lives in `base.b20.asset`: `decimals`, `multiplier`, used announcement IDs, extra metadata, and the pending multiplier. Shared ERC-20, role, policy, and pause state stays in `base.b20`. diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 24445df7..6afd4bd8 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -114,7 +114,7 @@ interface IB20Asset is //////////////////////////////////////////////////////////////*/ /// @notice Required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and - /// `updateMultiplier`. The metadata setters (`updateName`, `updateSymbol`, + /// `updateUIMultiplier`. The metadata setters (`updateName`, `updateSymbol`, /// `updateExtraMetadata`) are gated by the inherited `METADATA_ROLE` instead. /// @return Role constant. function OPERATOR_ROLE() external view returns (bytes32); diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index f90b7a3a..9c69ecd8 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -51,7 +51,7 @@ library B20FactoryLib { } /// @notice Bootstrap role-grant bundle for `B20Variant.ASSET`. Superset of `B20RoleHolders` - /// with an `OPERATOR_ROLE` convenience slot. + /// with an `OPERATOR_ROLE` slot. /// /// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20AssetCreateParams.initialAdmin`, not this struct. /// @dev Append `encodeGrantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, spender)` when needed. diff --git a/test/lib/B20AssetTest.sol b/test/lib/B20AssetTest.sol index 9241abcc..087bfd96 100644 --- a/test/lib/B20AssetTest.sol +++ b/test/lib/B20AssetTest.sol @@ -10,8 +10,9 @@ import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; /// Extends `B20Test` for the inherited test surface (actors, labels, /// setUp wiring, the `_singleFeature` helper, the `_grantRole` / /// `_mint` / `_pause` action wrappers, and the asset-variant token -/// deployed by `_deployToken`). Adds helpers for the announcement, -/// multiplier, and extra-metadata surfaces. +/// deployed by `_deployToken`). Adds the variant-specific role holder +/// (`operator`) plus helpers for the announcement, multiplier, +/// and extra-metadata surfaces. /// /// The inherited `token` member is typed `IB20`. Tests that need the /// variant-only surface (`announce`, `batchMint`, etc.) cast inline via @@ -61,9 +62,11 @@ contract B20AssetTest is B20Test { // ASSET-ROLE HELPERS // ============================================================ - /// @notice Grants `OPERATOR_ROLE` to the `operator` actor as the admin, idempotently. + /// @notice Grants `OPERATOR_ROLE` to the `operator` actor as + /// the admin, idempotent. function _grantOperator() internal { - if (!token.hasRole(OPERATOR_ROLE, operator)) _grantRole(OPERATOR_ROLE, operator); + bytes32 role = asset().OPERATOR_ROLE(); + if (!token.hasRole(role, operator)) _grantRole(role, operator); } // ============================================================ @@ -142,5 +145,15 @@ contract B20AssetTest is B20Test { blobs[0] = blob; } + // ============================================================ + // VARIANT-ONLY CONSTANTS + // ============================================================ + // Compile-time copies of the contract's variant-only constants. + // Tests reference these when they need the value in a context that + // can't make a contract call (e.g. inside a struct literal). The + // values match `asset().OPERATOR_ROLE()` etc. by construction; + // the per-constant test in `test/unit/B20Asset/constants/` pins + // that down. + bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); } diff --git a/test/lib/B20Test.sol b/test/lib/B20Test.sol index 2e4b8bd6..d8476d54 100644 --- a/test/lib/B20Test.sol +++ b/test/lib/B20Test.sol @@ -40,8 +40,6 @@ contract B20Test is B20FactoryTest { address internal burnBlocker = makeAddr("burnBlocker"); address internal authorizedSpender = makeAddr("authorizedSpender"); - bytes32 internal constant AUTHORIZED_SPENDER_ROLE = keccak256("AUTHORIZED_SPENDER_ROLE"); - // -- Token under test -- /// @notice Asset-variant `IB20` token deployed in `setUp`. IB20 internal token; diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 133c3e8f..4902e099 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -11,7 +11,7 @@ import { IScaledUIAmountConversion } from "base-std/interfaces/IERC8056.sol"; -import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; +import {MockB20} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20AssetStorage, MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; /// @title MockB20Asset @@ -62,7 +62,7 @@ contract MockB20Asset is MockB20, IB20Asset { // CONSTANTS // ============================================================ - bytes32 public constant OPERATOR_ROLE = B20Constants.OPERATOR_ROLE; + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); /// @notice Fixed-point precision for the multiplier. `1e18` (one /// WAD) is the standard DeFi convention; `toScaledBalance` From c7396254b8a55ab8f78fdd11547aa18bb120e98e Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Fri, 11 Sep 2026 18:40:52 +0200 Subject: [PATCH 4/5] docs(changelog): add spender design alternatives Co-authored-by: OpenCode --- changelog/03_Denim_B20_authorized_spender.md | 32 ++++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/changelog/03_Denim_B20_authorized_spender.md b/changelog/03_Denim_B20_authorized_spender.md index 8d03fe1b..97e05666 100644 --- a/changelog/03_Denim_B20_authorized_spender.md +++ b/changelog/03_Denim_B20_authorized_spender.md @@ -6,11 +6,11 @@ ## Summary -Denim lets a B20 issuer grant an account permission to spend from every holder without holder approvals. The dedicated `AUTHORIZED_SPENDER_ROLE` keeps this authority separate from the Asset-only `OPERATOR_ROLE`. +Denim lets a B20 issuer grant an account permission to spend from every holder without holder approvals. ## Motivation -Some token integrations need one contract, such as a router or settlement system, to spend from every holder. Requiring each holder to call `approve` adds a transaction and prevents the integration from working for holders that cannot make an approval call. +Some token integrations need one contract, such as a router or settlement system, to spend from every holder. For example, an issuer can authorize Permit2 to unlock signature-based transfers. This provides a workaround when a smart contract account cannot use token-native permit functionality. Requiring each holder to call `approve` adds a transaction and prevents these integrations from working for holders that cannot make an approval call. ## Specs @@ -55,16 +55,17 @@ There is no storage change. Authorized spender membership uses the existing role ```solidity bytes32 spenderRole = token.AUTHORIZED_SPENDER_ROLE(); -token.grantRole(spenderRole, address(router)); +token.grantRole(spenderRole, address(permit2)); -// Returns type(uint256).max even when alice never approved the router. -uint256 effectiveAllowance = token.allowance(alice, address(router)); +// Returns type(uint256).max even when alice never approved Permit2. +uint256 effectiveAllowance = token.allowance(alice, address(permit2)); -// The router calls token.transferFrom(alice, recipient, amount) -// from its own execution context. +// Permit2 can execute signature-based transfers against alice's token balance. ``` -## Design Decisions +## Design Decisions and Alternatives Considered + +### Selected design - Add a dedicated role to keep holder-spending authority separate from Asset operations. - Reuse the existing RBAC set instead of adding a spender registry or policy scope. @@ -74,6 +75,18 @@ uint256 effectiveAllowance = token.allowance(alice, address(router)); - Reuse `DEFAULT_ADMIN_ROLE` as the default role administrator. - Waive only allowance checks. Compliance policies and pause remain independent controls. +### Alternatives considered + +| Alternative | Reason not selected | +| --- | --- | +| Dedicated spender mapping with `isAuthorizedSpender`, `setAuthorizedSpender`, and a new event | Adds storage, mutators, and an event when the existing RBAC set already provides membership management. | +| New Policy Registry scope | An unset policy ID means always allow. Special-casing that default for spender grants would invert existing policy semantics and create a severe configuration risk. | +| Explicit token API backed by a Policy Registry policy | Requires both a token-level policy reference and registry configuration, which adds two moving parts for one permission. | +| Reuse the Asset `OPERATOR_ROLE` | Combines holder-spending authority with announcements and multiplier administration. A separate role keeps each capability explicit. | +| Fixed Permit2 authorization | Restricts issuers to one integration. The selected design supports Permit2 and other issuer-chosen spenders without granting any address by default. | +| Per-holder opt-out | Adds per-holder state and another transfer branch without changing the issuer trust model. Role revocation remains the authority-removal path. | +| Dedicated admin role or delayed grants | Adds administration and pending-grant state. The existing delegable role-admin model supports separation when an issuer needs it, while keeping grants and revocations immediate. | + ## Migration Steps ### Issuers @@ -81,7 +94,6 @@ uint256 effectiveAllowance = token.allowance(alice, address(router)); 1. Check for any historical generic grant of the `AUTHORIZED_SPENDER_ROLE` hash. 2. Check `getRoleAdmin(AUTHORIZED_SPENDER_ROLE)` if the role hash was already configured. 3. Grant the role only to contracts and accounts that may move every holder's balance. -4. Keep `OPERATOR_ROLE` assignments unchanged unless the Asset operator itself also needs spending authority. ### Integrators @@ -89,4 +101,4 @@ uint256 effectiveAllowance = token.allowance(alice, address(router)); 2. Do not present `approve(spender, 0)` as a revocation path for role-based authority. 3. Continue to handle `ContractPaused`, `PolicyForbids`, and `InsufficientBalance` on authorized spender transfers. -Both variants gain the additive `AUTHORIZED_SPENDER_ROLE()` selector. Existing `OPERATOR_ROLE` assignments retain their previous capabilities. The existing ERC-20 selectors change behavior only for accounts that hold the new role hash. +Both variants gain the additive `AUTHORIZED_SPENDER_ROLE()` selector. The existing ERC-20 selectors change behavior only for accounts that hold the new role hash. From f137c13f040fd6e70418d10532960201ac13bdd3 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Fri, 11 Sep 2026 19:24:34 +0200 Subject: [PATCH 5/5] refactor(b20): rename spender role to preauthorized Co-authored-by: OpenCode --- CHANGELOG.md | 10 +-- ... => 03_Denim_B20_preauthorized_spender.md} | 48 ++++++----- changelog/README.md | 2 +- docs/concepts/multipliers.md | 2 +- docs/concepts/policies.md | 3 +- docs/concepts/roles-and-pause.md | 7 +- docs/concepts/token-types.md | 4 +- docs/overview.md | 4 +- docs/reference/constants.md | 2 +- docs/reference/errors.md | 2 +- src/interfaces/IB20.sol | 10 +-- src/lib/B20Constants.sol | 2 +- src/lib/B20FactoryLib.sol | 2 - test/lib/B20Test.sol | 14 ++-- test/lib/mocks/MockB20.sol | 12 +-- test/unit/B20/erc20/allowance.t.sol | 35 ++++---- test/unit/B20/erc20/transferFrom.t.sol | 82 ++++++++++--------- .../transferFromWithMemo_revertOrder.t.sol | 2 +- .../B20/erc20/transferFrom_revertOrder.t.sol | 2 +- test/unit/B20/memo/transferFromWithMemo.t.sol | 26 +++--- test/unit/B20/roles/getRoleAdmin.t.sol | 10 +-- test/unit/B20/roles/roleConstants.t.sol | 16 ++-- test/unit/B20/roles/setRoleAdmin.t.sol | 16 ++-- test/unit/B20Stablecoin/erc20/allowance.t.sol | 18 ++-- .../B20Stablecoin/erc20/transferFrom.t.sol | 64 ++++++++------- .../memo/transferFromWithMemo.t.sol | 26 +++--- .../B20Stablecoin/roles/roleConstants.t.sol | 14 ++-- 27 files changed, 226 insertions(+), 209 deletions(-) rename changelog/{03_Denim_B20_authorized_spender.md => 03_Denim_B20_preauthorized_spender.md} (63%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9018143a..4d9eed7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,27 +9,27 @@ in [`changelog/`](changelog/README.md). ### Status -Denim has not activated yet. Authorized spender allowance behavior remains unavailable until Denim selects B20 logic v3. +Denim has not activated yet. Preauthorized spender allowance behavior remains unavailable until Denim selects B20 logic v3. ### Compatibility -Denim adds the shared `AUTHORIZED_SPENDER_ROLE()` getter and changes the behavior of existing `allowance`, `transferFrom`, and `transferFromWithMemo` selectors for accounts that hold this role. +Denim adds the shared `PREAUTHORIZED_SPENDER_ROLE()` getter and changes the behavior of existing `allowance`, `transferFrom`, and `transferFromWithMemo` selectors for accounts that hold this role. ### Summary of changes | Product | Feature | Change | Details | | --- | --- | --- | --- | -| B20 (Asset and Stablecoin) | Issuer-authorized spenders | An account with `AUTHORIZED_SPENDER_ROLE` reads as having infinite allowance from every holder. Authorized spender transfers do not consume stored allowances, but pause and transfer policies remain active. | [03_Denim_B20_authorized_spender](changelog/03_Denim_B20_authorized_spender.md) | +| B20 (Asset and Stablecoin) | Preauthorized spenders | An account with `PREAUTHORIZED_SPENDER_ROLE` reads as having infinite allowance from every holder. Preauthorized spender transfers do not consume stored allowances, but pause and transfer policies remain active. | [03_Denim_B20_preauthorized_spender](changelog/03_Denim_B20_preauthorized_spender.md) | ### Migration guidance #### Issuers -Grant `AUTHORIZED_SPENDER_ROLE` only to contracts and accounts that may move every holder's balance. Existing Asset `OPERATOR_ROLE` assignments keep their announcement and multiplier capabilities and do not gain spending authority. +Grant `PREAUTHORIZED_SPENDER_ROLE` only to contracts and accounts that may move every holder's balance. Existing Asset `OPERATOR_ROLE` assignments keep their announcement and multiplier capabilities and do not gain spending authority. #### Wallets and integrators -Treat `allowance(owner, spender) == type(uint256).max` as possible role-based authority. A holder cannot revoke that authority with `approve(spender, 0)`; only the role administrator can remove it. Continue to enforce transfer policy failures and paused-transfer failures for authorized spender calls. +Treat `allowance(owner, spender) == type(uint256).max` as possible role-based authority. A holder cannot revoke that authority with `approve(spender, 0)`; only the role administrator can remove it. Continue to enforce transfer policy failures and paused-transfer failures for preauthorized spender calls. ## Cobalt diff --git a/changelog/03_Denim_B20_authorized_spender.md b/changelog/03_Denim_B20_preauthorized_spender.md similarity index 63% rename from changelog/03_Denim_B20_authorized_spender.md rename to changelog/03_Denim_B20_preauthorized_spender.md index 97e05666..37b41c9c 100644 --- a/changelog/03_Denim_B20_authorized_spender.md +++ b/changelog/03_Denim_B20_preauthorized_spender.md @@ -1,8 +1,8 @@ -# Denim: Issuer-Authorized Spenders +# Denim: Preauthorized Spenders -- **Feature Name**: authorized_spender -- **Start Date**: 2026-09-10 -- **Title**: Issuer-authorized infinite allowances through `AUTHORIZED_SPENDER_ROLE` +- **Feature Name**: preauthorized_spender +- **Start Date**: 2026-09-11 +- **Title**: Issuer-controlled infinite allowances through `PREAUTHORIZED_SPENDER_ROLE` ## Summary @@ -10,33 +10,33 @@ Denim lets a B20 issuer grant an account permission to spend from every holder w ## Motivation -Some token integrations need one contract, such as a router or settlement system, to spend from every holder. For example, an issuer can authorize Permit2 to unlock signature-based transfers. This provides a workaround when a smart contract account cannot use token-native permit functionality. Requiring each holder to call `approve` adds a transaction and prevents these integrations from working for holders that cannot make an approval call. +Some token integrations need one contract, such as a router or settlement system, to spend from every holder. For example, an issuer can preauthorize Permit2 to unlock signature-based transfers. This provides a workaround when a smart contract account cannot use token-native permit functionality and removes the per-holder approval transaction. ## Specs ### Interface changes -`AUTHORIZED_SPENDER_ROLE()` is added to the shared [`IB20`](../src/interfaces/IB20.sol) interface. +`PREAUTHORIZED_SPENDER_ROLE()` is added to the shared [`IB20`](../src/interfaces/IB20.sol) interface. | Function | Selector | Denim change | | --- | --- | --- | -| `AUTHORIZED_SPENDER_ROLE()` | `0xef97aa21` | New shared role getter on Asset and Stablecoin. | -| `allowance(address,address)` | `0xdd62ed3e` | Returns `type(uint256).max` when `spender` holds `AUTHORIZED_SPENDER_ROLE`. | -| `transferFrom(address,address,uint256)` | `0x23b872dd` | Skips allowance validation and consumption when the caller holds `AUTHORIZED_SPENDER_ROLE`. | -| `transferFromWithMemo(address,address,uint256,bytes32)` | `0x929c2539` | Applies the same authorized spender behavior as `transferFrom`. | +| `PREAUTHORIZED_SPENDER_ROLE()` | `0x6c0f8b76` | New shared role getter on Asset and Stablecoin. | +| `allowance(address,address)` | `0xdd62ed3e` | Returns `type(uint256).max` when `spender` holds `PREAUTHORIZED_SPENDER_ROLE`. | +| `transferFrom(address,address,uint256)` | `0x23b872dd` | Skips allowance validation and consumption when the caller holds `PREAUTHORIZED_SPENDER_ROLE`. | +| `transferFromWithMemo(address,address,uint256,bytes32)` | `0x929c2539` | Applies the same preauthorized spender behavior as `transferFrom`. | The role value is: ```solidity -keccak256("AUTHORIZED_SPENDER_ROLE") -// 0xb0e3ae34a3ebd864ed280a15abe71cbcaf59103e086737862f5bbccae6a44b37 +keccak256("PREAUTHORIZED_SPENDER_ROLE") +// 0xb90b441c392e1b39b562e08d16a15eab44f161ced0b6366c03aa7d09a22f1a41 ``` -No new mutator, event, error, or storage slot is added. Issuers manage membership with `grantRole`, `revokeRole`, and `renounceRole`. `getRoleAdmin(AUTHORIZED_SPENDER_ROLE)` defaults to `DEFAULT_ADMIN_ROLE` and remains delegable through `setRoleAdmin`. +No new mutator, event, error, or storage slot is added. Issuers manage membership with `grantRole`, `revokeRole`, and `renounceRole`. `getRoleAdmin(PREAUTHORIZED_SPENDER_ROLE)` defaults to `DEFAULT_ADMIN_ROLE` and remains delegable through `setRoleAdmin`. ### Behavioral changes -For a caller that holds `AUTHORIZED_SPENDER_ROLE`: +For a caller that holds `PREAUTHORIZED_SPENDER_ROLE`: - `allowance(owner, caller)` returns `type(uint256).max` for every `owner`. - `transferFrom` and `transferFromWithMemo` do not read or decrement the stored allowance. @@ -49,12 +49,20 @@ For any other caller, allowance behavior remains unchanged. A finite allowance d ### Storage layout -There is no storage change. Authorized spender membership uses the existing role mapping. Holder allowances remain in their existing slots while the spender holds `AUTHORIZED_SPENDER_ROLE`. +There is no storage change. Preauthorized spender membership uses the existing role mapping. Holder allowances remain in their existing slots while the spender holds `PREAUTHORIZED_SPENDER_ROLE`. + +### Gas considerations + +- `allowance` adds a role-membership read. A preauthorized spender returns after that read and skips the stored allowance read. +- `transferFrom` and `transferFromWithMemo` add a role-membership read for all callers. +- A preauthorized spender skips the allowance read and any finite-allowance write. +- A caller without the role pays for the additional role read and then follows the existing allowance path. +- Exact native gas costs depend on the B20 logic v3 implementation and must be benchmarked with that implementation. ## Example ```solidity -bytes32 spenderRole = token.AUTHORIZED_SPENDER_ROLE(); +bytes32 spenderRole = token.PREAUTHORIZED_SPENDER_ROLE(); token.grantRole(spenderRole, address(permit2)); // Returns type(uint256).max even when alice never approved Permit2. @@ -91,14 +99,14 @@ uint256 effectiveAllowance = token.allowance(alice, address(permit2)); ### Issuers -1. Check for any historical generic grant of the `AUTHORIZED_SPENDER_ROLE` hash. -2. Check `getRoleAdmin(AUTHORIZED_SPENDER_ROLE)` if the role hash was already configured. +1. Check for any historical generic grant of the `PREAUTHORIZED_SPENDER_ROLE` hash. +2. Check `getRoleAdmin(PREAUTHORIZED_SPENDER_ROLE)` if the role hash was already configured. 3. Grant the role only to contracts and accounts that may move every holder's balance. ### Integrators 1. Do not assume that `allowance == type(uint256).max` came from holder approval. 2. Do not present `approve(spender, 0)` as a revocation path for role-based authority. -3. Continue to handle `ContractPaused`, `PolicyForbids`, and `InsufficientBalance` on authorized spender transfers. +3. Continue to handle `ContractPaused`, `PolicyForbids`, and `InsufficientBalance` on preauthorized spender transfers. -Both variants gain the additive `AUTHORIZED_SPENDER_ROLE()` selector. The existing ERC-20 selectors change behavior only for accounts that hold the new role hash. +Both variants gain the additive `PREAUTHORIZED_SPENDER_ROLE()` selector. The existing ERC-20 selectors change behavior only for accounts that hold the new role hash. diff --git a/changelog/README.md b/changelog/README.md index c21fdc44..27e6a4c6 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -27,7 +27,7 @@ Grouped by hardfork, one collapsible section per hardfork, newest first. | Product(s) | Change | Affected interfaces | Entry | | --- | --- | --- | --- | -| B20 Asset, B20 Stablecoin | Issuer-authorized spenders | `src/interfaces/IB20.sol` (shared surface) inherited by `src/interfaces/IB20Asset.sol`, `src/interfaces/IB20Stablecoin.sol` | [03_Denim_B20_authorized_spender](03_Denim_B20_authorized_spender.md) | +| B20 Asset, B20 Stablecoin | Preauthorized spenders | `src/interfaces/IB20.sol` (shared surface) inherited by `src/interfaces/IB20Asset.sol`, `src/interfaces/IB20Stablecoin.sol` | [03_Denim_B20_preauthorized_spender](03_Denim_B20_preauthorized_spender.md) | diff --git a/docs/concepts/multipliers.md b/docs/concepts/multipliers.md index 9152a069..d83be2f7 100644 --- a/docs/concepts/multipliers.md +++ b/docs/concepts/multipliers.md @@ -46,7 +46,7 @@ Convert a single amount with `toUIAmount(raw)` and `fromUIAmount(ui)` at that ef ### What it preserves -`balanceOf`, `transfer` amounts, `totalSupply`, and stored allowances stay raw. Protocols that use that ERC-20 surface do not see the split. The separate `AUTHORIZED_SPENDER_ROLE` rule can make `allowance(owner, spender)` return `type(uint256).max`; that value does not use the UI multiplier. +`balanceOf`, `transfer` amounts, `totalSupply`, and stored allowances stay raw. Protocols that use that ERC-20 surface do not see the split. The separate `PREAUTHORIZED_SPENDER_ROLE` rule can make `allowance(owner, spender)` return `type(uint256).max`; that value does not use the UI multiplier. The UI views above are opt-in. Protocols that call `balanceOfUI`, `scaledBalanceOf`, or `totalSupplyUI` do see the split. diff --git a/docs/concepts/policies.md b/docs/concepts/policies.md index 7f1df4b3..d5fe60f0 100644 --- a/docs/concepts/policies.md +++ b/docs/concepts/policies.md @@ -204,7 +204,7 @@ Most scopes deny when `isAuthorized` is `false` and revert `PolicyForbids`. `SEI | `SEIZE_HOLDER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means no account is seizable. | `from` | `true` | `AccountNotSeizable` | | `SEIZE_RECEIVER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means seize may send to any destination. | `to` | `false` | `PolicyForbids` | -`AUTHORIZED_SPENDER_ROLE` does not bypass these scopes. An authorized spender skips only the allowance check in `transferFrom` and `transferFromWithMemo`. The executor, sender, and receiver policy checks still run. +`PREAUTHORIZED_SPENDER_ROLE` does not bypass these scopes. A preauthorized spender skips only the allowance check in `transferFrom` and `transferFromWithMemo`. The executor, sender, and receiver policy checks still run. ## 4. Example @@ -348,4 +348,3 @@ If the issuer later needs the same KYC list or-ed with a token-specific partner | `ChildPoliciesOutsideOfRange()` | A composite's child count is outside `[2, 4]` | | `InvalidChildPolicy(childPolicyId)` | A composite child is not an existing simple policy | | `NonPayable()` | ETH was attached to a registry call | - diff --git a/docs/concepts/roles-and-pause.md b/docs/concepts/roles-and-pause.md index c33980cb..9080b76e 100644 --- a/docs/concepts/roles-and-pause.md +++ b/docs/concepts/roles-and-pause.md @@ -33,13 +33,13 @@ Two functions always require `DEFAULT_ADMIN_ROLE`: `updatePolicy` and `updateSup | `PAUSE_ROLE` | `pause` | | `UNPAUSE_ROLE` | `unpause` | | `METADATA_ROLE` | `updateName`, `updateSymbol`, `updateContractURI`; Asset also gates `updateExtraMetadata` | -| `AUTHORIZED_SPENDER_ROLE` | Infinite `transferFrom` allowance from every holder | +| `PREAUTHORIZED_SPENDER_ROLE` | Infinite `transferFrom` allowance from every holder | | `OPERATOR_ROLE` | Asset-only: `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, deprecated `updateMultiplier` | -`AUTHORIZED_SPENDER_ROLE` is shared by Asset and Stablecoin. For any holder and authorized spender, `allowance(holder, spender)` returns `type(uint256).max`. The authorized spender can call `transferFrom` or `transferFromWithMemo` without holder approval, and those calls do not change the holder's stored allowance. `approve(spender, 0)` does not opt the holder out. The role administrator must revoke `AUTHORIZED_SPENDER_ROLE` to remove the authority. +`PREAUTHORIZED_SPENDER_ROLE` is shared by Asset and Stablecoin. For any holder and preauthorized spender, `allowance(holder, spender)` returns `type(uint256).max`. The preauthorized spender can call `transferFrom` or `transferFromWithMemo` without holder approval, and those calls do not change the holder's stored allowance. `approve(spender, 0)` does not opt the holder out. The role administrator must revoke `PREAUTHORIZED_SPENDER_ROLE` to remove the authority. -Authorized spender transfers still use the `TRANSFER` pause vector and all three transfer policy scopes. The role waives only the allowance check. `OPERATOR_ROLE` remains an Asset-only role for announcements and multiplier updates. `approve` and holder `transfer` are not role-gated. +Preauthorized spender transfers still use the `TRANSFER` pause vector and all three transfer policy scopes. The role waives only the allowance check. `OPERATOR_ROLE` remains an Asset-only role for announcements and multiplier updates. `approve` and holder `transfer` are not role-gated. ### 2.3 Granting and revoking @@ -258,4 +258,3 @@ sequenceDiagram | `EmptyFeatureSet()` | `pause`/`unpause` called with an empty array | | `LastAdminCannotRenounce()` | `revokeRole`/`renounceRole` would remove the last `DEFAULT_ADMIN_ROLE` holder | | `NotSoleAdmin()` | `renounceLastAdmin` called while other admins still exist | - diff --git a/docs/concepts/token-types.md b/docs/concepts/token-types.md index bacf7735..e1cabda0 100644 --- a/docs/concepts/token-types.md +++ b/docs/concepts/token-types.md @@ -58,7 +58,7 @@ Stablecoin is the fiat-pegged variant. `decimals` is hardcoded to `6`. The issuer does not pass decimals. -The extra surface on top of `IB20` is `currency()`. Stablecoin has no announce, multiplier, extra metadata, `batchMint`, or `OPERATOR_ROLE`. It inherits the shared `AUTHORIZED_SPENDER_ROLE` allowance behavior from `IB20`. +The extra surface on top of `IB20` is `currency()`. Stablecoin has no announce, multiplier, extra metadata, `batchMint`, or `OPERATOR_ROLE`. It inherits the shared `PREAUTHORIZED_SPENDER_ROLE` allowance behavior from `IB20`. Stablecoin-specific state lives in `base.b20.stablecoin` (`currency` only). Shared ERC-20, role, policy, and pause state stays in `base.b20`. @@ -70,7 +70,7 @@ The same issuer can create both types. Different salts produce different address ### 6.1 Creating a Stablecoin -Predict the address with `getB20Address(STABLECOIN, sender, saltB)`. Then call `createB20` with `B20StablecoinCreateParams`: `version` `1`, name, symbol, `initialAdmin`, and `currency: "USD"`. Optional `initCalls` can grant `AUTHORIZED_SPENDER_ROLE` through the standard `grantRole` encoder. +Predict the address with `getB20Address(STABLECOIN, sender, saltB)`. Then call `createB20` with `B20StablecoinCreateParams`: `version` `1`, name, symbol, `initialAdmin`, and `currency: "USD"`. Optional `initCalls` can grant `PREAUTHORIZED_SPENDER_ROLE` through the standard `grantRole` encoder. ```mermaid sequenceDiagram diff --git a/docs/overview.md b/docs/overview.md index 8ae10d86..8a40d147 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -78,11 +78,11 @@ The Activation Registry is a Base-operated safety switch that turns Factory and ## Configuring Roles -Roles let an issuer assign each privileged operation to a specific account. An admin can grant minting to a minter, seizing to a compliance operator, issuer-approved spending to an authorized spender, and pausing of a single feature (`TRANSFER`, `MINT`, `BURN`, or `SEIZE`) without pausing the rest of the token. +Roles let an issuer assign each privileged operation to a specific account. An admin can grant minting to a minter, seizing to a compliance operator, spending to a preauthorized spender, and pausing of a single feature (`TRANSFER`, `MINT`, `BURN`, or `SEIZE`) without pausing the rest of the token. B20 implements this with [OpenZeppelin AccessControl](https://docs.openzeppelin.com/contracts/5.x/access-control) on the token. Roles are not a separate registry. One `DEFAULT_ADMIN_ROLE` holder grants and revokes the operating roles. A privileged call checks the role first, then the matching pause vector. Holder `transfer` skips the role check; it still hits the `TRANSFER` pause vector and policy. -`AUTHORIZED_SPENDER_ROLE` gives its holder an infinite allowance from every token holder. `allowance(owner, spender)` returns `type(uint256).max`, and `transferFrom` does not consume the holder's stored allowance. The transfer pause vector and sender, receiver, and executor policies still apply. A holder cannot opt out by approving zero; the role admin must revoke the role. +`PREAUTHORIZED_SPENDER_ROLE` gives its holder an infinite allowance from every token holder. `allowance(owner, spender)` returns `type(uint256).max`, and `transferFrom` does not consume the holder's stored allowance. The transfer pause vector and sender, receiver, and executor policies still apply. A holder cannot opt out by approving zero; the role admin must revoke the role. The full role list and what each role gates is in [Roles](./concepts/roles.md). A role-gated call looks like this: diff --git a/docs/reference/constants.md b/docs/reference/constants.md index cbc77f06..478d48bd 100644 --- a/docs/reference/constants.md +++ b/docs/reference/constants.md @@ -26,7 +26,7 @@ | `PAUSE_ROLE` | `keccak256("PAUSE_ROLE")`
`0x139c2898040ef16910dc9f44dc697df79363da767d8bc92f2e310312b816e46d` | Required to call `pause`. | | `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")`
`0x265b220c5a8891efdd9e1b1b7fa72f257bd5169f8d87e319cf3dad6ff52b94ae` | Required to call `unpause`. | | `METADATA_ROLE` | `keccak256("METADATA_ROLE")`
`0x6bd6b5318a46e5fff572d5e4258a20774aab40cc35ac7680654b9081fcc82f80` | Required to call `updateName`, `updateSymbol`, `updateContractURI`, and `updateExtraMetadata`. | -| `AUTHORIZED_SPENDER_ROLE` | `keccak256("AUTHORIZED_SPENDER_ROLE")`
`0xb0e3ae34a3ebd864ed280a15abe71cbcaf59103e086737862f5bbccae6a44b37` | Grants infinite allowance from every holder. | +| `PREAUTHORIZED_SPENDER_ROLE` | `keccak256("PREAUTHORIZED_SPENDER_ROLE")`
`0xb90b441c392e1b39b562e08d16a15eab44f161ced0b6366c03aa7d09a22f1a41` | Grants infinite allowance from every holder. | | `OPERATOR_ROLE` | `keccak256("OPERATOR_ROLE")`
`0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | B20Asset-only. Required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `updateMultiplier`. | ## Policy types diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 75ba974d..8731d01c 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -12,7 +12,7 @@ | `AccessControlUnauthorizedAccount(address account, bytes32 neededRole)` | `0xe2517d3f` | `account` does not hold `neededRole`. | | `Unauthorized()` | `0x82b42900` | Caller failed a positional authorization check that isn't expressible as "missing role X". | | `ContractPaused(uint8 feature)` | `0xfd8c4245` | The `PausableFeature` covering the operation is currently paused. | -| `InsufficientAllowance(address spender, uint256 allowance, uint256 needed)` | `0x192b9e4e` | `spender` does not hold `AUTHORIZED_SPENDER_ROLE`, and its allowance is less than `needed` for the requested `transferFrom`. | +| `InsufficientAllowance(address spender, uint256 allowance, uint256 needed)` | `0x192b9e4e` | `spender` does not hold `PREAUTHORIZED_SPENDER_ROLE`, and its allowance is less than `needed` for the requested `transferFrom`. | | `InsufficientBalance(address sender, uint256 balance, uint256 needed)` | `0xdb42144d` | `sender`'s balance is less than `needed` for the requested transfer or burn. | | `InvalidSender(address sender)` | `0x4c14f64c` | The transfer's source address is invalid (typically `address(0)`). | | `InvalidReceiver(address receiver)` | `0x9cfea583` | The transfer's destination address is invalid (typically `address(0)`). | diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index fe8e0ce2..88ed69cf 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -234,7 +234,7 @@ interface IB20 { /// @notice Grants an infinite allowance from every holder for `transferFrom` and `transferFromWithMemo`. /// @return Role constant. - function AUTHORIZED_SPENDER_ROLE() external view returns (bytes32); + function PREAUTHORIZED_SPENDER_ROLE() external view returns (bytes32); /*////////////////////////////////////////////////////////////// POLICY TYPE CONSTANTS @@ -309,7 +309,7 @@ interface IB20 { function balanceOf(address account) external view returns (uint256); /// @notice Allowance granted by `owner` to `spender`. Returns `type(uint256).max` when `spender` holds - /// `AUTHORIZED_SPENDER_ROLE`, regardless of the stored allowance. + /// `PREAUTHORIZED_SPENDER_ROLE`, regardless of the stored allowance. /// /// @param owner Allowance owner. /// @param spender Allowance spender. @@ -333,12 +333,12 @@ interface IB20 { function transfer(address to, uint256 amount) external returns (bool); /// @notice Transfers `amount` from `from` to `to` using `msg.sender`'s allowance or - /// `AUTHORIZED_SPENDER_ROLE`. Emits `Transfer`. + /// `PREAUTHORIZED_SPENDER_ROLE`. Emits `Transfer`. /// /// @dev Reverts with `ContractPaused(TRANSFER)` when `TRANSFER` is paused. /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. /// @dev Reverts with `InvalidSender` when `from == address(0)`. - /// @dev Reverts with `InsufficientAllowance` when the caller does not hold `AUTHORIZED_SPENDER_ROLE` and its + /// @dev Reverts with `InsufficientAllowance` when the caller does not hold `PREAUTHORIZED_SPENDER_ROLE` and its /// allowance from `from` is below `amount`. /// @dev Reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender != from` and `msg.sender` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `from` is not authorized. @@ -353,7 +353,7 @@ interface IB20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); /// @notice Sets `spender`'s stored allowance to `amount`. Not gated by any policy or by pause. Emits `Approval`. - /// This does not limit a spender that holds `AUTHORIZED_SPENDER_ROLE`. + /// This does not limit a spender that holds `PREAUTHORIZED_SPENDER_ROLE`. /// /// @dev Reverts with `InvalidApprover` when `msg.sender == address(0)`. /// @dev Reverts with `InvalidSpender` when `spender == address(0)`. diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index fef12f9f..549d8026 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -12,7 +12,7 @@ library B20Constants { bytes32 internal constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); bytes32 internal constant UNPAUSE_ROLE = keccak256("UNPAUSE_ROLE"); bytes32 internal constant METADATA_ROLE = keccak256("METADATA_ROLE"); - bytes32 internal constant AUTHORIZED_SPENDER_ROLE = keccak256("AUTHORIZED_SPENDER_ROLE"); + bytes32 internal constant PREAUTHORIZED_SPENDER_ROLE = keccak256("PREAUTHORIZED_SPENDER_ROLE"); bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); bytes32 internal constant TRANSFER_SENDER_POLICY = keccak256("TRANSFER_SENDER_POLICY"); diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index 9c69ecd8..4b620530 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -34,7 +34,6 @@ library B20FactoryLib { /// `address(0)` fields are skipped at bootstrap. /// /// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20StablecoinCreateParams.initialAdmin`, not this struct. - /// @dev Append `encodeGrantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, spender)` when needed. struct B20RoleHolders { /// @dev Account granted `MINT_ROLE`. address minter; @@ -54,7 +53,6 @@ library B20FactoryLib { /// with an `OPERATOR_ROLE` slot. /// /// @dev `DEFAULT_ADMIN_ROLE` is assigned via `B20AssetCreateParams.initialAdmin`, not this struct. - /// @dev Append `encodeGrantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, spender)` when needed. struct B20AssetRoleHolders { /// @dev Account granted `MINT_ROLE`. address minter; diff --git a/test/lib/B20Test.sol b/test/lib/B20Test.sol index d8476d54..25aad071 100644 --- a/test/lib/B20Test.sol +++ b/test/lib/B20Test.sol @@ -19,7 +19,7 @@ import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; /// /// On top of the inherited factory actors, this contract adds the /// token-specific role-holders (`minter`, `burner`, `pauser`, -/// `unpauser`, `burnBlocker`, `authorizedSpender`) so role-gated tests have explicit named +/// `unpauser`, `burnBlocker`, `preauthorizedSpender`) so role-gated tests have explicit named /// accounts to grant roles to in setUp's initCalls. contract B20Test is B20FactoryTest { // Role constants (DEFAULT_ADMIN_ROLE, MINT_ROLE, BURN_ROLE, @@ -38,7 +38,7 @@ contract B20Test is B20FactoryTest { address internal pauser = makeAddr("pauser"); address internal unpauser = makeAddr("unpauser"); address internal burnBlocker = makeAddr("burnBlocker"); - address internal authorizedSpender = makeAddr("authorizedSpender"); + address internal preauthorizedSpender = makeAddr("preauthorizedSpender"); // -- Token under test -- /// @notice Asset-variant `IB20` token deployed in `setUp`. @@ -53,7 +53,7 @@ contract B20Test is B20FactoryTest { vm.label(pauser, "pauser"); vm.label(unpauser, "unpauser"); vm.label(burnBlocker, "burnBlocker"); - vm.label(authorizedSpender, "authorizedSpender"); + vm.label(preauthorizedSpender, "preauthorizedSpender"); token = _deployToken(); vm.label(address(token), "token"); @@ -109,10 +109,10 @@ contract B20Test is B20FactoryTest { token.grantRole(role, account); } - /// @notice Grants `AUTHORIZED_SPENDER_ROLE` to the `authorizedSpender` actor as the admin, idempotently. - function _grantAuthorizedSpender() internal { - if (!token.hasRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender)) { - _grantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); + /// @notice Grants `PREAUTHORIZED_SPENDER_ROLE` to the `preauthorizedSpender` actor as the admin, idempotently. + function _grantPreauthorizedSpender() internal { + if (!token.hasRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender)) { + _grantRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender); } } diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 5a3cef96..b70fc316 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -89,7 +89,7 @@ abstract contract MockB20 is IB20 { bytes32 public constant PAUSE_ROLE = B20Constants.PAUSE_ROLE; bytes32 public constant UNPAUSE_ROLE = B20Constants.UNPAUSE_ROLE; bytes32 public constant METADATA_ROLE = B20Constants.METADATA_ROLE; - bytes32 public constant AUTHORIZED_SPENDER_ROLE = B20Constants.AUTHORIZED_SPENDER_ROLE; + bytes32 public constant PREAUTHORIZED_SPENDER_ROLE = B20Constants.PREAUTHORIZED_SPENDER_ROLE; /// @notice Policy-type constants. Same `keccak256` convention as roles. bytes32 public constant TRANSFER_SENDER_POLICY = B20Constants.TRANSFER_SENDER_POLICY; @@ -178,7 +178,7 @@ abstract contract MockB20 is IB20 { } function allowance(address owner, address spender) external view returns (uint256) { - if (hasRole(AUTHORIZED_SPENDER_ROLE, spender)) return type(uint256).max; + if (hasRole(PREAUTHORIZED_SPENDER_ROLE, spender)) return type(uint256).max; return MockB20Storage.layout().allowances[owner][spender]; } @@ -198,7 +198,7 @@ abstract contract MockB20 is IB20 { returns (bool) { _requireNonZeroActors(from, to); - // Factory privilege does not bypass allowance accounting. AUTHORIZED_SPENDER_ROLE + // Factory privilege does not bypass allowance accounting. PREAUTHORIZED_SPENDER_ROLE // and the infinite-allowance sentinel do bypass it inside `_consumeAllowance`. _consumeAllowance(from, msg.sender, amount); if (!_isPrivileged() && msg.sender != from) { @@ -244,7 +244,7 @@ abstract contract MockB20 is IB20 { returns (bool) { _requireNonZeroActors(from, to); - // Factory privilege does not bypass allowance accounting. AUTHORIZED_SPENDER_ROLE + // Factory privilege does not bypass allowance accounting. PREAUTHORIZED_SPENDER_ROLE // and the infinite-allowance sentinel do bypass it inside `_consumeAllowance`. _consumeAllowance(from, msg.sender, amount); if (!_isPrivileged() && msg.sender != from) { @@ -725,7 +725,7 @@ abstract contract MockB20 is IB20 { } function _consumeAllowance(address owner, address spender, uint256 amount) internal { - if (hasRole(AUTHORIZED_SPENDER_ROLE, spender)) return; + if (hasRole(PREAUTHORIZED_SPENDER_ROLE, spender)) return; uint256 current = MockB20Storage.layout().allowances[owner][spender]; if (current != type(uint256).max) { @@ -751,7 +751,7 @@ abstract contract MockB20 is IB20 { /// `transferWithMemo`, `transferFromWithMemo`) before reaching /// this helper. `transferFrom` / `transferFromWithMemo` /// additionally consume the allowance unless the caller holds - /// `AUTHORIZED_SPENDER_ROLE`, and check the executor policy in their bodies + /// `PREAUTHORIZED_SPENDER_ROLE`, and check the executor policy in their bodies /// before calling here. Only the policy checks honor the bootstrap /// bypass. function _transfer(address from, address to, uint256 amount) internal { diff --git a/test/unit/B20/erc20/allowance.t.sol b/test/unit/B20/erc20/allowance.t.sol index 4b8a48bc..2234fda3 100644 --- a/test/unit/B20/erc20/allowance.t.sol +++ b/test/unit/B20/erc20/allowance.t.sol @@ -58,45 +58,46 @@ contract B20AllowanceTest is B20Test { ); } - /// @notice Verifies every holder reports an infinite allowance for an authorized spender + /// @notice Verifies every holder reports an infinite allowance for a preauthorized spender /// @dev Role membership overrides the allowance view without changing the stored allowance. - function test_allowance_success_authorizedSpenderReadsAsInfinite(address owner, uint256 storedAllowance) public { + function test_allowance_success_preauthorizedSpenderReadsAsInfinite(address owner, uint256 storedAllowance) public { _assumeValidActor(owner); vm.prank(owner); - token.approve(authorizedSpender, storedAllowance); - _grantAuthorizedSpender(); + token.approve(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); assertEq( - token.allowance(owner, authorizedSpender), + token.allowance(owner, preauthorizedSpender), type(uint256).max, - "authorized spender allowance must read as infinite" + "preauthorized spender allowance must read as infinite" ); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, authorizedSpender))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, preauthorizedSpender))), storedAllowance, - "authorized spender role must not overwrite stored allowance" + "preauthorized spender role must not overwrite stored allowance" ); } - /// @notice Verifies revoking AUTHORIZED_SPENDER_ROLE restores the holder's stored allowance + /// @notice Verifies revoking PREAUTHORIZED_SPENDER_ROLE restores the holder's stored allowance /// @dev Role revocation removes only the synthetic infinite allowance. - function test_allowance_success_revokedAuthorizedSpenderReadsStoredAllowance(address owner, uint256 storedAllowance) - public - { + function test_allowance_success_revokedPreauthorizedSpenderReadsStoredAllowance( + address owner, + uint256 storedAllowance + ) public { _assumeValidActor(owner); vm.prank(owner); - token.approve(authorizedSpender, storedAllowance); - _grantAuthorizedSpender(); + token.approve(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); vm.prank(admin); - token.revokeRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); + token.revokeRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender); assertEq( - token.allowance(owner, authorizedSpender), + token.allowance(owner, preauthorizedSpender), storedAllowance, - "revoked authorized spender must read stored allowance" + "revoked preauthorized spender must read stored allowance" ); } } diff --git a/test/unit/B20/erc20/transferFrom.t.sol b/test/unit/B20/erc20/transferFrom.t.sol index 545eeda6..55e1f993 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -57,20 +57,22 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } - /// @notice Verifies AUTHORIZED_SPENDER_ROLE does not bypass TRANSFER_EXECUTOR_POLICY + /// @notice Verifies PREAUTHORIZED_SPENDER_ROLE does not bypass TRANSFER_EXECUTOR_POLICY /// @dev The role waives allowance only; executor policy remains active. - function test_transferFrom_revert_authorizedSpenderExecutorPolicyForbids(address from, address to, uint256 amount) - public - { + function test_transferFrom_revert_preauthorizedSpenderExecutorPolicyForbids( + address from, + address to, + uint256 amount + ) public { _assumeValidActor(from); _assumeValidActor(to); - vm.assume(authorizedSpender != from); + vm.assume(preauthorizedSpender != from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -107,19 +109,19 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } - /// @notice Verifies AUTHORIZED_SPENDER_ROLE does not bypass TRANSFER_SENDER_POLICY + /// @notice Verifies PREAUTHORIZED_SPENDER_ROLE does not bypass TRANSFER_SENDER_POLICY /// @dev The role waives allowance only; sender policy remains active. - function test_transferFrom_revert_authorizedSpenderSenderPolicyForbids(address from, address to, uint256 amount) + function test_transferFrom_revert_preauthorizedSpenderSenderPolicyForbids(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -156,19 +158,21 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } - /// @notice Verifies AUTHORIZED_SPENDER_ROLE does not bypass TRANSFER_RECEIVER_POLICY + /// @notice Verifies PREAUTHORIZED_SPENDER_ROLE does not bypass TRANSFER_RECEIVER_POLICY /// @dev The role waives allowance only; receiver policy remains active. - function test_transferFrom_revert_authorizedSpenderReceiverPolicyForbids(address from, address to, uint256 amount) - public - { + function test_transferFrom_revert_preauthorizedSpenderReceiverPolicyForbids( + address from, + address to, + uint256 amount + ) public { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -196,9 +200,9 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } - /// @notice Verifies a revoked authorized spender must use the holder's stored allowance + /// @notice Verifies a revoked preauthorized spender must use the holder's stored allowance /// @dev Revocation removes the allowance bypass immediately. - function test_transferFrom_revert_revokedAuthorizedSpenderInsufficientAllowance( + function test_transferFrom_revert_revokedPreauthorizedSpenderInsufficientAllowance( address from, address to, uint256 amount @@ -207,12 +211,12 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(to); amount = bound(amount, 1, type(uint256).max); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); vm.prank(admin); - token.revokeRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); + token.revokeRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender); - vm.prank(authorizedSpender); - vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, authorizedSpender, 0, amount)); + vm.prank(preauthorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, preauthorizedSpender, 0, amount)); token.transferFrom(from, to, amount); } @@ -342,9 +346,9 @@ contract B20TransferFromTest is B20Test { ); } - /// @notice Verifies approving zero does not opt a holder out of authorized spender transfers - /// @dev AUTHORIZED_SPENDER_ROLE supplies independent infinite authority over every holder balance. - function test_transferFrom_success_authorizedSpenderSpendsAfterHolderApprovesZero( + /// @notice Verifies approving zero does not opt a holder out of preauthorized spender transfers + /// @dev PREAUTHORIZED_SPENDER_ROLE supplies independent infinite authority over every holder balance. + function test_transferFrom_success_preauthorizedSpenderSpendsAfterHolderApprovesZero( address from, address to, uint256 amount @@ -356,23 +360,23 @@ contract B20TransferFromTest is B20Test { _mint(from, amount); vm.prank(from); - token.approve(authorizedSpender, 0); - _grantAuthorizedSpender(); + token.approve(preauthorizedSpender, 0); + _grantPreauthorizedSpender(); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); token.transferFrom(from, to, amount); - assertEq(token.balanceOf(to), amount, "authorized spender must spend despite zero stored allowance"); + assertEq(token.balanceOf(to), amount, "preauthorized spender must spend despite zero stored allowance"); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, preauthorizedSpender))), 0, "zero stored allowance must remain unchanged" ); } - /// @notice Verifies authorized spender transfers do not consume a finite stored allowance + /// @notice Verifies preauthorized spender transfers do not consume a finite stored allowance /// @dev Role authority and holder-managed allowance accounting remain independent. - function test_transferFrom_success_authorizedSpenderPreservesStoredAllowance( + function test_transferFrom_success_preauthorizedSpenderPreservesStoredAllowance( address from, address to, uint256 storedAllowance, @@ -386,19 +390,19 @@ contract B20TransferFromTest is B20Test { _mint(from, amount); vm.prank(from); - token.approve(authorizedSpender, storedAllowance); - _grantAuthorizedSpender(); + token.approve(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); token.transferFrom(from, to, amount); assertEq( - token.allowance(from, authorizedSpender), + token.allowance(from, preauthorizedSpender), type(uint256).max, - "authorized spender allowance must remain infinite" + "preauthorized spender allowance must remain infinite" ); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, preauthorizedSpender))), storedAllowance, "stored allowance must not be consumed" ); diff --git a/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol b/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol index 093d9bd4..66a15265 100644 --- a/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol +++ b/test/unit/B20/erc20/transferFromWithMemo_revertOrder.t.sol @@ -13,7 +13,7 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// `transferFrom`; the memo parameter adds no new revert conditions. /// /// **Canonical order (Solidity reference, when `msg.sender != from` and the caller lacks -/// `AUTHORIZED_SPENDER_ROLE`):** +/// `PREAUTHORIZED_SPENDER_ROLE`):** /// 1. PAUSE (`whenNotPaused(TRANSFER)` modifier) → `ContractPaused` /// 2. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 3. ZERO-SENDER (`from == address(0)`) → `InvalidSender` diff --git a/test/unit/B20/erc20/transferFrom_revertOrder.t.sol b/test/unit/B20/erc20/transferFrom_revertOrder.t.sol index ce3f1713..13e8e754 100644 --- a/test/unit/B20/erc20/transferFrom_revertOrder.t.sol +++ b/test/unit/B20/erc20/transferFrom_revertOrder.t.sol @@ -16,7 +16,7 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// work in the entrypoint body. /// /// **Canonical order (Solidity reference, when -/// `msg.sender != from` and the caller lacks `AUTHORIZED_SPENDER_ROLE`):** +/// `msg.sender != from` and the caller lacks `PREAUTHORIZED_SPENDER_ROLE`):** /// 1. PAUSE (`whenNotPaused(TRANSFER)` modifier) → `ContractPaused` /// 2. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 3. ZERO-SENDER (`from == address(0)`) → `InvalidSender` diff --git a/test/unit/B20/memo/transferFromWithMemo.t.sol b/test/unit/B20/memo/transferFromWithMemo.t.sol index d867049b..18014dd7 100644 --- a/test/unit/B20/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20/memo/transferFromWithMemo.t.sol @@ -31,9 +31,9 @@ contract B20TransferFromWithMemoTest is B20Test { token.transferFromWithMemo(from, to, amount, memo); } - /// @notice Verifies AUTHORIZED_SPENDER_ROLE does not bypass the memo transfer's executor policy + /// @notice Verifies PREAUTHORIZED_SPENDER_ROLE does not bypass the memo transfer's executor policy /// @dev The memo variant preserves the same policy boundary as transferFrom. - function test_transferFromWithMemo_revert_authorizedSpenderExecutorPolicyForbids( + function test_transferFromWithMemo_revert_preauthorizedSpenderExecutorPolicyForbids( address from, address to, uint256 amount, @@ -41,13 +41,13 @@ contract B20TransferFromWithMemoTest is B20Test { ) public { _assumeValidActor(from); _assumeValidActor(to); - vm.assume(authorizedSpender != from); + vm.assume(preauthorizedSpender != from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -155,9 +155,9 @@ contract B20TransferFromWithMemoTest is B20Test { assertTrue(token.transferFromWithMemo(from, to, amount, memo), "transferFromWithMemo must return true"); } - /// @notice Verifies approving zero does not opt a holder out of memo transfers by an authorized spender - /// @dev AUTHORIZED_SPENDER_ROLE bypasses allowance without changing the stored zero value. - function test_transferFromWithMemo_success_authorizedSpenderSpendsAfterHolderApprovesZero( + /// @notice Verifies approving zero does not opt a holder out of memo transfers by a preauthorized spender + /// @dev PREAUTHORIZED_SPENDER_ROLE bypasses allowance without changing the stored zero value. + function test_transferFromWithMemo_success_preauthorizedSpenderSpendsAfterHolderApprovesZero( address from, address to, uint256 amount, @@ -170,15 +170,15 @@ contract B20TransferFromWithMemoTest is B20Test { _mint(from, amount); vm.prank(from); - token.approve(authorizedSpender, 0); - _grantAuthorizedSpender(); + token.approve(preauthorizedSpender, 0); + _grantPreauthorizedSpender(); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); token.transferFromWithMemo(from, to, amount, memo); - assertEq(token.balanceOf(to), amount, "authorized spender memo transfer must move the balance"); + assertEq(token.balanceOf(to), amount, "preauthorized spender memo transfer must move the balance"); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, preauthorizedSpender))), 0, "zero stored allowance must remain unchanged" ); diff --git a/test/unit/B20/roles/getRoleAdmin.t.sol b/test/unit/B20/roles/getRoleAdmin.t.sol index fb4cae26..603cf03e 100644 --- a/test/unit/B20/roles/getRoleAdmin.t.sol +++ b/test/unit/B20/roles/getRoleAdmin.t.sol @@ -5,13 +5,13 @@ import {B20Test} from "base-std-test/lib/B20Test.sol"; import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; contract B20GetRoleAdminTest is B20Test { - /// @notice Verifies AUTHORIZED_SPENDER_ROLE is administered by DEFAULT_ADMIN_ROLE on a fresh token - /// @dev Pins the selected governance default for issuer-authorized spenders. - function test_getRoleAdmin_success_authorizedSpenderDefaultsToAdminRole() public view { + /// @notice Verifies PREAUTHORIZED_SPENDER_ROLE is administered by DEFAULT_ADMIN_ROLE on a fresh token + /// @dev Pins the selected governance default for preauthorized spenders. + function test_getRoleAdmin_success_preauthorizedSpenderDefaultsToAdminRole() public view { assertEq( - token.getRoleAdmin(B20Constants.AUTHORIZED_SPENDER_ROLE), + token.getRoleAdmin(B20Constants.PREAUTHORIZED_SPENDER_ROLE), B20Constants.DEFAULT_ADMIN_ROLE, - "authorized spender role must default to DEFAULT_ADMIN_ROLE" + "preauthorized spender role must default to DEFAULT_ADMIN_ROLE" ); } diff --git a/test/unit/B20/roles/roleConstants.t.sol b/test/unit/B20/roles/roleConstants.t.sol index 33b3174b..95eedf5c 100644 --- a/test/unit/B20/roles/roleConstants.t.sol +++ b/test/unit/B20/roles/roleConstants.t.sol @@ -60,16 +60,18 @@ contract B20RoleConstantsTest is B20Test { assertEq(token.METADATA_ROLE(), B20Constants.METADATA_ROLE, "must match B20Test's local constant"); } - /// @notice Verifies AUTHORIZED_SPENDER_ROLE returns keccak256("AUTHORIZED_SPENDER_ROLE") - /// @dev Constant stability for issuer-authorized spending. - function test_AUTHORIZED_SPENDER_ROLE_success_matchesExpected() public view { + /// @notice Verifies PREAUTHORIZED_SPENDER_ROLE returns keccak256("PREAUTHORIZED_SPENDER_ROLE") + /// @dev Constant stability for issuer-controlled spending. + function test_PREAUTHORIZED_SPENDER_ROLE_success_matchesExpected() public view { assertEq( - token.AUTHORIZED_SPENDER_ROLE(), - keccak256("AUTHORIZED_SPENDER_ROLE"), - "B20Constants.AUTHORIZED_SPENDER_ROLE digest" + token.PREAUTHORIZED_SPENDER_ROLE(), + keccak256("PREAUTHORIZED_SPENDER_ROLE"), + "B20Constants.PREAUTHORIZED_SPENDER_ROLE digest" ); assertEq( - token.AUTHORIZED_SPENDER_ROLE(), B20Constants.AUTHORIZED_SPENDER_ROLE, "must match B20Test's local constant" + token.PREAUTHORIZED_SPENDER_ROLE(), + B20Constants.PREAUTHORIZED_SPENDER_ROLE, + "must match B20Test's local constant" ); } } diff --git a/test/unit/B20/roles/setRoleAdmin.t.sol b/test/unit/B20/roles/setRoleAdmin.t.sol index 70667b34..2bb0aa7c 100644 --- a/test/unit/B20/roles/setRoleAdmin.t.sol +++ b/test/unit/B20/roles/setRoleAdmin.t.sol @@ -38,28 +38,28 @@ contract B20SetRoleAdminTest is B20Test { ); } - /// @notice Verifies AUTHORIZED_SPENDER_ROLE administration can be delegated from DEFAULT_ADMIN_ROLE - /// @dev Pins the selected governance model for the exact authorized spender role. - function test_setRoleAdmin_success_delegatesAuthorizedSpenderRoleAdministration(address delegatedAdmin) public { + /// @notice Verifies PREAUTHORIZED_SPENDER_ROLE administration can be delegated from DEFAULT_ADMIN_ROLE + /// @dev Pins the selected governance model for the exact preauthorized spender role. + function test_setRoleAdmin_success_delegatesPreauthorizedSpenderRoleAdministration(address delegatedAdmin) public { _assumeValidCaller(delegatedAdmin); vm.assume(delegatedAdmin != admin); bytes32 customAdminRole = keccak256("CUSTOM_ADMIN_ROLE"); vm.startPrank(admin); token.grantRole(customAdminRole, delegatedAdmin); - token.setRoleAdmin(B20Constants.AUTHORIZED_SPENDER_ROLE, customAdminRole); + token.setRoleAdmin(B20Constants.PREAUTHORIZED_SPENDER_ROLE, customAdminRole); vm.stopPrank(); vm.prank(admin); vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, admin, customAdminRole)); - token.grantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); + token.grantRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender); vm.prank(delegatedAdmin); - token.grantRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender); + token.grantRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender); assertTrue( - token.hasRole(B20Constants.AUTHORIZED_SPENDER_ROLE, authorizedSpender), - "delegated admin must grant authorized spender role" + token.hasRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender), + "delegated admin must grant preauthorized spender role" ); } diff --git a/test/unit/B20Stablecoin/erc20/allowance.t.sol b/test/unit/B20Stablecoin/erc20/allowance.t.sol index e5d2980c..4890cfda 100644 --- a/test/unit/B20Stablecoin/erc20/allowance.t.sol +++ b/test/unit/B20Stablecoin/erc20/allowance.t.sol @@ -10,27 +10,27 @@ contract B20StablecoinAllowanceTest is B20StablecoinTest { /// @dev The Asset-only role hash remains inert on the shared allowance path. function test_allowance_success_operatorRoleDoesNotAuthorizeSpending(address owner) public { _assumeValidActor(owner); - _grantRole(B20Constants.OPERATOR_ROLE, authorizedSpender); + _grantRole(B20Constants.OPERATOR_ROLE, preauthorizedSpender); - assertEq(token.allowance(owner, authorizedSpender), 0, "operator role allowance must remain zero"); + assertEq(token.allowance(owner, preauthorizedSpender), 0, "operator role allowance must remain zero"); } - /// @notice Verifies Stablecoin reports infinite allowance for an authorized spender + /// @notice Verifies Stablecoin reports infinite allowance for a preauthorized spender /// @dev Role membership overrides the view without changing the stored allowance. - function test_allowance_success_authorizedSpenderReadsAsInfinite(address owner, uint256 storedAllowance) public { + function test_allowance_success_preauthorizedSpenderReadsAsInfinite(address owner, uint256 storedAllowance) public { _assumeValidActor(owner); vm.prank(owner); - token.approve(authorizedSpender, storedAllowance); - _grantAuthorizedSpender(); + token.approve(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); assertEq( - token.allowance(owner, authorizedSpender), + token.allowance(owner, preauthorizedSpender), type(uint256).max, - "authorized spender allowance must be infinite" + "preauthorized spender allowance must be infinite" ); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, authorizedSpender))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, preauthorizedSpender))), storedAllowance, "stored allowance must remain unchanged" ); diff --git a/test/unit/B20Stablecoin/erc20/transferFrom.t.sol b/test/unit/B20Stablecoin/erc20/transferFrom.t.sol index 39542300..099e015e 100644 --- a/test/unit/B20Stablecoin/erc20/transferFrom.t.sol +++ b/test/unit/B20Stablecoin/erc20/transferFrom.t.sol @@ -17,44 +17,46 @@ contract B20StablecoinTransferFromTest is B20StablecoinTest { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 1, type(uint256).max); - _grantRole(B20Constants.OPERATOR_ROLE, authorizedSpender); + _grantRole(B20Constants.OPERATOR_ROLE, preauthorizedSpender); - vm.prank(authorizedSpender); - vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, authorizedSpender, 0, amount)); + vm.prank(preauthorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, preauthorizedSpender, 0, amount)); token.transferFrom(from, to, amount); } - /// @notice Verifies a Stablecoin authorized spender remains subject to TRANSFER pause - /// @dev AUTHORIZED_SPENDER_ROLE waives allowance only. - function test_transferFrom_revert_authorizedSpenderWhenTransferPaused(address from, address to, uint256 amount) + /// @notice Verifies a Stablecoin preauthorized spender remains subject to TRANSFER pause + /// @dev PREAUTHORIZED_SPENDER_ROLE waives allowance only. + function test_transferFrom_revert_preauthorizedSpenderWhenTransferPaused(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); _pause(IB20.PausableFeature.TRANSFER); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.TRANSFER)); token.transferFrom(from, to, amount); } - /// @notice Verifies a Stablecoin authorized spender remains subject to TRANSFER_EXECUTOR_POLICY - /// @dev AUTHORIZED_SPENDER_ROLE waives allowance only. - function test_transferFrom_revert_authorizedSpenderExecutorPolicyForbids(address from, address to, uint256 amount) - public - { + /// @notice Verifies a Stablecoin preauthorized spender remains subject to TRANSFER_EXECUTOR_POLICY + /// @dev PREAUTHORIZED_SPENDER_ROLE waives allowance only. + function test_transferFrom_revert_preauthorizedSpenderExecutorPolicyForbids( + address from, + address to, + uint256 amount + ) public { _assumeValidActor(from); _assumeValidActor(to); - vm.assume(authorizedSpender != from); + vm.assume(preauthorizedSpender != from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -65,33 +67,35 @@ contract B20StablecoinTransferFromTest is B20StablecoinTest { token.transferFrom(from, to, amount); } - /// @notice Verifies a Stablecoin authorized spender can spend from a holder with zero allowance + /// @notice Verifies a Stablecoin preauthorized spender can spend from a holder with zero allowance /// @dev Confirms the shared allowance bypass applies to the Stablecoin variant. - function test_transferFrom_success_authorizedSpenderSpendsWithoutAllowance(address from, address to, uint256 amount) - public - { + function test_transferFrom_success_preauthorizedSpenderSpendsWithoutAllowance( + address from, + address to, + uint256 amount + ) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); token.transferFrom(from, to, amount); - assertEq(token.balanceOf(to), amount, "authorized spender must move holder balance"); + assertEq(token.balanceOf(to), amount, "preauthorized spender must move holder balance"); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, preauthorizedSpender))), 0, "zero stored allowance must remain unchanged" ); } - /// @notice Verifies a Stablecoin authorized spender does not consume a finite stored allowance + /// @notice Verifies a Stablecoin preauthorized spender does not consume a finite stored allowance /// @dev Role authority remains independent from holder-managed allowance state. - function test_transferFrom_success_authorizedSpenderPreservesStoredAllowance( + function test_transferFrom_success_preauthorizedSpenderPreservesStoredAllowance( address from, address to, uint256 storedAllowance, @@ -105,14 +109,14 @@ contract B20StablecoinTransferFromTest is B20StablecoinTest { _mint(from, amount); vm.prank(from); - token.approve(authorizedSpender, storedAllowance); - _grantAuthorizedSpender(); + token.approve(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); token.transferFrom(from, to, amount); assertEq( - uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, authorizedSpender))), + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, preauthorizedSpender))), storedAllowance, "stored allowance must not be consumed" ); diff --git a/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol b/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol index c8e3464f..322023ab 100644 --- a/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol @@ -19,16 +19,16 @@ contract B20StablecoinTransferFromWithMemoTest is B20StablecoinTest { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 1, type(uint256).max); - _grantRole(B20Constants.OPERATOR_ROLE, authorizedSpender); + _grantRole(B20Constants.OPERATOR_ROLE, preauthorizedSpender); - vm.prank(authorizedSpender); - vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, authorizedSpender, 0, amount)); + vm.prank(preauthorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, preauthorizedSpender, 0, amount)); token.transferFromWithMemo(from, to, amount, memo); } - /// @notice Verifies a Stablecoin authorized spender memo transfer remains subject to executor policy + /// @notice Verifies a Stablecoin preauthorized spender memo transfer remains subject to executor policy /// @dev The memo variant preserves the same policy boundary as transferFrom. - function test_transferFromWithMemo_revert_authorizedSpenderExecutorPolicyForbids( + function test_transferFromWithMemo_revert_preauthorizedSpenderExecutorPolicyForbids( address from, address to, uint256 amount, @@ -36,13 +36,13 @@ contract B20StablecoinTransferFromWithMemoTest is B20StablecoinTest { ) public { _assumeValidActor(from); _assumeValidActor(to); - vm.assume(authorizedSpender != from); + vm.assume(preauthorizedSpender != from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); vm.expectRevert( abi.encodeWithSelector( IB20.PolicyForbids.selector, @@ -53,9 +53,9 @@ contract B20StablecoinTransferFromWithMemoTest is B20StablecoinTest { token.transferFromWithMemo(from, to, amount, memo); } - /// @notice Verifies a Stablecoin authorized spender can spend with a memo and zero allowance + /// @notice Verifies a Stablecoin preauthorized spender can spend with a memo and zero allowance /// @dev Confirms the shared memo allowance bypass applies to the Stablecoin variant. - function test_transferFromWithMemo_success_authorizedSpenderSpendsWithoutAllowance( + function test_transferFromWithMemo_success_preauthorizedSpenderSpendsWithoutAllowance( address from, address to, uint256 amount, @@ -67,11 +67,11 @@ contract B20StablecoinTransferFromWithMemoTest is B20StablecoinTest { amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); - _grantAuthorizedSpender(); + _grantPreauthorizedSpender(); - vm.prank(authorizedSpender); + vm.prank(preauthorizedSpender); token.transferFromWithMemo(from, to, amount, memo); - assertEq(token.balanceOf(to), amount, "authorized spender memo transfer must move holder balance"); + assertEq(token.balanceOf(to), amount, "preauthorized spender memo transfer must move holder balance"); } } diff --git a/test/unit/B20Stablecoin/roles/roleConstants.t.sol b/test/unit/B20Stablecoin/roles/roleConstants.t.sol index 7a83813e..bc07045d 100644 --- a/test/unit/B20Stablecoin/roles/roleConstants.t.sol +++ b/test/unit/B20Stablecoin/roles/roleConstants.t.sol @@ -12,16 +12,18 @@ contract B20StablecoinRoleConstantsTest is B20StablecoinTest { assertFalse(success, "Stablecoin must not expose OPERATOR_ROLE"); } - /// @notice Verifies Stablecoin exposes the shared AUTHORIZED_SPENDER_ROLE constant + /// @notice Verifies Stablecoin exposes the shared PREAUTHORIZED_SPENDER_ROLE constant /// @dev Pins the shared selector and role value on the Stablecoin variant. - function test_AUTHORIZED_SPENDER_ROLE_success_matchesExpected() public view { + function test_PREAUTHORIZED_SPENDER_ROLE_success_matchesExpected() public view { assertEq( - token.AUTHORIZED_SPENDER_ROLE(), keccak256("AUTHORIZED_SPENDER_ROLE"), "AUTHORIZED_SPENDER_ROLE digest" + token.PREAUTHORIZED_SPENDER_ROLE(), + keccak256("PREAUTHORIZED_SPENDER_ROLE"), + "PREAUTHORIZED_SPENDER_ROLE digest" ); assertEq( - token.AUTHORIZED_SPENDER_ROLE(), - B20Constants.AUTHORIZED_SPENDER_ROLE, - "AUTHORIZED_SPENDER_ROLE library value" + token.PREAUTHORIZED_SPENDER_ROLE(), + B20Constants.PREAUTHORIZED_SPENDER_ROLE, + "PREAUTHORIZED_SPENDER_ROLE library value" ); } }