Abstract
Denim appliesTRANSFER_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 callstransferFrom.
TRANSFER_EXECUTOR_POLICY is the initiator allowlist for that pattern, but before Denim it had two
gaps:
transfernever consulted the executor policy. A holder could always move their own tokens throughtransfer, regardless of the allowlist.transferFromskipped the check whenmsg.sender == from. A holder could calltransferFrom(self, to, amount)to reach the same unchecked path.
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
Transfer Agent Allowlist
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 setTRANSFER_EXECUTOR_POLICY. The unset slot stays
always-allow, and the bootstrap bypass is unchanged.
For a token with a restrictive TRANSFER_EXECUTOR_POLICY:
- Holders using
transfer: they must be authorized underTRANSFER_EXECUTOR_POLICY, directly or through a policy they belong to, to keep moving their own tokens. - Holders using self-
transferFrom: the same authorization now applies to that path. - To keep allowing holder-initiated transfers: add those holders, or a policy covering them, to the executor allowlist before Denim activates.
TRANSFER_EXECUTOR_POLICY
reference page describes the scope as it behaves before Denim activates.