Skip to content

feat(policy): enforce TRANSFER_EXECUTOR_POLICY on every transfer path - #224

Open
rayyan224 wants to merge 3 commits into
mainfrom
feat/policy-transfer-executor-enforcement
Open

feat(policy): enforce TRANSFER_EXECUTOR_POLICY on every transfer path#224
rayyan224 wants to merge 3 commits into
mainfrom
feat/policy-transfer-executor-enforcement

Conversation

@rayyan224

@rayyan224 rayyan224 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes TRANSFER_EXECUTOR_POLICY apply to every transfer path. The executor gate now checks msg.sender on transfer, transferFrom, transferWithMemo, and transferFromWithMemo — including when msg.sender == from. Previously it ran only on the delegated transferFrom paths, and only when msg.sender != from.

This targets the Q4 "Denim" candidate "Apply transfer executor policy on normal transfer" (P2) — letting issuers use an executor allowlist to restrict who may initiate a transfer (e.g. only an approved settlement contract).

Why

The old behavior left the executor scope unenforceable as an initiator gate, via two bypasses:

  1. Direct transfer was never gated — the initiator is msg.sender (== from), and the check ran only inside transferFrom.
  2. Self-transferFrom skipped the checkmsg.sender == from bypassed it, so a non-allowlisted holder could route transferFrom(self, to, amount) to move tokens anyway.

Centralizing the check in _transfer on msg.sender and removing the msg.sender == from carve-out closes both.

Approach

  • MockB20 (test/lib/mocks/MockB20.sol): executor check moved into _transfer (first, before sender/receiver, under the existing _isPrivileged() bootstrap bypass); duplicated body checks and the msg.sender == from carve-out removed. Allowance is still consumed in the transferFrom* bodies first, so revert order is unchanged.
  • Tests: executor cases (sentinel / external allowlist / privileged bypass) on transfer.t.sol + memo parity; EXECUTOR woven into transfer_revertOrder.t.sol (C(7,2)=21 pairs) and the memo sequential order test; the old transferFrom self-caller skip test inverted into test_transferFrom_revert_selfCaller_executorPolicyForbids to pin the closed loophole.
  • Interface/comments: IB20.sol natspec for the executor scope + the transfer/transferFrom revert lists; stale "delegated-only" comments in the mock/storage/revert-order headers.

Scope note: docs (docs/) and changelog were intentionally left out of this PR.

Compatibility

Purely behavioral — no new selectors, events, errors, or storage. An unset executor slot stays always-allow, so tokens that never configured the policy are unaffected. Factory bootstrap bypass and allowance accounting are unchanged.

Breaking only for a token that has set a restrictive TRANSFER_EXECUTOR_POLICY and relies on holders moving their own tokens via transfer / self-transferFrom — those holders must now be authorized as initiators.

Testing

forge test — 746 passed, 0 failed, 4 skipped (pre-existing mock-only privileged skips).

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

Interface Coverage

✅ All interface functions have test coverage.

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

📊 Forge Coverage (src/lib/)

🟡 ≥95% across all metrics — some metrics below 99%.

File Lines Stmts Branches Funcs
🟡 B20FactoryLib.sol 97.70% 98.00% 100.00% 95.00%
🔴 test/lib/ForceFeeder.sol 0.00% 0.00% 100.00% 0.00%
🔴 test/lib/PrecompileProbe.sol 0.00% 0.00% 0.00% 0.00%
🟢 MockActivationRegistry.sol 100.00% 100.00% 100.00% 100.00%
🟢 MockActivationRegistryStorage.sol 100.00% 100.00% 100.00% 100.00%
🟢 MockB20.sol 100.00% 100.00% 100.00% 100.00%
🟢 MockB20Asset.sol 100.00% 100.00% 100.00% 100.00%
🟡 MockB20Factory.sol 98.96% 99.10% 100.00% 100.00%
🟢 MockB20Stablecoin.sol 100.00% 100.00% 100.00% 100.00%
🟢 MockB20Storage.sol 100.00% 100.00% 100.00% 100.00%
🟡 MockPolicyRegistry.sol 100.00% 99.54% 97.67% 100.00%
🟢 MockPolicyRegistryStorage.sol 100.00% 100.00% 100.00% 100.00%
Total 97.05% 97.49% 98.12% 97.00%

Full report: download artifact. To browse locally: make coverage (runs forge coverage + genhtml + opens the HTML report).

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

⚠️ Fork tests: 16 failed, 728 passed

These failures indicate divergences where base/base needs to catch up to the base-std spec. This check is advisory and does not block merging.

Failing tests
  • test_transferFrom_revert_selfCaller_executorPolicyForbids(address,address,uint256): next call did not revert as expected; counterexample: calldata=0x641b204b000000000000000000000000f8f612bcfa256101af477dae98d6a1c25fdad2340000000000000000000000009aa16b4c5962f15dfe351f2299102137c13a99ef000000000000000000000000000003563afc25e89b0c38fe56700073e1281a98 args=[0xf8F612bCFa256101aF477DAE98D6A1c25fdad234, 0x9Aa16B4c5962f15dFE351F2299102137C13a99eF, 290679545801923397686110225624458917845656 [2.906e41]]
  • test_transferWithMemo_revertOrder(address,address,uint256,bytes32): Error != expected error: PolicyForbids(0xb81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5, 72057594037927937 [7.205e16]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xf5e31125000000000000000000000000eb8d6e1a2a9e0db46a4439a45ac300ef8b17620b000000000000000000000000631f5b04674997bd6289afb0999a5e062722cdbb00000000000000000000000000000000000000008ac1aab300958ead51b40610fe3148a9cf035b67613295c4b4d7a03a22a4d6561635058b3eeba7bee609a9a9 args=[0xeb8d6e1a2A9E0DB46A4439a45AC300Ef8B17620B, 0x631f5b04674997Bd6289AfB0999A5e062722CDbb, 42943060142842616595015271952 [4.294e28], 0xfe3148a9cf035b67613295c4b4d7a03a22a4d6561635058b3eeba7bee609a9a9]
  • test_transferWithMemo_revert_executorPolicyForbids(address,address,uint256,bytes32): Error != expected error: InsufficientBalance(0x9Df5687e08A59F55870550060DE05D6617F9ff7e, 0, 4763971665705791 [4.763e15]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xa3802c170000000000000000000000009df5687e08a59f55870550060de05d6617f9ff7e00000000000000000000000076777ca60ad337cf3c34e9b9139d2540396cd68a0000000000000000000000000000000000000000000000000010ecce96dabb3fb187e1f38aec8b579a79f12b16b277c13246397f545b3fc9c71f5b44b47998e2 args=[0x9Df5687e08A59F55870550060DE05D6617F9ff7e, 0x76777Ca60Ad337cf3c34E9b9139d2540396cD68a, 4763971665705791 [4.763e15], 0xb187e1f38aec8b579a79f12b16b277c13246397f545b3fc9c71f5b44b47998e2]
  • test_transfer_revertOrder_executorPolicy_beats_balance(address,address,uint256): Error != expected error: InsufficientBalance(0x4900b2A5c0BEcafe338b399378b6D79b19197Ab4, 0, 2792252292360021 [2.792e15]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0x76b31f6e0000000000000000000000004900b2a5c0becafe338b399378b6d79b19197ab4000000000000000000000000de826f1f200e37f2747ba8678f2ce11847ab46bb0000000000000000000000000000000000000000000000000009eb89e6026755 args=[0x4900b2A5c0BEcafe338b399378b6D79b19197Ab4, 0xDE826F1f200E37f2747Ba8678F2ce11847Ab46bb, 2792252292360021 [2.792e15]]
  • test_transfer_revertOrder_executorPolicy_beats_receiverPolicy(address,address,uint256): Error != expected error: PolicyForbids(0x8a4b3fa2d8b921852bc0089c6ef0958aa6961897be36fd731330fe2cd23f8363, 72057594037927937 [7.205e16]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0x67f822c50000000000000000000000006eeec71d40a423e1823760417b69c12baaafc9eb0000000000000000000000000b74b2ebfc9c7a3059e733f2c7712e705ac4063e000000668e778fce5eec07b681065fb9f004a0f5ed1abf30d03ca0a09bb8ee1d args=[0x6EeEC71D40A423e1823760417b69C12bAaAFc9eB, 0x0B74b2EbfC9C7A3059E733f2c7712e705ac4063e, 2764918090314431452460590551738696443959192507860577143817619165998621 [2.764e69]]
  • test_transfer_revertOrder_executorPolicy_beats_senderPolicy(address,address,uint256): Error != expected error: PolicyForbids(0xb81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5, 72057594037927937 [7.205e16]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xe9945cbb0000000000000000000000004f8faea1f7f8e74dd1163d001b0700d3784c95bf0000000000000000000000007578bec064b16cc2f800ac2b8dc0f88ff1d72dbd0000000000006093dbe6ba1a16b34a3a0d462f53dab9f3c1cdfbb8953254429d args=[0x4F8fAEA1F7F8e74Dd1163D001B0700D3784c95Bf, 0x7578beC064B16Cc2F800aC2B8DC0f88ff1d72DbD, 155194178167592493147839537130312129904170652505345635577512605 [1.551e62]]
  • test_transfer_revert_executorPolicyForbids(address,address,uint256): Error != expected error: InsufficientBalance(0x9193Aa34054020496d1cDc21cF118F45e84Ca980, 0, 10774395844037090457709468347 [1.077e28]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xe8de369d0000000000000000000000009193aa34054020496d1cdc21cf118f45e84ca9800000000000000000000000009d1c4a033cd96a8bbebb568bedaf11f84d940171000000000000000000000000000000000000000022d05f1110f1d70358e45abb args=[0x9193Aa34054020496d1cDc21cF118F45e84Ca980, 0x9D1C4A033Cd96a8BBeBb568beDAf11f84d940171, 10774395844037090457709468347 [1.077e28]]
  • test_transfer_revert_externalExecutorPolicyDenies(address,address,uint256): Error != expected error: InsufficientBalance(0x1Ee8Ca5346F51576d4A17098Cae7573cF8fbb1aE, 0, 152640355164504264249372650012175398182959107139327546 [1.526e53]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927938 [7.205e16]); counterexample: calldata=0x66ef86710000000000000000000000001ee8ca5346f51576d4a17098cae7573cf8fbb1ae000000000000000000000000573d143e957d79ec506fed43ba508406fc1dcd1e0000000000000000000197f8c4e92707d9e1a51cee4d0a4c482e954fb64e563a args=[0x1Ee8Ca5346F51576d4A17098Cae7573cF8fbb1aE, 0x573d143e957D79EC506FED43Ba508406fc1dcD1e, 152640355164504264249372650012175398182959107139327546 [1.526e53]]

@rayyan224
rayyan224 force-pushed the feat/policy-transfer-executor-enforcement branch from 8b64ef2 to 2c63f86 Compare September 10, 2026 20:11
Centralize the executor gate in `_transfer` so TRANSFER_EXECUTOR_POLICY is
checked against `msg.sender` on all four transfer entrypoints (`transfer`,
`transferFrom`, `transferWithMemo`, `transferFromWithMemo`), including when
`msg.sender == from`. Previously it ran only on the delegated `transferFrom`
paths and only when `msg.sender != from`.

This closes two bypasses that made an executor allowlist unenforceable: direct
`transfer` was never gated, and a self-`transferFrom` skipped the check. The
executor scope is now a true "who may initiate a transfer" gate.

Behavioral only — no new selectors, events, errors, or storage. An unset
executor slot stays always-allow, so tokens that never configured the policy
are unaffected. The factory bootstrap bypass and allowance accounting are
unchanged.

- MockB20: move executor check into `_transfer` (first, before sender/receiver);
  drop the duplicated body checks and the `msg.sender == from` carve-out
- tests: executor cases on transfer + memo, EXECUTOR woven into the revert-order
  suites, and the old self-caller skip test inverted to pin the closed loophole
- interface/comments: IB20 natspec, mock/storage comments

Co-Authored-By: Claude <noreply@anthropic.com>
@rayyan224
rayyan224 force-pushed the feat/policy-transfer-executor-enforcement branch from 2c63f86 to 6a55bf8 Compare September 10, 2026 20:28
// is still not decremented. The executor policy is enforced centrally
// in `_transfer` (on `msg.sender`), which honors the bootstrap bypass.
_consumeAllowance(from, msg.sender, amount);
if (!_isPrivileged() && msg.sender != from) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed here, becaues repulled in the helper no need for an extra SLOAD

@rayyan224
rayyan224 marked this pull request as ready for review September 10, 2026 20:29
rayyan224 and others added 2 commits September 11, 2026 11:16
Record that TRANSFER_EXECUTOR_POLICY now gates every transfer path, including memo variants, so integrators can see the new revert order and coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a task-oriented guide for gating TRANSFER_EXECUTOR_POLICY with a
transfer-agent example, and index it in docs/README.md.

Co-Authored-By: Claude <noreply@anthropic.com>

## Motivation

An issuer may want an executor allowlist: only specific, approved contracts or accounts may initiate a transfer, for example a settlement contract that moves tokens on a holder's behalf. `TRANSFER_EXECUTOR_POLICY` exists for this, but the previous scope could not enforce it consistently with `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY`, which already run on every transfer path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Talk about transfer agent, here


This change makes `TRANSFER_EXECUTOR_POLICY` apply to every transfer path. The executor gate now checks `msg.sender` on `transfer`, `transferFrom`, `transferWithMemo`, and `transferFromWithMemo`, including when `msg.sender == from`. Previously the check ran only on the delegated `transferFrom` paths, and only when `msg.sender != from`.

The change is purely behavioral. It adds no new selectors, events, errors, or storage. A token that never sets `TRANSFER_EXECUTOR_POLICY` keeps the unset always-allow default, so it is unaffected.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure to talk about the breaking change in the summary

When more than one check would fail, the caller sees the first revert in that order:

There are no new storage slots. The `TRANSFER_EXECUTOR_POLICY` policy ID is read from the same packed slot as before; `_transfer` now reads all three transfer-side policy IDs from that slot in one `SLOAD` instead of the executor lane being pre-warmed by a separate read in `transferFrom`'s body.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add Gas Section


```mermaid
flowchart TD
subgraph beforeTransfer ["Before: transfer / transferWithMemo"]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check to move everything in the helper.

// check. Solidity emits a single SLOAD for the struct read +
// masked extracts for the named fields.
MockB20Storage.TransferPolicyIds memory packed = MockB20Storage.layout().transferPolicyIds;
if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(packed.executor, msg.sender)) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add optimization for, cache

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant