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
122 changes: 122 additions & 0 deletions changelog/03_Denim_B20_token_receiver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Reject the Token Itself as a Credit Recipient

- **Feature Name**: token_receiver
- **Start Date**: 2026-09-17
- **Authors**: Rayyan Alam
- **Title**: (Breaking) Reject the Token Itself as a Credit Recipient

## Summary

Denim rejects a send whose destination is this token's own address. That destination cannot spend the credited tokens, so the send would lock them.

`transfer`, `transferFrom`, their memo variants, `mint`, `mintWithMemo`, `batchMint`, and `seizeWithMemo` revert `InvalidReceiver(to)` when `to` is the token. A holder sending to themselves (`from == to`) still succeeds. An issuer can still recover tokens already credited to the B20 token contract: `seizeWithMemo` from the token address succeeds.

## Motivation

It is common for users to mistakenly send tokens to the token address instead of their intended recipient's address. In the case of B20 tokens, this makes the funds inaccessible without intervention from the token admin.

A B20 token is a precompile. It has no holder key and cannot call `transfer` on itself. After a transfer lands at the token address, the sender cannot recover those tokens. Only the issuer can, by calling `seizeWithMemo`.

There is no valid use case for a B20 token address to hold its own tokens. Denim therefore reverts `InvalidReceiver(to)` on that destination so the accidental send fails instead of locking the funds.

## Background

B20 is a native token precompile. The token address has no holder key and cannot initiate calls. A credit to that address is not spender-recoverable by the sender.

`InvalidReceiver(address receiver)` already fires for `address(0)` (ERC-6093). This change adds `address(this)` as a second trigger of the same error.

## Specs

### Interface Changes

This change adds no new functions, events, errors, or selectors. `InvalidReceiver(address receiver)` already exists. Its documented triggers now include the token's own address.

### Behavioural Changes

A shared valid-receiver check runs at the same position as the existing zero-receiver guard:

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

Canonical order is unchanged. `address(0)` and `address(this)` are two triggers of the same invalid-receiver step.


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


`from` may equal `address(this)`. A seize that drains the token into a treasury still succeeds.

### Examples

A holder transfer to this token reverts:

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

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

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

A holder sending to themselves still succeeds:

```solidity
vm.prank(alice);
token.transfer({to: address(alice), amount: uint256(amount)}); // succeeds; balance and totalSupply unchanged
```

Recovery of a pre-activation stuck balance still succeeds:

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

## Design Decisions & Alternatives Considered

### Chosen: reuse `InvalidReceiver`, reject `to == address(this)` on every credit path

The destination is invalid for the same reason `address(0)` is invalid: a holder cannot spend the credited units. Reusing `InvalidReceiver` avoids a new selector. Wallets that already treat that error as "do not send here" keep the same revert handling.

The check compares against `address(this)` — one word, no external call, matches the reported paste-error footgun exactly. Mint, seize, and `batchMint` are included because they write the same `balances[to]` slot.

The check lives next to the existing zero-receiver guard, not inside `_moveBalance`. `_moveBalance` is an unguarded mechanic. Callers already apply their own input checks.

### Alternative — reject any B20-prefix address as recipient

That would also revert when `to` is a different B20-prefix address (token A → token B). It was rejected. A prefix check cannot tell a B20 token from a user-controlled account in that address space, such as a multisig. Rejecting the whole prefix would revert valid transfers to those recipients.

Denim therefore compares `to` against `address(this)` only. Sends to other addresses, including other B20 tokens, still succeed.

### Alternative — call `isB20Initialized(to)`

That would reject only live tokens. It was rejected because it adds a factory call on every credit path and does not fit the paste-error framing.

### Alternative — new error such as `SelfSend(address)`

A dedicated error would make the case obvious in traces. It was rejected because it adds ABI surface for a condition that is already "this destination is invalid".

### Alternative — also reject `from == address(this)`

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

## Migration Steps

1. Treat this token's own address as an invalid recipient, the same way you already treat `address(0)`. This applies to wallets, custodians, and indexers.
2. After Denim activation, expect `InvalidReceiver` from a transfer, mint, or seize to `address(token)` that succeeded before Denim.
3. If a balance is already credited to the token address from before activation, recover it with `seizeWithMemo(address(token), treasury, amount, memo)`. The token must be seizable under `SEIZE_EXEMPT_POLICY`. The caller must hold `SEIZE_ROLE`.
4. Do not change holder-to-holder self-transfers, approvals, burns, or sends to other B20 tokens. This change does not affect them.
1 change: 1 addition & 0 deletions changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Grouped by hardfork, one collapsible section per hardfork, newest first.

| Product(s) | Change | Affected interfaces | Entry |
| --- | --- | --- | --- |
| B20 | Reject the token itself as a credit recipient | `src/interfaces/IB20.sol`, `src/interfaces/IB20Asset.sol` | [03_Denim_B20_token_receiver](03_Denim_B20_token_receiver.md) |
| B20 | Transfer executor policy on every transfer path | `src/interfaces/IB20.sol` | [03_Denim_B20_transfer_executor_enforcement](03_Denim_B20_transfer_executor_enforcement.md) |
| PolicyRegistry | NOT / invert policies | `src/interfaces/IPolicyRegistry.sol` | [03_Denim_PolicyRegistry_not_policy](03_Denim_PolicyRegistry_not_policy.md) |

Expand Down
6 changes: 4 additions & 2 deletions docs/guides/seizeing-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ You need all of the following:
- A B20 token you administer.
- `DEFAULT_ADMIN_ROLE` on that token, so you can grant roles and attach policies.
- An account that will call `seizeWithMemo` (the seizer).
- A non-zero destination that is not the holder (typically a treasury).
- A non-zero destination that is not the holder and not this token's own address (typically a treasury).
- `SEIZE` not paused. `pause([SEIZE])` blocks every seize until `unpause([SEIZE])`.

Three independent controls then decide whether a seize can run. In this order, the steps later configure them in the same order.
Expand Down Expand Up @@ -269,14 +269,16 @@ These errors follow the order `seizeWithMemo` checks them.
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `ContractPaused(SEIZE)` | `SEIZE` is paused. | Call `unpause` with `PausableFeature.SEIZE`. |
| `AccessControlUnauthorizedAccount(caller, SEIZE_ROLE)` | The caller does not hold `SEIZE_ROLE`. | Grant `SEIZE_ROLE` to the seizer. |
| `InvalidReceiver(to)` | `to` is `address(0)`, or `from == to`. | Use a distinct, non-zero safekeeping address. |
| `InvalidReceiver(to)` | `to` is `address(0)`, this token's own address, or `from == to`. | Use a distinct, non-zero safekeeping address that is not this token itself. |
| `InvalidSender(from)` | `from` is `address(0)`. | Pass the holder's address. |
| `AccountNotSeizable(from)` | `from` is still authorized under `SEIZE_HOLDER_POLICY`. The slot is unset, or the holder is not on the attached blocklist. | Attach a blocklist and add `from`. |
| `PolicyForbids(SEIZE_RECEIVER_POLICY, policyId)` | `to` is not authorized under `SEIZE_RECEIVER_POLICY`. | Add `to` to the receiver allowlist, or set the scope back to `0` (`ALWAYS_ALLOW`). |
| `InsufficientBalance(from, balance, amount)` | `from` holds less than `amount`. | Seize `balanceOf(from)` or less. |
| `PolicyNotFound(policyId)` | `updatePolicy` received an ID that is not a sentinel and does not exist in the registry. | Create the policy first, then attach the returned ID. |
| `Unauthorized()` | A non-admin called `updateBlocklist` or `updateAllowlist`. | Call as the policy's `policyAdmin`. |

A balance already sitting at this token's address can still be recovered. `seizeWithMemo(address(token), treasury, amount, memo)` is allowed; seizing *to* `address(token)` is not.


```mermaid
flowchart TD
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
| `InsufficientAllowance(address spender, uint256 allowance, uint256 needed)` | `0x192b9e4e` | `spender`'s 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)`). |
| `InvalidReceiver(address receiver)` | `0x9cfea583` | The transfer's destination address is invalid (`address(0)` or the token's own address). |
| `InvalidApprover(address approver)` | `0x8bc146c4` | The approval's `owner` address is invalid (typically `address(0)`). |
| `InvalidSpender(address spender)` | `0x4e15efda` | The approval's `spender` address is invalid (typically `address(0)`). |
| `InvalidAmount()` | `0x2c5211c6` | An amount argument was zero where a non-zero value is required. Not used for ERC-20 amount arguments. |
Expand Down
3 changes: 3 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"lib/base-std": {
"rev": "d4b531cde26e920b4166025c2502c5674fa42de6"
},
"lib/forge-std": {
"tag": {
"name": "v1.16.1",
Expand Down
12 changes: 6 additions & 6 deletions script/mutate.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,12 @@ class Mutation:
"// if (spender == address(0)) revert InvalidSpender(spender);",
"approve: drop zero-spender guard",
),
# === MockB20: zero-receiver check skipped in _transfer specifically ===
# === MockB20: valid-receiver predicate always returns false (guard skipped everywhere) ===
Mutation(
MOCK_B20,
" function _requireNonZeroActors(address from, address to) internal pure {\n if (to == address(0)) revert InvalidReceiver(to);",
" function _requireNonZeroActors(address from, address to) internal pure {\n // if (to == address(0)) revert InvalidReceiver(to);",
"_requireNonZeroActors: drop zero-recipient guard",
"return account == address(0) || account == address(this);",
"return false;",
"_isContractAddressOrZero: predicate always false (drops zero-and-self-recipient guard at every callsite)",
),
# === MockB20: more mutations on accounting / event integrity ===
Mutation(
Expand Down Expand Up @@ -246,13 +246,13 @@ class Mutation:
MOCK_FACTORY,
"return (uint160(token) >> 80) == (uint160(0xB2) << 72);",
"return (uint160(token) >> 80) == (uint160(0xB3) << 72);",
"_isB20Prefix: compares against wrong prefix byte (no real B-20 ever matches)",
"_hasB20Prefix: compares against wrong prefix byte (no real B-20 ever matches)",
),
Mutation(
MOCK_FACTORY,
"return (uint160(token) >> 80) == (uint160(0xB2) << 72);",
"return (uint160(token) >> 88) == (uint160(0xB2) << 72);",
"_isB20Prefix: wrong shift amount (compares wrong byte range)",
"_hasB20Prefix: wrong shift amount (compares wrong byte range)",
),
# === String encoding short/long boundary ===
Mutation(
Expand Down
35 changes: 35 additions & 0 deletions script/smoke/journeys/asset_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@

from __future__ import annotations

from web3.exceptions import ContractLogicError

from .. import config
from ..chain import Chain, die, log, ok, step
from ..codec import AssetCreateParams, init_call
from ..errors import ERROR_BY_SELECTOR

MEMO = b"smoke".ljust(32, b"\x00")

Expand Down Expand Up @@ -155,6 +158,36 @@ def _executor_policy(c: Chain, tok) -> None:
)


def _token_recipient_rejected(tok, frm) -> bool:
"""Denim probe: `transfer` to the token address reverts `InvalidReceiver`.

Pre-Denim the call succeeds (zero-amount), so the token-recipient edges must
skip rather than fail. Uses eth_call so a pre-Denim success does not lock tokens.
"""
try:
tok.functions.transfer(tok.address, 0).call({"from": frm})
except ContractLogicError as exc:
data = getattr(exc, "data", None)
if isinstance(data, str) and data.startswith("0x") and len(data) >= 10:
return ERROR_BY_SELECTOR.get(data[:10].lower()) == "InvalidReceiver"
return False
return False


def _assert_token_recipient_rejected(c: Chain, tok) -> None:
if not _token_recipient_rejected(tok, c.DEPLOYER):
log("token-as-recipient still allowed — chain is pre-Denim; skipping InvalidReceiver edges")
return
step("11d", "transfer to token address -> InvalidReceiver")
c.expect_revert("InvalidReceiver", tok.functions.transfer(tok.address, 1), c.DEPLOYER)
step("11e", "transferFrom to token address -> InvalidReceiver")
c.expect_revert("InvalidReceiver", tok.functions.transferFrom(c.DEPLOYER, tok.address, 1), c.USER2)
step("11f", "mint to token address -> InvalidReceiver")
c.expect_revert("InvalidReceiver", tok.functions.mint(tok.address, 1), c.DEPLOYER)
step("11g", "batchMint including token address -> InvalidReceiver")
c.expect_revert("InvalidReceiver", tok.functions.batchMint([c.ALICE, tok.address], [1, 1]), c.DEPLOYER)


def _edges(c: Chain, tok) -> None:
step(11, "supply cap: lower cap to current supply, then mint 1 -> SupplyCapExceeded")
total = tok.functions.totalSupply().call()
Expand All @@ -167,6 +200,8 @@ def _edges(c: Chain, tok) -> None:
step("11c", "transferFrom insufficient allowance -> InsufficientAllowance (allowance consumed in step 5)")
c.expect_revert("InsufficientAllowance", tok.functions.transferFrom(c.DEPLOYER, c.BOB, config.amt(1, 18)), c.USER2)

_assert_token_recipient_rejected(c, tok)

step(12, "pause TRANSFER: transfer AND transferFrom revert ContractPaused; unpause restores")
# Approve user2 first so transferFrom clears the allowance check and the pause gate is the binding revert.
c.send(tok.functions.approve(c.USER2, config.amt(5, 18)), c.deployer)
Expand Down
7 changes: 7 additions & 0 deletions script/smoke/journeys/seize.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ def _edges(c: Chain, tok) -> None:
step(7, "zero destination -> InvalidReceiver (seize is a reassignment, not a burn)")
c.expect_revert("InvalidReceiver", tok.functions.seizeWithMemo(c.ALICE, config.ZERO, 1, MEMO), c.DEPLOYER)

step("7b", "token destination -> InvalidReceiver (Denim; skipped pre-Denim)")
try:
tok.functions.seizeWithMemo(c.ALICE, tok.address, 1, MEMO).call({"from": c.DEPLOYER})
log("seize to token address still allowed — chain is pre-Denim; skipping")
except ContractLogicError:
c.expect_revert("InvalidReceiver", tok.functions.seizeWithMemo(c.ALICE, tok.address, 1, MEMO), c.DEPLOYER)

step(8, "zero source -> InvalidSender (seize is a reassignment, not a mint)")
c.expect_revert("InvalidSender", tok.functions.seizeWithMemo(config.ZERO, c.BOB, 1, MEMO), c.DEPLOYER)

Expand Down
Loading
Loading