Skip to content

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

Merged
rayyan224 merged 6 commits into
mainfrom
feat/policy-transfer-executor-enforcement
Sep 14, 2026
Merged

rayyan224 merged 6 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% 99.67% 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.07% 97.42% 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, 730 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=0x641b204b000000000000000000000000cb000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005a3e0000000000000000000000000000000000000000000000000000000000015180 args=[0xCB00000000000000000000000000000000000000, 0x0000000000000000000000000000000000005a3e, 86400 [8.64e4]]
  • test_transferWithMemo_revertOrder(address,address,uint256,bytes32): Error != expected error: PolicyForbids(0xb81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5, 72057594037927937 [7.205e16]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xf5e31125000000000000000000000000d04451ce7caefbae6d50d2a4df0962a364c7caec000000000000000000000000ae36241e73b9b1a6f894d29fa41b79d2c96ffc6700000000000000000000000000000000000004e896ca871a45e998af3936347da1d7a4d259029653b17ccaf94870e8f0c7514717f9d301cf184f1ce6afbb9e71 args=[0xD04451ce7caEfbAE6d50D2a4dF0962a364c7cAeC, 0xAE36241E73b9b1A6F894D29Fa41B79D2c96Ffc67, 99557239710408900191748298781821 [9.955e31], 0xa1d7a4d259029653b17ccaf94870e8f0c7514717f9d301cf184f1ce6afbb9e71]
  • test_transferWithMemo_revert_executorPolicyForbids(address,address,uint256,bytes32): Error != expected error: InsufficientBalance(0xd0100cFEd849363cd101E33294533b608f520e08, 0, 43241827434344240323736053602669525264954589702965735556316224898 [4.324e64]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xa3802c17000000000000000000000000d0100cfed849363cd101e33294533b608f520e08000000000000000000000000eea94b35c6c551410c95505ab978c4653d96e4040000000000691d748d02cbd5f3b05c451267a14cbab1e19ee581df45e1895d82991f9ac00378ccced55baebbd887aad3505806c487f7d0fef03f22087dce6c0a args=[0xd0100cFEd849363cd101E33294533b608f520e08, 0xeea94B35C6C551410C95505AB978C4653D96E404, 43241827434344240323736053602669525264954589702965735556316224898 [4.324e64], 0x991f9ac00378ccced55baebbd887aad3505806c487f7d0fef03f22087dce6c0a]
  • test_transfer_revertOrder_executorPolicy_beats_balance(address,address,uint256): Error != expected error: InsufficientBalance(0x37D91c80044a2F8570eE0820A15F83055e6c18Ce, 0, 160229028558920910288566518779009012148 [1.602e38]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0x76b31f6e00000000000000000000000037d91c80044a2f8570ee0820a15f83055e6c18ce0000000000000000000000000c3f81e9e5453e5a5125e5b3b303c8e286b4c1ed0000000000004868146af3bda654ba61788afd082fd07b1c93d2d3dd4456d753 args=[0x37D91c80044a2F8570eE0820A15F83055e6c18Ce, 0x0C3F81E9e5453E5a5125e5b3b303c8E286b4C1ed, 116352858409677817205911509666691032584288671825459505758918483 [1.163e62]]
  • test_transfer_revertOrder_executorPolicy_beats_receiverPolicy(address,address,uint256): Error != expected error: PolicyForbids(0x8a4b3fa2d8b921852bc0089c6ef0958aa6961897be36fd731330fe2cd23f8363, 72057594037927937 [7.205e16]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0x67f822c5000000000000000000000000064dfa028fc52b169d1c044d990fd762e01328e6000000000000000000000000e410dd0cf00a76bd1a0ffc473f25714978a58e14000000000000000000000000000000000000000077f22541c2f703a476084c92 args=[0x064dfA028FC52b169d1C044D990Fd762e01328E6, 0xE410DD0cf00A76bd1A0fFc473f25714978A58e14, 37121452157733735974016928914 [3.712e28]]
  • test_transfer_revertOrder_executorPolicy_beats_senderPolicy(address,address,uint256): Error != expected error: PolicyForbids(0xb81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5, 72057594037927937 [7.205e16]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xe9945cbb00000000000000000000000007d0ad8acd8322d80d8633c8217cffacfb33fe470000000000000000000000001400c54cc9101a08fcdcd081ecc5463ab23eb2ac0000000000000000000000000000000000004a3df29752a8cb4abff5c1da3531 args=[0x07D0AD8AcD8322d80D8633c8217CffACFb33fe47, 0x1400c54Cc9101a08fCdCD081eCC5463ab23EB2Ac, 1505806306894117016612433882133809 [1.505e33]]
  • test_transfer_revert_executorPolicyForbids(address,address,uint256): Error != expected error: InsufficientBalance(0x953c8d2Ff0A8a3feE5171F55c8B4B67B4e1a6cD5, 0, 111914425524794 [1.119e14]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xe8de369d000000000000000000000000953c8d2ff0a8a3fee5171f55c8b4b67b4e1a6cd50000000000000000000000004aac2401447e3ca7702c342ade67db7582537604000000000000000000000000000000000000000000000000000065c91b94223a args=[0x953c8d2Ff0A8a3feE5171F55c8B4B67B4e1a6cD5, 0x4aac2401447e3Ca7702c342ADE67db7582537604, 111914425524794 [1.119e14]]
  • test_transfer_revert_externalExecutorPolicyDenies(address,address,uint256): Error != expected error: InsufficientBalance(0x3107D42FBadE0c8B268Dd8AA65A58B151A7cCc2e, 0, 109492801521037076171545623 [1.094e26]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927938 [7.205e16]); counterexample: calldata=0x66ef86710000000000000000000000003107d42fbade0c8b268dd8aa65a58b151a7ccc2e000000000000000000000000c82091d0368f69702dd0f4c52abccd39bb66b41f0000000000000000000000000000000000000000005a9200a9fdfa09f8675c17 args=[0x3107D42FBadE0c8B268Dd8AA65A58B151A7cCc2e, 0xC82091d0368F69702dD0F4C52aBcCd39Bb66B41F, 109492801521037076171545623 [1.094e26]]

@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
Comment thread test/lib/mocks/MockB20.sol
@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>
Comment thread changelog/03_Denim_B20_transfer_executor_enforcement.md Outdated
Comment thread changelog/03_Denim_B20_transfer_executor_enforcement.md Outdated
Comment thread changelog/03_Denim_B20_transfer_executor_enforcement.md
Comment thread changelog/03_Denim_B20_transfer_executor_enforcement.md
Comment thread test/lib/mocks/MockB20.sol Outdated
rayyan224 and others added 2 commits September 14, 2026 11:00
Call out the breaking change and transfer-agent use case, add a Gas
section, and document why pause/zero-actor/allowance stay in the
entrypoints. Cache unpacked policy IDs in `_transfer`.

Co-authored-by: Cursor <cursoragent@cursor.com>
Skip the second registry call when both scopes share a policy ID and
from == msg.sender. A spender still gets a distinct sender check.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread test/lib/mocks/MockB20.sol Outdated
Comment thread test/lib/mocks/MockB20.sol Outdated
Comment thread test/lib/mocks/MockB20.sol Outdated
Rename skipSenderPolicyCheck, say "compiles this to" instead of
"emits", and move executor allowlist rationale onto IB20 transfer docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
@rayyan224
rayyan224 merged commit 253bb15 into main Sep 14, 2026
10 checks passed
@rayyan224
rayyan224 deleted the feat/policy-transfer-executor-enforcement branch September 14, 2026 18:54
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.

3 participants