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. 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
Expand Down
112 changes: 112 additions & 0 deletions changelog/03_Denim_B20_preauthorized_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,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

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.

Add gas section


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

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

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.

4 changes: 2 additions & 2 deletions 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` |

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


7 changes: 4 additions & 3 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` |
| `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

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


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

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 `PREAUTHORIZED_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, 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
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`. |
| `PREAUTHORIZED_SPENDER_ROLE` | `keccak256("PREAUTHORIZED_SPENDER_ROLE")`<br>`0xb90b441c392e1b39b562e08d16a15eab44f161ced0b6366c03aa7d09a22f1a41` | 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 `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)`). |
Expand Down
Loading
Loading