Skip to main content

Abstract

Denim 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: 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: At Denim, the invalid-receiver step also rejects the token’s own address; see Reject the Token Itself as a Credit Recipient. 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:
Holder Transfer Blocked by Executor Policy
An executor allowlist restricts initiation to a transfer agent:
Transfer Agent Allowlist
The factory bootstrap bypass still applies. This sketch elides the createB20 arguments:
Bootstrap Bypass

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 reference page describes the scope as it behaves before Denim activates.