diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a0d09d..4d9eed7d 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. Preauthorized spender allowance behavior remains unavailable until Denim selects B20 logic v3. + +### Compatibility + +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) | 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 `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 preauthorized spender calls. + ## Cobalt ### Status diff --git a/changelog/03_Denim_B20_preauthorized_spender.md b/changelog/03_Denim_B20_preauthorized_spender.md new file mode 100644 index 00000000..37b41c9c --- /dev/null +++ b/changelog/03_Denim_B20_preauthorized_spender.md @@ -0,0 +1,112 @@ +# Denim: Preauthorized Spenders + +- **Feature Name**: preauthorized_spender +- **Start Date**: 2026-09-11 +- **Title**: Issuer-controlled infinite allowances through `PREAUTHORIZED_SPENDER_ROLE` + +## Summary + +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. 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 + +`PREAUTHORIZED_SPENDER_ROLE()` is added to the shared [`IB20`](../src/interfaces/IB20.sol) interface. + +| Function | Selector | Denim change | +| --- | --- | --- | +| `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("PREAUTHORIZED_SPENDER_ROLE") +// 0xb90b441c392e1b39b562e08d16a15eab44f161ced0b6366c03aa7d09a22f1a41 +``` + +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 `PREAUTHORIZED_SPENDER_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. 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.PREAUTHORIZED_SPENDER_ROLE(); +token.grantRole(spenderRole, address(permit2)); + +// Returns type(uint256).max even when alice never approved Permit2. +uint256 effectiveAllowance = token.allowance(alice, address(permit2)); + +// Permit2 can execute signature-based transfers against alice's token balance. +``` + +## 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. +- Keep the set empty by default. No address, including Permit2, receives implicit authority. +- 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. + +### 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 + +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 preauthorized spender transfers. + +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 703cd9d7..27e6a4c6 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 | 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) | + +
+
Cobalt (upcoming) — ordinal 02 diff --git a/docs/concepts/multipliers.md b/docs/concepts/multipliers.md index 06fd4d4f..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 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 `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. @@ -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..d5fe60f0 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` | +`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 Start with a receiver allowlist. Then combine it with a sanctions blocklist so a transfer requires both. @@ -346,5 +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 44a44a4d..9080b76e 100644 --- a/docs/concepts/roles-and-pause.md +++ b/docs/concepts/roles-and-pause.md @@ -33,10 +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` | +| `PREAUTHORIZED_SPENDER_ROLE` | Infinite `transferFrom` allowance from every holder | | `OPERATOR_ROLE` | Asset-only: `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. +`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. + +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 @@ -255,5 +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 29a7087a..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`. +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"`. +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 09f92aba..8a40d147 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, 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. +`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: ```mermaid diff --git a/docs/reference/constants.md b/docs/reference/constants.md index 23f40a8d..478d48bd 100644 --- a/docs/reference/constants.md +++ b/docs/reference/constants.md @@ -26,6 +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`. | +| `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 08a2c793..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`'s 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 60d96619..88ed69cf 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 PREAUTHORIZED_SPENDER_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 + /// `PREAUTHORIZED_SPENDER_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 + /// `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's allowance from `from` is below `amount`. + /// @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. /// @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 `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 1a27d1d6..549d8026 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 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/test/lib/B20Test.sol b/test/lib/B20Test.sol index cc517c96..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`) 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,6 +38,7 @@ contract B20Test is B20FactoryTest { address internal pauser = makeAddr("pauser"); address internal unpauser = makeAddr("unpauser"); address internal burnBlocker = makeAddr("burnBlocker"); + address internal preauthorizedSpender = makeAddr("preauthorizedSpender"); // -- Token under test -- /// @notice Asset-variant `IB20` token deployed in `setUp`. @@ -52,6 +53,7 @@ contract B20Test is B20FactoryTest { vm.label(pauser, "pauser"); vm.label(unpauser, "unpauser"); vm.label(burnBlocker, "burnBlocker"); + vm.label(preauthorizedSpender, "preauthorizedSpender"); token = _deployToken(); vm.label(address(token), "token"); @@ -107,6 +109,13 @@ contract B20Test is B20FactoryTest { token.grantRole(role, account); } + /// @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); + } + } + /// @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..b70fc316 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 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; @@ -177,6 +178,7 @@ abstract contract MockB20 is IB20 { } function allowance(address owner, address spender) external view returns (uint256) { + if (hasRole(PREAUTHORIZED_SPENDER_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. PREAUTHORIZED_SPENDER_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. PREAUTHORIZED_SPENDER_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(PREAUTHORIZED_SPENDER_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 + /// `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 { if (!_isPrivileged()) { // One SLOAD pulls both policy IDs we need for the transfer diff --git a/test/unit/B20/erc20/allowance.t.sol b/test/unit/B20/erc20/allowance.t.sol index 1112bf45..2234fda3 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,47 @@ 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 a preauthorized spender + /// @dev Role membership overrides the allowance view without changing the stored allowance. + function test_allowance_success_preauthorizedSpenderReadsAsInfinite(address owner, uint256 storedAllowance) public { + _assumeValidActor(owner); + + vm.prank(owner); + token.approve(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); + + assertEq( + token.allowance(owner, preauthorizedSpender), + type(uint256).max, + "preauthorized spender allowance must read as infinite" + ); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(owner, preauthorizedSpender))), + storedAllowance, + "preauthorized spender role must not overwrite 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_revokedPreauthorizedSpenderReadsStoredAllowance( + address owner, + uint256 storedAllowance + ) public { + _assumeValidActor(owner); + + vm.prank(owner); + token.approve(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); + + vm.prank(admin); + token.revokeRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender); + + assertEq( + token.allowance(owner, preauthorizedSpender), + storedAllowance, + "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 0b3197a9..55e1f993 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -57,6 +57,32 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } + /// @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_preauthorizedSpenderExecutorPolicyForbids( + address from, + address to, + uint256 amount + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(preauthorizedSpender != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantPreauthorizedSpender(); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(preauthorizedSpender); + 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 +109,29 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } + /// @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_preauthorizedSpenderSenderPolicyForbids(address from, address to, uint256 amount) + public + { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantPreauthorizedSpender(); + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(preauthorizedSpender); + 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 +158,31 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } + /// @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_preauthorizedSpenderReceiverPolicyForbids( + address from, + address to, + uint256 amount + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantPreauthorizedSpender(); + _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(preauthorizedSpender); + 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 +200,26 @@ contract B20TransferFromTest is B20Test { token.transferFrom(from, to, amount); } + /// @notice Verifies a revoked preauthorized spender must use the holder's stored allowance + /// @dev Revocation removes the allowance bypass immediately. + function test_transferFrom_revert_revokedPreauthorizedSpenderInsufficientAllowance( + address from, + address to, + uint256 amount + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 1, type(uint256).max); + + _grantPreauthorizedSpender(); + vm.prank(admin); + token.revokeRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender); + + vm.prank(preauthorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, preauthorizedSpender, 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 +346,68 @@ contract B20TransferFromTest is B20Test { ); } + /// @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 + ) 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(preauthorizedSpender, 0); + _grantPreauthorizedSpender(); + + vm.prank(preauthorizedSpender); + token.transferFrom(from, to, amount); + + assertEq(token.balanceOf(to), amount, "preauthorized spender must spend despite zero stored allowance"); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, preauthorizedSpender))), + 0, + "zero stored allowance must remain unchanged" + ); + } + + /// @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_preauthorizedSpenderPreservesStoredAllowance( + 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(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); + + vm.prank(preauthorizedSpender); + token.transferFrom(from, to, amount); + + assertEq( + token.allowance(from, preauthorizedSpender), + type(uint256).max, + "preauthorized spender allowance must remain infinite" + ); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, preauthorizedSpender))), + 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..66a15265 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 +/// `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 ba84c27a..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`):** +/// `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 9a5a6e86..18014dd7 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 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_preauthorizedSpenderExecutorPolicyForbids( + address from, + address to, + uint256 amount, + bytes32 memo + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(preauthorizedSpender != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantPreauthorizedSpender(); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(preauthorizedSpender); + 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 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, + 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(preauthorizedSpender, 0); + _grantPreauthorizedSpender(); + + vm.prank(preauthorizedSpender); + token.transferFromWithMemo(from, to, amount, memo); + + assertEq(token.balanceOf(to), amount, "preauthorized spender memo transfer must move the balance"); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, preauthorizedSpender))), + 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..603cf03e 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 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.PREAUTHORIZED_SPENDER_ROLE), + B20Constants.DEFAULT_ADMIN_ROLE, + "preauthorized spender 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..95eedf5c 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,19 @@ 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 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.PREAUTHORIZED_SPENDER_ROLE(), + keccak256("PREAUTHORIZED_SPENDER_ROLE"), + "B20Constants.PREAUTHORIZED_SPENDER_ROLE digest" + ); + assertEq( + 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 716beda4..2bb0aa7c 100644 --- a/test/unit/B20/roles/setRoleAdmin.t.sol +++ b/test/unit/B20/roles/setRoleAdmin.t.sol @@ -38,6 +38,31 @@ contract B20SetRoleAdminTest is B20Test { ); } + /// @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.PREAUTHORIZED_SPENDER_ROLE, customAdminRole); + vm.stopPrank(); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, admin, customAdminRole)); + token.grantRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender); + + vm.prank(delegatedAdmin); + token.grantRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender); + + assertTrue( + token.hasRole(B20Constants.PREAUTHORIZED_SPENDER_ROLE, preauthorizedSpender), + "delegated admin must grant preauthorized spender 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/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 new file mode 100644 index 00000000..4890cfda --- /dev/null +++ b/test/unit/B20Stablecoin/erc20/allowance.t.sol @@ -0,0 +1,38 @@ +// 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"; +import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; + +contract B20StablecoinAllowanceTest is B20StablecoinTest { + /// @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, preauthorizedSpender); + + assertEq(token.allowance(owner, preauthorizedSpender), 0, "operator role allowance must remain zero"); + } + + /// @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_preauthorizedSpenderReadsAsInfinite(address owner, uint256 storedAllowance) public { + _assumeValidActor(owner); + + vm.prank(owner); + token.approve(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); + + assertEq( + token.allowance(owner, preauthorizedSpender), + type(uint256).max, + "preauthorized spender allowance must be infinite" + ); + assertEq( + 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 new file mode 100644 index 00000000..099e015e --- /dev/null +++ b/test/unit/B20Stablecoin/erc20/transferFrom.t.sol @@ -0,0 +1,124 @@ +// 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 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, preauthorizedSpender); + + vm.prank(preauthorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, preauthorizedSpender, 0, amount)); + token.transferFrom(from, to, 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); + + _grantPreauthorizedSpender(); + _pause(IB20.PausableFeature.TRANSFER); + + vm.prank(preauthorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.TRANSFER)); + token.transferFrom(from, to, amount); + } + + /// @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(preauthorizedSpender != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantPreauthorizedSpender(); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(preauthorizedSpender); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transferFrom(from, to, amount); + } + + /// @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_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); + _grantPreauthorizedSpender(); + + vm.prank(preauthorizedSpender); + token.transferFrom(from, to, amount); + + assertEq(token.balanceOf(to), amount, "preauthorized spender must move holder balance"); + assertEq( + uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, preauthorizedSpender))), + 0, + "zero stored allowance must remain unchanged" + ); + } + + /// @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_preauthorizedSpenderPreservesStoredAllowance( + 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(preauthorizedSpender, storedAllowance); + _grantPreauthorizedSpender(); + + vm.prank(preauthorizedSpender); + token.transferFrom(from, to, amount); + + assertEq( + 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 new file mode 100644 index 00000000..322023ab --- /dev/null +++ b/test/unit/B20Stablecoin/memo/transferFromWithMemo.t.sol @@ -0,0 +1,77 @@ +// 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 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, preauthorizedSpender); + + vm.prank(preauthorizedSpender); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientAllowance.selector, preauthorizedSpender, 0, amount)); + token.transferFromWithMemo(from, to, amount, memo); + } + + /// @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_preauthorizedSpenderExecutorPolicyForbids( + address from, + address to, + uint256 amount, + bytes32 memo + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(preauthorizedSpender != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + _grantPreauthorizedSpender(); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(preauthorizedSpender); + 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 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_preauthorizedSpenderSpendsWithoutAllowance( + 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); + _grantPreauthorizedSpender(); + + vm.prank(preauthorizedSpender); + token.transferFromWithMemo(from, to, amount, memo); + + 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 new file mode 100644 index 00000000..bc07045d --- /dev/null +++ b/test/unit/B20Stablecoin/roles/roleConstants.t.sol @@ -0,0 +1,29 @@ +// 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 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 PREAUTHORIZED_SPENDER_ROLE constant + /// @dev Pins the shared selector and role value on the Stablecoin variant. + function test_PREAUTHORIZED_SPENDER_ROLE_success_matchesExpected() public view { + assertEq( + token.PREAUTHORIZED_SPENDER_ROLE(), + keccak256("PREAUTHORIZED_SPENDER_ROLE"), + "PREAUTHORIZED_SPENDER_ROLE digest" + ); + assertEq( + token.PREAUTHORIZED_SPENDER_ROLE(), + B20Constants.PREAUTHORIZED_SPENDER_ROLE, + "PREAUTHORIZED_SPENDER_ROLE library value" + ); + } +}