Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ npx skills add base/base-skills
|SDKs & APIs/Base Verify API:sdks/base-verify/overview,sdks/base-verify/verify-social-accounts,sdks/base-verify/verify-users-onchain
|SDKs & APIs/Migrated Documentation:sdks/migrated-products
|Upgrades/Overview:upgrades/overview,base-chain/network-information/configuration-changelog
|Upgrades/Denim:upgrades/denim/overview,upgrades/denim/200ms-blocks,upgrades/denim/migrate-from-flashblocks
|Upgrades/Denim:upgrades/denim/overview,upgrades/denim/200ms-blocks,upgrades/denim/migrate-from-flashblocks,base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver,base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement,base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy
|Upgrades/Cobalt:upgrades/cobalt/overview,upgrades/cobalt/dynamic-upgrades,base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier,base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize,base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy,upgrades/cobalt/validity-transactions
|Upgrades/Beryl:upgrades/beryl/overview,upgrades/beryl/reth-v2,upgrades/beryl/reducing-canonical-withdrawal-delay,upgrades/beryl/b20
|Upgrades/Azul:upgrades/azul/overview,upgrades/azul/node-upgrade,upgrades/azul/exec-engine,upgrades/azul/proofs
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: "B20: Seize Surface and burnBlocked Deprecation"
title: "B20: Seize Functionality"
description: "The B20 seize surface at Cobalt and the deprecation of burnBlocked. Migration notes for teams integrated against the Beryl seize path."
---

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: "B20: Beryl to Cobalt Migration"
title: "B20: ERC-8056 Conformant Multiplier"
description: "B20 Asset multiplier becomes ERC-8056 conformant at Cobalt, with a scheduled multiplier setter for corporate actions. Every Beryl selector stays dialable."
---

Expand All @@ -13,9 +13,11 @@ conformant and gains a scheduled multiplier setter for corporate actions. Nothin
breaks: every Beryl selector, event topic, and error keeps its exact 4-byte selector or topic0 and
stays dialable at Cobalt. The deprecations below are advisory, not enforced.

To migrate, adopt the canonical ERC-8056 names (`uiMultiplier`, `toUIAmount`/`fromUIAmount`,
`balanceOfUI`, `totalSupplyUI`), and move routine multiplier changes from the instant
`updateMultiplier(uint256)` to the scheduled `updateUIMultiplier(uint256,uint256)`.
To migrate, adopt the ERC-8056 names (`uiMultiplier`, `toUIAmount`/`fromUIAmount`, `balanceOfUI`,
`totalSupplyUI`), and move routine multiplier changes from the instant `updateMultiplier(uint256)` to
the scheduled `updateUIMultiplier(uint256,uint256)`. `multiplier()` and `scaledBalanceOf(address)`
remain the canonical B20 names; `uiMultiplier()` and `balanceOfUI(address)` are their ERC-8056
aliases.

Cobalt hasn't gone live yet. Until it activates, only the Beryl surface exists on-chain.

Expand All @@ -26,12 +28,12 @@ The selectors and topic0s below are the real values from the frozen ABIs: `abi/v

### Functions

| Beryl symbol (selector) | Cobalt canonical (selector) | Status | Why |
| Beryl symbol (selector) | Cobalt ERC-8056 symbol (selector) | Status | Why |
| --- | --- | --- | --- |
| `multiplier()` `0x1b3ed722` | `uiMultiplier()` `0xa60bf13d` | deprecated-name-kept / new alias | ERC-8056 core naming. Both return the same effective multiplier. `multiplier()` stays. |
| `multiplier()` `0x1b3ed722` | `uiMultiplier()` `0xa60bf13d` | unchanged (canonical name) / new alias | ERC-8056 core naming. Both return the same effective multiplier. |
| `toScaledBalance(uint256)` `0x04f04c99` | `toUIAmount(uint256)` `0x3248d4ff` | deprecated-dialable / new | ERC-8056 Conversion extension. Byte-identical behavior. |
| `toRawBalance(uint256)` `0x0ca06c44` | `fromUIAmount(uint256)` `0x65cd9b3c` | deprecated-dialable / new | ERC-8056 Conversion extension. Byte-identical behavior. |
| `scaledBalanceOf(address)` `0x1da24f3e` | `balanceOfUI(address)` `0x437a9958` | deprecated-name-kept / new alias | ERC-8056 Balances extension. Alias, same value. |
| `scaledBalanceOf(address)` `0x1da24f3e` | `balanceOfUI(address)` `0x437a9958` | unchanged (canonical name) / new alias | ERC-8056 Balances extension. Alias, same value. |
| `updateMultiplier(uint256)` `0x5ffe6146` | `updateUIMultiplier(uint256,uint256)` `0x628e600f` | deprecated-dialable / new (not 1:1) | The canonical path is now the scheduled setter. The instant setter remains as an emergency failsafe. |
| — | `newUIMultiplier()` `0xdc767007` | new | ERC-8056 pending-schedule read. |
| — | `effectiveAt()` `0x97a4064f` | new | ERC-8056 pending-schedule read (flip timestamp). |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
title: "B20: Reject the Token Itself as a Credit Recipient"
description: "Denim reverts InvalidReceiver when a transfer, mint, or seize credits the B20 token's own address, so a mistyped recipient fails instead of locking funds."
---

## Abstract

[Denim](/upgrades/denim/overview) adds `address(this)` as a second trigger of the existing
`InvalidReceiver(address receiver)` error. `transfer`, `transferFrom`, their memo variants, `mint`,
`mintWithMemo`, `batchMint`, and `seizeWithMemo` revert `InvalidReceiver(to)` when `to` is the
token's own address. The guard covers the `IB20` transfer, mint, and seize paths plus
`IB20Asset.batchMint`, so it applies to both B20 Asset and B20 Stablecoin.

A holder sending to themselves (`from == to`) still succeeds. An issuer can still recover tokens
already credited to the token address: `seizeWithMemo` with `from == address(token)` succeeds.

This change is breaking for any integration that sends to the token address today. It adds no new
functions, events, errors, or selectors.

## Motivation

Users often paste the token address instead of the recipient's address. A B20 token is a precompile
with no holder key, so it cannot call `transfer` on itself. After a credit lands at the token
address, the sender cannot recover it; only the issuer can, through `seizeWithMemo`.

There is no valid use case for a B20 token to hold its own tokens. Denim reverts on that destination
so the mistaken send fails instead of locking the funds.

## What Changed

### Receiver Guard

`InvalidReceiver` already fires for `address(0)` (ERC-6093). Denim extends the same guard, at the
same position in the revert order:

```solidity Before (Cobalt)
if (to == address(0)) revert InvalidReceiver(to);
```

```solidity After (Denim)
if (to == address(0) || to == address(this)) revert InvalidReceiver(to);
```

| Symbol | Selector | Status | Behavior |
| --- | --- | --- | --- |
| `InvalidReceiver(address)` | `0x9cfea583` | Extended | Now also fires when `to == address(this)`. |

### Revert Order

The check runs at the existing invalid-receiver step. Canonical order is otherwise unchanged:

| Function | Check order |
| --- | --- |
| `transfer` / `transferWithMemo` | pause → **invalid-receiver** → zero-sender → executor policy → sender policy → receiver policy → balance |
| `transferFrom` / `transferFromWithMemo` | pause → **invalid-receiver** → zero-sender → allowance → executor policy → sender policy → receiver policy → balance |
| `mint` / `mintWithMemo` | pause → role → **invalid-receiver** → mint-receiver policy → supply cap |
| `batchMint` | pause → role → length / empty → per-element **invalid-receiver** → `_mint` body |
| `seizeWithMemo` | pause → role → **invalid-receiver** → zero-sender → self-seize (`from == to`) → seizable → seize-receiver policy → balance |

The guard checks `to` only. `from` may equal `address(this)`, so a seize that drains the token
address into a treasury still succeeds.

## Examples

A holder transfer to the token reverts:

```solidity Transfer to Token Address
vm.prank(alice);
token.transfer({to: address(token), amount: amount}); // reverts InvalidReceiver(address(token))
```

Mint and seize to the token address revert the same way:

```solidity Mint or Seize to Token Address lines expandable wrap
token.mint({to: address(token), amount: amount}); // reverts InvalidReceiver(address(token))

token.seizeWithMemo({
from: alice, to: address(token), amount: amount, memo: memo
}); // reverts InvalidReceiver(address(token))
```

A self-send and a recovery seize both still succeed:

```solidity Unaffected Paths lines expandable wrap
vm.prank(alice);
token.transfer({to: alice, amount: amount}); // succeeds; balance and totalSupply unchanged

token.seizeWithMemo({
from: address(token), to: treasury, amount: amount, memo: memo
}); // succeeds
```

## Design Decisions and Alternatives Considered

Denim reuses `InvalidReceiver` and compares `to` against `address(this)` on every credit path. The
destination is invalid for the same reason `address(0)` is: no holder can spend the credited units.
Wallets that already treat `InvalidReceiver` as "do not send here" keep the same revert handling.

### Reject Any B20-Prefix Address

A prefix check cannot tell a B20 token from a user-controlled account in the same address space,
such as a multisig. Rejecting the whole prefix would revert valid transfers, so Denim checks
`address(this)` only. Sends to other B20 tokens still succeed.

### Call `isB20Initialized(to)`

This would reject only live tokens, but it adds a factory call on every credit path.

### New `SelfSend(address)` Error

A dedicated error would read more clearly in traces, but it adds ABI surface for a condition
`InvalidReceiver` already describes.

### Also Reject `from == address(this)`

Blocking spends from the token address would close the only recovery path for balances already
sitting there.

## Migration

1. Treat the token's own address as an invalid recipient in wallets, custodians, and indexers, the
same way you treat `address(0)`.
2. After Denim activates, expect `InvalidReceiver` from any transfer, mint, or seize to
`address(token)` that succeeded before.
3. Recover a balance credited to the token address before activation with
`seizeWithMemo(address(token), treasury, amount, memo)`. The caller must hold `SEIZE_ROLE`, and
the token must be seizable under `SEIZE_EXEMPT_POLICY`.
4. No change is needed for self-transfers, approvals, burns, or sends to other B20 tokens.

The [`seizeWithMemo`](/specifications/b20/reference/interfaces/ib20/seize-with-memo) and
[`transfer`](/specifications/b20/reference/interfaces/ib20/transfer) reference pages describe
behavior before Denim activates.
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
title: "B20: Transfer Executor Policy Enforcement"
description: "Denim enforces TRANSFER_EXECUTOR_POLICY on msg.sender for every transfer path, closing the transfer and self-transferFrom bypasses."
---

## Abstract

[Denim](/upgrades/denim/overview) applies `TRANSFER_EXECUTOR_POLICY` to every transfer path. The
executor gate now checks `msg.sender` on `transfer`, `transferFrom`, `transferWithMemo`, and
`transferFromWithMemo`, including when `msg.sender == from`. Before Denim, the check ran only on
the `transferFrom` paths, and only when `msg.sender != from`.

This change is breaking for a token that already set a restrictive `TRANSFER_EXECUTOR_POLICY`:
holders who moved their own tokens with `transfer` or self-`transferFrom` must now be authorized as
initiators. A token that never set the policy keeps the unset always-allow default and is
unaffected. The change adds no new selectors, events, errors, or storage.

## Motivation

An issuer of a restricted security token may require every transfer to go through a registered
transfer agent. Holders approve the agent, and only the agent calls `transferFrom`.
`TRANSFER_EXECUTOR_POLICY` is the initiator allowlist for that pattern, but before Denim it had two
gaps:

1. **`transfer` never consulted the executor policy.** A holder could always move their own tokens
through `transfer`, regardless of the allowlist.
2. **`transferFrom` skipped the check when `msg.sender == from`.** A holder could call
`transferFrom(self, to, amount)` to reach the same unchecked path.

Both gaps let a non-allowlisted holder move tokens by picking a different entrypoint. Denim brings
`TRANSFER_EXECUTOR_POLICY` to parity with `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY`,
which already run on every transfer path.

## What Changed

### Transfer-Side Scopes

All three transfer-side scopes now run inside the shared `_transfer` helper that backs `transfer`,
`transferFrom`, and their memo variants:

| Scope | Account checked | Before Denim | Denim |
| --- | --- | --- | --- |
| `TRANSFER_SENDER_POLICY` | `from` | Every transfer path | Every transfer path |
| `TRANSFER_RECEIVER_POLICY` | `to` | Every transfer path | Every transfer path |
| `TRANSFER_EXECUTOR_POLICY` | `msg.sender` | `transferFrom` paths, only when `msg.sender != from` | Every transfer path |

All three scopes are still bypassed during the factory bootstrap window (`_isPrivileged()`), so a
token's `initCalls` can move newly minted supply without pre-authorizing the factory.

### Revert Order

Pause, zero-actor, and allowance checks stay in the entrypoints. The executor check moves into
`_transfer`, where it runs first, before the sender and receiver checks:

| Function | Before Denim | Denim |
| --- | --- | --- |
| `transfer` / `transferWithMemo` | pause → zero-receiver → zero-sender → sender policy → receiver policy → balance | pause → invalid-receiver → zero-sender → **executor policy** → sender policy → receiver policy → balance |
| `transferFrom` / `transferFromWithMemo` | pause → zero-receiver → zero-sender → allowance → executor policy (skipped if `msg.sender == from`) → sender policy → receiver policy → balance | pause → invalid-receiver → zero-sender → allowance → **executor policy** → sender policy → receiver policy → balance |

At Denim, the invalid-receiver step also rejects the token's own address; see
[Reject the Token Itself as a Credit Recipient](/base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver).
When more than one check would fail, the caller sees the first revert in that order.

### Gas

`_transfer` reads all three transfer-side policy IDs from the existing packed slot in one `SLOAD`.
On `transferFrom`, this removes the second (warm) read the entrypoint used to make.

On `transfer`, the executor lookup is new. When `TRANSFER_EXECUTOR_POLICY` equals
`TRANSFER_SENDER_POLICY` and `from == msg.sender` (including both slots unset, `ALWAYS_ALLOW_ID`),
`_transfer` reuses the executor result and skips the sender `isAuthorized` call. A default
`transfer` therefore still makes two `isAuthorized` calls.

## Examples

A holder moving their own tokens is now gated by the executor policy:

```solidity Holder Transfer Blocked by Executor Policy
token.updatePolicy(TRANSFER_EXECUTOR_POLICY, ALWAYS_BLOCK_ID);

vm.prank(alice);
token.transfer(bob, amount); // reverts PolicyForbids(TRANSFER_EXECUTOR_POLICY, ALWAYS_BLOCK_ID)
```

An executor allowlist restricts initiation to a transfer agent:

```solidity Transfer Agent Allowlist lines expandable wrap highlight={6-7,9-10}
address[] memory agents = new address[](1);
agents[0] = transferAgent;
uint64 executorAllowlist =
policyRegistry.createPolicyWithAccounts(admin, IPolicyRegistry.PolicyType.ALLOWLIST, agents);
token.updatePolicy(TRANSFER_EXECUTOR_POLICY, executorAllowlist);

vm.prank(transferAgent);
token.transferFrom(alice, bob, amount); // succeeds: transferAgent is allowlisted

vm.prank(alice);
token.transfer(bob, amount); // reverts PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...): alice is not allowlisted
```

The factory bootstrap bypass still applies. This sketch elides the `createB20` arguments:

```solidity Bootstrap Bypass
initCalls = [
abi.encodeCall(IB20.mint, (address(factory), amount)),
abi.encodeCall(IB20.updatePolicy, (TRANSFER_EXECUTOR_POLICY, ALWAYS_BLOCK_ID)),
abi.encodeCall(IB20.transfer, (to, amount))
];
factory.createB20(..., initCalls); // succeeds: bootstrap window bypasses the executor check
```

## Design Decisions and Alternatives Considered

Denim centralizes the executor check in `_transfer`, on `msg.sender`, with no `msg.sender == from`
carve-out. It is the smallest change that closes both gaps, adds no interface surface, and matches
how the sender and receiver scopes are already enforced.

### Fold Pause, Zero-Actor, and Allowance Into `_transfer`

Allowance is specific to `transferFrom`. Folding it in would need a consume-allowance flag, and
moving the zero-actor checks after allowance would change revert order.

### Add a Separate Check to `transfer`

Adding a matching check to `transfer` while keeping the `transferFrom` carve-out leaves the
self-`transferFrom` bypass open and duplicates the check across two entrypoints.

## Migration

No action is needed for a token that never set `TRANSFER_EXECUTOR_POLICY`. The unset slot stays
always-allow, and the bootstrap bypass is unchanged.

For a token with a restrictive `TRANSFER_EXECUTOR_POLICY`:

1. **Holders using `transfer`:** they must be authorized under `TRANSFER_EXECUTOR_POLICY`, directly
or through a policy they belong to, to keep moving their own tokens.
2. **Holders using self-`transferFrom`:** the same authorization now applies to that path.
3. **To keep allowing holder-initiated transfers:** add those holders, or a policy covering them, to
the executor allowlist before Denim activates.

The [`TRANSFER_EXECUTOR_POLICY`](/specifications/b20/reference/interfaces/ib20/transfer-executor-policy)
reference page describes the scope as it behaves before Denim activates.
Loading
Loading