Skip to content

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

Open
rayyan224 wants to merge 1 commit into
mainfrom
feat/policy-transfer-executor-enforcement
Open

feat(policy): enforce TRANSFER_EXECUTOR_POLICY on every transfer path#224
rayyan224 wants to merge 1 commit 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=0x641b204b00000000000000000000000006a17337c13c6101173e30c273ea65d95c4917b60000000000000000000000009dc98663317a570d996ee83f695576446bb9e4c3000000000000000000000000000000000000000001e5b19484c83c6d570cc435 args=[0x06a17337C13c6101173E30C273EA65d95C4917B6, 0x9dc98663317A570D996EE83f695576446bb9E4C3, 587167621066649834607133749 [5.871e26]]
  • test_transferWithMemo_revertOrder(address,address,uint256,bytes32): Error != expected error: PolicyForbids(0xb81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5, 72057594037927937 [7.205e16]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xf5e31125000000000000000000000000a22f32796692b9f14ff9fb1ed46b65ec62b1163700000000000000000000000035208c88a4cff83ab44939268daddf3c787fe7a5000000000000000000000000000000000000000000000000000178955ebf3183ba700ca84d0751bce97184ba1436a97b70a196c4b9f381978d40eac29a8ce9c2 args=[0xa22F32796692b9F14FF9FB1Ed46b65eC62b11637, 0x35208C88a4cFF83aB44939268daddF3C787fE7A5, 414057911759235 [4.14e14], 0xba700ca84d0751bce97184ba1436a97b70a196c4b9f381978d40eac29a8ce9c2]
  • test_transferWithMemo_revert_executorPolicyForbids(address,address,uint256,bytes32): Error != expected error: InsufficientBalance(0xdFEb83394946DF80873444A75f44efACE026Df64, 0, 238212217285884894929357986813646299511979848 [2.382e44]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xa3802c17000000000000000000000000dfeb83394946df80873444a75f44eface026df64000000000000000000000000969e3d7f3bac7de71f55047a386aa2f41ee22aa4000000000000000000000000000aae8aca0ef9f839f8cc394f3e80803c80ff483a30fa131d26dc7bc158ce489d1788965ca1685b8cdb9a6f58b16302b1c7fa37 args=[0xdFEb83394946DF80873444A75f44efACE026Df64, 0x969e3d7F3BAc7de71F55047a386aa2F41ee22aA4, 238212217285884894929357986813646299511979848 [2.382e44], 0x3a30fa131d26dc7bc158ce489d1788965ca1685b8cdb9a6f58b16302b1c7fa37]
  • test_transfer_revertOrder_executorPolicy_beats_balance(address,address,uint256): Error != expected error: InsufficientBalance(0x76e5E10C4A5CDB2655B95d35bcd9357C19c06a26, 0, 220805966067977251936758552293812526031 [2.208e38]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0x76b31f6e00000000000000000000000076e5e10c4a5cdb2655b95d35bcd9357c19c06a260000000000000000000000009a0e39e4fa6490cd6b071b7f624ccda7feb0212d00000000000000d49c8a230c36c15556a61daea4545076b0465d8b0f8eff8679 args=[0x76e5E10C4A5CDB2655B95d35bcd9357C19c06a26, 0x9a0E39E4Fa6490Cd6B071B7F624CCdA7FEb0212D, 1334583907658922835927391312231826498715135846032854191408761 [1.334e60]]
  • test_transfer_revertOrder_executorPolicy_beats_receiverPolicy(address,address,uint256): Error != expected error: PolicyForbids(0x8a4b3fa2d8b921852bc0089c6ef0958aa6961897be36fd731330fe2cd23f8363, 72057594037927937 [7.205e16]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0x67f822c500000000000000000000000075a83df7f545d6125f0db4cf7564f8cd7aceaa22000000000000000000000000ca926997cfdd52fdaf3edd32a1efc5a1462c327a0000000000000000000000000000000000000009a4ef375f1568a2afdd16cc8a args=[0x75a83df7F545d6125f0Db4Cf7564f8Cd7AceaA22, 0xcA926997CFDd52fDaF3EDd32A1eFC5A1462C327A, 764098198994107432566995209354 [7.64e29]]
  • test_transfer_revertOrder_executorPolicy_beats_senderPolicy(address,address,uint256): Error != expected error: PolicyForbids(0xb81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5, 72057594037927937 [7.205e16]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xe9945cbb000000000000000000000000464840f78168518fd99f7881d6b8a2e90cdd69b2000000000000000000000000225dffb746e3329b91ca242d167bb4014b0c4de40000000000000000000000000000000000000000000000000581e0faab862ee2 args=[0x464840f78168518fd99f7881d6B8a2E90CDd69b2, 0x225DfFb746E3329b91Ca242d167bb4014b0c4de4, 396845609409457890 [3.968e17]]
  • test_transfer_revert_executorPolicyForbids(address,address,uint256): Error != expected error: InsufficientBalance(0x9E47Bd550cBE1183e42E19Bf8ADDfeF7aE946B02, 0, 3402540785422183560712145151 [3.402e27]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927937 [7.205e16]); counterexample: calldata=0xe8de369d0000000000000000000000009e47bd550cbe1183e42e19bf8addfef7ae946b02000000000000000000000000e21ad2d412e8f126d2112983b6f0f27c72ce6a7600000000000000000000000000000000000000000afe84099385fdd99b65c8ff args=[0x9E47Bd550cBE1183e42E19Bf8ADDfeF7aE946B02, 0xe21Ad2d412E8f126d2112983B6F0f27c72CE6a76, 3402540785422183560712145151 [3.402e27]]
  • test_transfer_revert_externalExecutorPolicyDenies(address,address,uint256): Error != expected error: InsufficientBalance(0x0000000000000000000000003AB73830Bab9b2ba, 0, 66251 [6.625e4]) != PolicyForbids(0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8, 72057594037927938 [7.205e16]); counterexample: calldata=0x66ef86710000000000000000000000000000000000000000000000003ab73830bab9b2ba0000000000000000000000000000000000000000000000000000000000004a4500000000000000000000000000000000000000000000000000000000000102cb args=[0x0000000000000000000000003AB73830Bab9b2ba, 0x0000000000000000000000000000000000004a45, 66251 [6.625e4]]

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