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"
+ );
+ }
+}