Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Authorized spender allowance behavior remains unavailable until Denim selects B20 logic v3.

### Compatibility

Denim adds the shared `AUTHORIZED_SPENDER_ROLE()` getter and changes the behavior of existing `allowance`, `transferFrom`, and `transferFromWithMemo` selectors for accounts that hold this role.

### Summary of changes

| Product | Feature | Change | Details |
| --- | --- | --- | --- |
| B20 (Asset and Stablecoin) | Issuer-authorized spenders | An account with `AUTHORIZED_SPENDER_ROLE` reads as having infinite allowance from every holder. Authorized spender transfers do not consume stored allowances, but pause and transfer policies remain active. | [03_Denim_B20_authorized_spender](changelog/03_Denim_B20_authorized_spender.md) |

### Migration guidance

#### Issuers

Grant `AUTHORIZED_SPENDER_ROLE` only to contracts and accounts that may move every holder's balance. Existing Asset `OPERATOR_ROLE` assignments keep their announcement and multiplier capabilities and do not gain spending authority.

#### Wallets and integrators

Treat `allowance(owner, spender) == type(uint256).max` as possible role-based authority. A holder cannot revoke that authority with `approve(spender, 0)`; only the role administrator can remove it. Continue to enforce transfer policy failures and paused-transfer failures for authorized spender calls.

## Cobalt

### Status
Expand Down
92 changes: 92 additions & 0 deletions changelog/03_Denim_B20_authorized_spender.md

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1

Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Denim: Issuer-Authorized Spenders

- **Feature Name**: authorized_spender
- **Start Date**: 2026-09-10
- **Title**: Issuer-authorized infinite allowances through `AUTHORIZED_SPENDER_ROLE`

## Summary

Denim lets a B20 issuer grant an account permission to spend from every holder without holder approvals. The dedicated `AUTHORIZED_SPENDER_ROLE` keeps this authority separate from the Asset-only `OPERATOR_ROLE`.

## Motivation

Some token integrations need one contract, such as a router or settlement system, to spend from every holder. Requiring each holder to call `approve` adds a transaction and prevents the integration from working for holders that cannot make an approval call.

## Specs

### Interface changes

`AUTHORIZED_SPENDER_ROLE()` is added to the shared [`IB20`](../src/interfaces/IB20.sol) interface.

| Function | Selector | Denim change |
| --- | --- | --- |
| `AUTHORIZED_SPENDER_ROLE()` | `0xef97aa21` | New shared role getter on Asset and Stablecoin. |
| `allowance(address,address)` | `0xdd62ed3e` | Returns `type(uint256).max` when `spender` holds `AUTHORIZED_SPENDER_ROLE`. |
| `transferFrom(address,address,uint256)` | `0x23b872dd` | Skips allowance validation and consumption when the caller holds `AUTHORIZED_SPENDER_ROLE`. |
| `transferFromWithMemo(address,address,uint256,bytes32)` | `0x929c2539` | Applies the same authorized spender behavior as `transferFrom`. |

The role value is:

```solidity
keccak256("AUTHORIZED_SPENDER_ROLE")
// 0xb0e3ae34a3ebd864ed280a15abe71cbcaf59103e086737862f5bbccae6a44b37
```

No new mutator, event, error, or storage slot is added. Issuers manage membership with `grantRole`, `revokeRole`, and `renounceRole`. `getRoleAdmin(AUTHORIZED_SPENDER_ROLE)` defaults to `DEFAULT_ADMIN_ROLE` and remains delegable through `setRoleAdmin`.

### Behavioral changes

For a caller that holds `AUTHORIZED_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. Authorized spender membership uses the existing role mapping. Holder allowances remain in their existing slots while the spender holds `AUTHORIZED_SPENDER_ROLE`.

## Example

```solidity
bytes32 spenderRole = token.AUTHORIZED_SPENDER_ROLE();
token.grantRole(spenderRole, address(router));

// Returns type(uint256).max even when alice never approved the router.
uint256 effectiveAllowance = token.allowance(alice, address(router));

// The router calls token.transferFrom(alice, recipient, amount)
// from its own execution context.
```

## Design Decisions

- 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.

## Migration Steps

### Issuers

1. Check for any historical generic grant of the `AUTHORIZED_SPENDER_ROLE` hash.
2. Check `getRoleAdmin(AUTHORIZED_SPENDER_ROLE)` if the role hash was already configured.
3. Grant the role only to contracts and accounts that may move every holder's balance.
4. Keep `OPERATOR_ROLE` assignments unchanged unless the Asset operator itself also needs spending authority.

### Integrators

1. Do not assume that `allowance == type(uint256).max` came from holder approval.
2. Do not present `approve(spender, 0)` as a revocation path for role-based authority.
3. Continue to handle `ContractPaused`, `PolicyForbids`, and `InsufficientBalance` on authorized spender transfers.

Both variants gain the additive `AUTHORIZED_SPENDER_ROLE()` selector. Existing `OPERATOR_ROLE` assignments retain their previous capabilities. The existing ERC-20 selectors change behavior only for accounts that hold the new role hash.
10 changes: 10 additions & 0 deletions changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details open>
<summary><strong>Denim (upcoming)</strong> - ordinal <code>03</code></summary>

| Product(s) | Change | Affected interfaces | Entry |
| --- | --- | --- | --- |
| B20 Asset, B20 Stablecoin | Issuer-authorized spenders | `src/interfaces/IB20.sol` (shared surface) inherited by `src/interfaces/IB20Asset.sol`, `src/interfaces/IB20Stablecoin.sol` | [03_Denim_B20_authorized_spender](03_Denim_B20_authorized_spender.md) |

</details>

<details open>
<summary><strong>Cobalt (upcoming)</strong> — ordinal <code>02</code></summary>

Expand Down
3 changes: 1 addition & 2 deletions docs/concepts/multipliers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `AUTHORIZED_SPENDER_ROLE` rule can make `allowance(owner, spender)` return `type(uint256).max`; that value does not use the UI multiplier.

The UI views above are opt-in. Protocols that call `balanceOfUI`, `scaledBalanceOf`, or `totalSupplyUI` do see the split.

Expand Down Expand Up @@ -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.

3 changes: 2 additions & 1 deletion docs/concepts/policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

`AUTHORIZED_SPENDER_ROLE` does not bypass these scopes. An authorized spender skips only the allowance check in `transferFrom` and `transferFromWithMemo`. The executor, sender, and receiver policy checks still run.

## 4. Example

Start with a receiver allowlist. Then combine it with a sanctions blocklist so a transfer requires both.
Expand Down Expand Up @@ -347,4 +349,3 @@ If the issuer later needs the same KYC list or-ed with a token-specific partner
| `InvalidChildPolicy(childPolicyId)` | A composite child is not an existing simple policy |
| `NonPayable()` | ETH was attached to a registry call |


6 changes: 4 additions & 2 deletions docs/concepts/roles-and-pause.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
| `AUTHORIZED_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.
`AUTHORIZED_SPENDER_ROLE` is shared by Asset and Stablecoin. For any holder and authorized spender, `allowance(holder, spender)` returns `type(uint256).max`. The authorized spender can call `transferFrom` or `transferFromWithMemo` without holder approval, and those calls do not change the holder's stored allowance. `approve(spender, 0)` does not opt the holder out. The role administrator must revoke `AUTHORIZED_SPENDER_ROLE` to remove the authority.

Authorized spender transfers still use the `TRANSFER` pause vector and all three transfer policy scopes. The role waives only the allowance check. `OPERATOR_ROLE` remains an Asset-only role for announcements and multiplier updates. `approve` and holder `transfer` are not role-gated.

### 2.3 Granting and revoking

Expand Down Expand Up @@ -256,4 +259,3 @@ sequenceDiagram
| `LastAdminCannotRenounce()` | `revokeRole`/`renounceRole` would remove the last `DEFAULT_ADMIN_ROLE` holder |
| `NotSoleAdmin()` | `renounceLastAdmin` called while other admins still exist |


4 changes: 2 additions & 2 deletions docs/concepts/token-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `AUTHORIZED_SPENDER_ROLE` allowance behavior from `IB20`.

Stablecoin-specific state lives in `base.b20.stablecoin` (`currency` only). Shared ERC-20, role, policy, and pause state stays in `base.b20`.

Expand All @@ -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 `AUTHORIZED_SPENDER_ROLE` through the standard `grantRole` encoder.

```mermaid
sequenceDiagram
Expand Down
4 changes: 3 additions & 1 deletion docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ The Activation Registry is a Base-operated safety switch that turns Factory and

## Configuring Roles

Roles let an issuer assign each privileged operation to a specific account. An admin can grant minting to a minter, seizing to a compliance operator, and pausing of a single feature (`TRANSFER`, `MINT`, `BURN`, or `SEIZE`) without pausing the rest of the token.
Roles let an issuer assign each privileged operation to a specific account. An admin can grant minting to a minter, seizing to a compliance operator, issuer-approved spending to an authorized spender, and pausing of a single feature (`TRANSFER`, `MINT`, `BURN`, or `SEIZE`) without pausing the rest of the token.

B20 implements this with [OpenZeppelin AccessControl](https://docs.openzeppelin.com/contracts/5.x/access-control) on the token. Roles are not a separate registry. One `DEFAULT_ADMIN_ROLE` holder grants and revokes the operating roles. A privileged call checks the role first, then the matching pause vector. Holder `transfer` skips the role check; it still hits the `TRANSFER` pause vector and policy.

`AUTHORIZED_SPENDER_ROLE` gives its holder an infinite allowance from every token holder. `allowance(owner, spender)` returns `type(uint256).max`, and `transferFrom` does not consume the holder's stored allowance. The transfer pause vector and sender, receiver, and executor policies still apply. A holder cannot opt out by approving zero; the role admin must revoke the role.

The full role list and what each role gates is in [Roles](./concepts/roles.md). A role-gated call looks like this:

```mermaid
Expand Down
1 change: 1 addition & 0 deletions docs/reference/constants.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
| `PAUSE_ROLE` | `keccak256("PAUSE_ROLE")`<br>`0x139c2898040ef16910dc9f44dc697df79363da767d8bc92f2e310312b816e46d` | Required to call `pause`. |
| `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")`<br>`0x265b220c5a8891efdd9e1b1b7fa72f257bd5169f8d87e319cf3dad6ff52b94ae` | Required to call `unpause`. |
| `METADATA_ROLE` | `keccak256("METADATA_ROLE")`<br>`0x6bd6b5318a46e5fff572d5e4258a20774aab40cc35ac7680654b9081fcc82f80` | Required to call `updateName`, `updateSymbol`, `updateContractURI`, and `updateExtraMetadata`. |
| `AUTHORIZED_SPENDER_ROLE` | `keccak256("AUTHORIZED_SPENDER_ROLE")`<br>`0xb0e3ae34a3ebd864ed280a15abe71cbcaf59103e086737862f5bbccae6a44b37` | Grants infinite allowance from every holder. |
| `OPERATOR_ROLE` | `keccak256("OPERATOR_ROLE")`<br>`0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | B20Asset-only. Required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `updateMultiplier`. |

## Policy types
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `AUTHORIZED_SPENDER_ROLE`, and its allowance is less than `needed` for the requested `transferFrom`. |
| `InsufficientBalance(address sender, uint256 balance, uint256 needed)` | `0xdb42144d` | `sender`'s balance is less than `needed` for the requested transfer or burn. |
| `InvalidSender(address sender)` | `0x4c14f64c` | The transfer's source address is invalid (typically `address(0)`). |
| `InvalidReceiver(address receiver)` | `0x9cfea583` | The transfer's destination address is invalid (typically `address(0)`). |
Expand Down
16 changes: 12 additions & 4 deletions src/interfaces/IB20.sol

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2

Original file line number Diff line number Diff line change
Expand Up @@ -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 AUTHORIZED_SPENDER_ROLE() external view returns (bytes32);

/*//////////////////////////////////////////////////////////////
POLICY TYPE CONSTANTS
//////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -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
/// `AUTHORIZED_SPENDER_ROLE`, regardless of the stored allowance.
///
/// @param owner Allowance owner.
/// @param spender Allowance spender.
Expand All @@ -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
/// `AUTHORIZED_SPENDER_ROLE`. Emits `Transfer`.
///
/// @dev Reverts with `ContractPaused(TRANSFER)` when `TRANSFER` is paused.
/// @dev Reverts with `InvalidReceiver` when `to == address(0)`.
/// @dev Reverts with `InvalidSender` when `from == address(0)`.
/// @dev Reverts with `InsufficientAllowance` when the caller's allowance from `from` is below `amount`.
/// @dev Reverts with `InsufficientAllowance` when the caller does not hold `AUTHORIZED_SPENDER_ROLE` and its
/// allowance from `from` is below `amount`.
/// @dev Reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender != from` and `msg.sender` is not authorized.
/// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `from` is not authorized.
/// @dev Reverts with `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized.
Expand All @@ -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 `AUTHORIZED_SPENDER_ROLE`.
///
/// @dev Reverts with `InvalidApprover` when `msg.sender == address(0)`.
/// @dev Reverts with `InvalidSpender` when `spender == address(0)`.
Expand Down
1 change: 1 addition & 0 deletions src/lib/B20Constants.sol

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ library B20Constants {
bytes32 internal constant PAUSE_ROLE = keccak256("PAUSE_ROLE");
bytes32 internal constant UNPAUSE_ROLE = keccak256("UNPAUSE_ROLE");
bytes32 internal constant METADATA_ROLE = keccak256("METADATA_ROLE");
bytes32 internal constant AUTHORIZED_SPENDER_ROLE = keccak256("AUTHORIZED_SPENDER_ROLE");
bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");

bytes32 internal constant TRANSFER_SENDER_POLICY = keccak256("TRANSFER_SENDER_POLICY");
Expand Down
Loading
Loading