Skip to content

refactor: order B20 checks pause → role → input - #101

Merged
stevieraykatz merged 5 commits into
mainfrom
amiecorso/bop-215-b20-mocks-reorder-cross-cutting-checks
May 30, 2026
Merged

stevieraykatz merged 5 commits into
mainfrom
amiecorso/bop-215-b20-mocks-reorder-cross-cutting-checks

Conversation

@amiecorso

@amiecorso amiecorso commented May 29, 2026 •

Copy link
Copy Markdown
Collaborator

Refactor MockB20 / MockB20Security so every mutating entrypoint
evaluates cross-cutting gates in the canonical order
pause → role → input → allowance → policy → invariants → effects,
expressed as modifiers on the external surface. Helpers (_transfer,
_mint, _burnRaw) reduce to pure mechanics; _burnSelf is folded
away.

Aligns mock ↔ Rust precompile parity for which error surfaces when
multiple preconditions fail at once (notably transferFrom, where
allowance + executor-policy used to fire before pause + input).

Linear

BOP-215

What changed

MockB20.sol — one new modifier:

  • whenNotPaused(feature) — always-strict pause check (no factory
    bypass, see "Bonus" below)

Entrypoints declare whenNotPaused + onlyRole modifiers in
canonical order; zero-address / empty-feature checks are inlined at
each entrypoint (or via the _requireNonZeroActors(from, to) helper
for the four transfer-family entrypoints, which share both checks).
Internal helpers are pure mechanics:

  • _transfer → policy + balance + effects
  • _mint → policy + supply cap + effects
  • _burnRaw → balance + effects
  • _burnSelf removed (folded into burn / burnWithMemo direct
    calls to _burnRaw)

MockB20Security.sol — batchMint adds entrypoint
whenNotPaused(MINT) onlyRole(MINT_ROLE) modifiers (previously
inherited from per-element _mint) and inlines per-element receiver
check in the loop. batchBurn adds whenNotPaused(BURN) to its
modifier set. redeem / redeemWithMemo carry whenNotPaused(REDEEM)
on the entrypoint; _redeemBurn body strips its inline pause check.

Interface natspec — IB20.sol and IB20Security.sol rewrite the
revert-order lists for transfer, transferFrom, approve, mint,
burn, burnBlocked, pause, unpause, redeem, batchMint,
batchBurn as explicit numbered lists matching the new order.

Bonus: pause bypass removal

The pre-refactor _transfer / _mint / _burnSelf wrapped their
pause checks in if (!_isPrivileged()) { ... } — i.e. pause honored
the factory bootstrap bypass alongside role and policy. After
discussion, this PR removes that bypass: pause now always fires,
even from the factory during the bootstrap window.

Rationale: role and policy bypass have real bootstrap motivations
(the factory needs to act before having granted itself any roles; the
factory needs to seed initial supply before compliance policies are
configured). Pause has no equivalent motivation — pause defaults to
"nothing paused" at creation, and any pause state during bootstrap is
explicitly opted into by the operator's initCalls. There's no
coherent flow where the factory pauses a feature in initCall N and
then needs to use it in initCall N+1; start-paused configurations
should sequence the pause(...) call last. The same "no init-time
use case → bypass widens attack surface without buying anything"
rationale already documented for redeem / batchBurn applies to
pause generally.

All 592 tests still pass after the change — no test exercised the
factory-bootstrap-with-pause-bypass path, which is strong evidence
the bypass was unintentional / unused in practice.

Implications:

  • whenNotPaused is now always strict; whenNotPausedStrict (which
    this PR briefly introduced for the redeem family) is deleted as
    redundant.
  • The Rust precompile needs the matching change. Tracked under
    BOP-217.

Tests

All 592 unit tests pass. The 6 *_revertOrder.t.sol files are
updated for the new precedence (renamed pairs, flipped assertions,
canonical-order docstrings). Two happy-path tests in batchMint.t.sol
now grant MINT_ROLE to the caller before triggering
LengthMismatch / EmptyBatch (the role check now fires first via
the entrypoint modifier).

Ran 124 test suites: 592 tests passed, 0 failed, 0 skipped

forge build clean. forge fmt --check clean on all files this PR
touches. (One pre-existing fmt drift in test/lib/B20FactoryLibTest.sol
exists on main — unrelated and not touched here.)

Out of band — Rust precompile parity

The production Rust precompile (separate repo) must be reordered
identically and parity verified on multi-failure inputs, especially
transferFrom. The pause-bypass removal also needs to land Rust-side.
Both tracked under BOP-217.

@linear

linear Bot commented May 29, 2026

Copy link
Copy Markdown

BOP-215

amiecorso added 4 commits May 29, 2026 14:40
Hoist cross-cutting gates from shared internal helpers to external
entrypoints as modifiers, so every mutating op evaluates its
preconditions in the canonical order:

    pause → role → input → allowance → policy → invariants → effects

Modifier list order equals evaluation order (Solidity runs modifiers
left-to-right, body at `_`), so each function's modifier set reads
top-to-bottom as its revert precedence.

MockB20
- New modifiers: `whenNotPaused` (bypass-aware), `validReceiver`,
  `validSender`, `validApprover`, `validSpender`, `nonEmptyFeatures`.
- Internal helpers reduced to pure mechanics: `_transfer`
  (policy + balance + effects), `_mint` (policy + supply-cap + effects),
  `_burnRaw` (balance + effects). `_burnSelf` folded away.

MockB20Security
- New `whenNotPausedStrict` modifier mirrors `onlyRoleStrict`: no
  factory-bootstrap bypass, used by the holder-initiated `redeem` /
  `redeemWithMemo` path.
- `batchMint` declares `whenNotPaused(MINT) + onlyRole(MINT_ROLE)`
  itself (was inherited per-element via `_mint`); per-element
  `validReceiver` inlined in the loop since modifier params evaluate
  once at entry.
- `batchBurn` swaps inline pause check for the `whenNotPaused(BURN)`
  modifier; role-strict semantics preserved.
- `_redeemBurn` body strips its inline pause check.

Interface natspec
- `IB20` / `IB20Security` revert-order lists rewritten as explicit
  numbered lists in the new canonical order for `transfer`,
  `transferFrom`, `approve`, `mint`, `burn`, `burnBlocked`, `pause`,
  `unpause`, `redeem`, `batchMint`, `batchBurn`.

Tests
- Revert-order tests under
  `test/unit/B20/{erc20,supply}/*_revertOrder.t.sol` and
  `test/unit/B20Security/batch/*_revertOrder.t.sol` updated to assert
  the new precedence (canonical-order docstrings rewritten; pair
  tests renamed and flipped where precedence reversed).
- Two happy-path tests in
  `test/unit/B20Security/batch/batchMint.t.sol` now grant MINT_ROLE
  before triggering EmptyBatch / LengthMismatch since the role gate
  now fires before body checks.
- forge build clean. forge test: 592 / 592 passing.

Out of band: the production Rust precompile (separate repo) must be
reordered identically and mock ↔ Rust parity verified on
multi-failure inputs, especially `transferFrom`. Coordinate with the
Rust owner.

Refs: BOP-215
Per review feedback: `validReceiver` / `validSender` / `validApprover` /
`validSpender` / `nonEmptyFeatures` were misleading (the same address
also has policy checks fired against it elsewhere, so a name like
`validReceiver` implies completeness it doesn't have). Removed.

Kept: `whenNotPaused` and `whenNotPausedStrict` (the pause gate is
the only new modifier this PR introduces).

Inlined zero-address / empty-feature checks at each entrypoint;
revert order is unchanged. Tightened docstrings in mocks + tests to
drop references to the removed modifiers.
Replaces the inline `InvalidReceiver` + `InvalidSender` checks in
`transfer` / `transferFrom` / `transferWithMemo` / `transferFromWithMemo`
with a single shared helper. Order preserved (receiver first, then
sender). Mint paths keep the inline `InvalidReceiver` check — only
one line per site.
Pause now always fires, even from the factory during the bootstrap
window. Unlike role + policy bypass (which have real bootstrap
motivations — factory needs to act before having roles, factory needs
to seed initial supply before policies are configured), pause has no
equivalent need: pause defaults to 'nothing paused' at creation, and
any pause state during bootstrap is explicitly opted into by the
operator's initCalls. Start-paused configurations must sequence the
`pause(...)` call last.

- `whenNotPaused` modifier drops its `_isPrivileged()` wrapper.
- `whenNotPausedStrict` deleted (now redundant — `whenNotPaused` is
  always strict).
- `redeem` / `redeemWithMemo` switched from
  `whenNotPausedStrict(REDEEM)` to `whenNotPaused(REDEEM)`.
- Contract-level natspec updated to call out the pause-no-bypass rule.

All 592 tests still pass — no test relied on the factory bypass for
pause, which is strong evidence the bypass was unintentional /
unused. Rust precompile gets the matching change via BOP-217.
@amiecorso
amiecorso force-pushed the amiecorso/bop-215-b20-mocks-reorder-cross-cutting-checks branch from 24b16c8 to 0741474 Compare May 29, 2026 21:40
@amiecorso amiecorso changed the title refactor: order B20 checks pause → role → input via modifiers refactor: order B20 checks pause → role → input May 29, 2026
@github-actions

github-actions Bot commented May 29, 2026 •

Copy link
Copy Markdown

⚠️ Fork tests: 50 failed, 580 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_batchBurn_revertOrder_pause_beats_role(address,uint256): Error != expected error: AccessControlUnauthorizedAccount(0x393437F8246529ddD67D3275563c464A69107E16, 0x25400dba76bf0d00acf274c2b61ff56aa4ed19826e21e0186e3fecd6a6671875) != ContractPaused(2); counterexample: calldata=0x6f32692d000000000000000000000000393437f8246529ddd67d3275563c464a69107e16000000000000000000000000000000000000000000000005b79e39e72394b476 args=[0x393437F8246529ddD67D3275563c464A69107E16, 105464796788706030710 [1.054e20]]
  • test_batchMint_revertOrder_pause_beats_role(address): Error != expected error: LengthMismatch(2, 1) != ContractPaused(1); counterexample: calldata=0x4ea977520000000000000000000000000000000000000000000000000000000000002933 args=[0x0000000000000000000000000000000000002933]
  • test_batchMint_revertOrder_role_beats_emptyBatch(address): Error != expected error: EmptyBatch() != AccessControlUnauthorizedAccount(0x643d7E4198fe2f1fB9269E2b272A576553Ca0253, 0x154c00819833dac601ee5ddded6fda79d9d8b506b911b3dbd54cdb95fe6c3686); counterexample: calldata=0x84ae4e0c000000000000000000000000643d7e4198fe2f1fb9269e2b272a576553ca0253 args=[0x643d7E4198fe2f1fB9269E2b272A576553Ca0253]
  • test_batchMint_revertOrder_role_beats_lengthMismatch(address): Error != expected error: LengthMismatch(2, 1) != AccessControlUnauthorizedAccount(0xcbB9D8D9fC49487eB1472efc6A61d553130Dbf90, 0x154c00819833dac601ee5ddded6fda79d9d8b506b911b3dbd54cdb95fe6c3686); counterexample: calldata=0xf78f2a15000000000000000000000000cbb9d8d9fc49487eb1472efc6a61d553130dbf90 args=[0xcbB9D8D9fC49487eB1472efc6A61d553130Dbf90]
  • test_burnBlocked_revertOrder_pause_beats_role(address,address,uint256): Error != expected error: AccessControlUnauthorizedAccount(0xF6143f3dCaF74d4a72255fD743aA77caa88c900e, 0x7408fdc0d31c7bcb349eab611f5d1168acd4303574993f8cdc98b1cd18c41cae) != ContractPaused(2); counterexample: calldata=0x022471a5000000000000000000000000f6143f3dcaf74d4a72255fd743aa77caa88c900e0000000000000000000000007f2d81212ab72efe1f09b36b3da98520d94784b900000000000000000000000000000000000000000000000032d25b3ee45b54fd args=[0xF6143f3dCaF74d4a72255fD743aA77caa88c900e, 0x7f2d81212AB72Efe1F09B36B3dA98520D94784b9, 3662089772682925309 [3.662e18]]
  • test_burn_revertOrder_pause_beats_role(address,uint256): Error != expected error: AccessControlUnauthorizedAccount(0xF4d66F68AFcdCE1350fe1F66D54C81C5cbEF08F5, 0xe97b137254058bd94f28d2f3eb79e2d34074ffb488d042e3bc958e0a57d2fa22) != ContractPaused(2); counterexample: calldata=0xad5b1072000000000000000000000000f4d66f68afcdce1350fe1f66d54c81c5cbef08f5000000000000000000000004710b008234223e8053177062cb89d071acbd06a0 args=[0xF4d66F68AFcdCE1350fe1F66D54C81C5cbEF08F5, 6491367858929894737179963361792287739735273440928 [6.491e48]]
  • test_createB20_revert_missingIsin(address,bytes32): Error != expected error: custom error 0xff5f85a2 != MissingRequiredField("isin"); counterexample: calldata=0x108bedc0000000000000000000000000cb000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015040 args=[0xCB00000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000015040]
  • test_createB20_success_b20CreatedVariantParams_stablecoin_decodes(address,bytes32,uint256): STABLECOIN variantEventParams must be non-empty: 0 <= 0; counterexample: calldata=0xd98dd665000000000000000000000000000000000000000000000000000000000000221b18a238c1bf6a9326e4ed719bacd671faa7ecfe3d8a049d1b95d3f583f2d239f20000000000000000000000000000000000000000000000006124fee993bc0000 args=[0x000000000000000000000000000000000000221B, 0x18a238c1bf6a9326e4ed719bacd671faa7ecfe3d8a049d1b95d3f583f2d239f2, 7000000000000000000 [7e18]]
  • test_createB20_success_emitsB20Created(address,bytes32): log != expected log; counterexample: calldata=0xc85a7045000000000000000000000000d84a9e4798c26df45597c8aadedfbbbdc0351f8e4e3ba2d990cbf80dd3e37d42283cbce21e09290db917badabac39a8b86e62539 args=[0xD84a9e4798c26dF45597C8aadEDfBbBdC0351F8E, 0x4e3ba2d990cbf80dd3e37d42283cbce21e09290db917badabac39a8b86e62539]
  • test_createB20_success_emitsB20CreatedBeforeInitCallEvents(address,bytes32): B20Created must be present in the log: -1 <= -1; counterexample: calldata=0xe87c62f00000000000000000000000006ec38302eb772f2c0c5512190b2637359d3fef129d06dd50c56bd163cfe419699c38e47d6a7c4f45bac6a6cf0a3e217e0211f84e args=[0x6ec38302Eb772f2c0c5512190b2637359d3FeF12, 0x9d06dd50c56bd163cfe419699c38e47d6a7c4f45bac6a6cf0a3e217e0211f84e]
  • test_createB20_success_emitsB20Created_security(address,bytes32): log != expected log; counterexample: calldata=0xf32acc570000000000000000000000000bb44272ece87b784587017ddee8ef81f997c38ca37fb726b19b741839eca8c29b116dd803e14c4cc6c5dda470d560412b371913 args=[0x0Bb44272eCe87b784587017dDEe8eF81F997c38c, 0xa37fb726b19b741839eca8c29b116dd803e14c4cc6c5dda470d560412b371913]
  • test_createB20_success_emitsB20Created_stablecoin(address,bytes32): log != expected log; counterexample: calldata=0xa0352f05000000000000000000000000000000000000000000000000000000000000339b00000000000000000000000000000000000000000000000000000000e87c62ef args=[0x000000000000000000000000000000000000339b, 0x00000000000000000000000000000000000000000000000000000000e87c62ef]
  • test_mint_revertOrder_pause_beats_role(address,address,uint256): Error != expected error: AccessControlUnauthorizedAccount(0xAd4983fab683BdEB37DFA7a054D7bEBE4FA8BBd4, 0x154c00819833dac601ee5ddded6fda79d9d8b506b911b3dbd54cdb95fe6c3686) != ContractPaused(1); counterexample: calldata=0x7ce6a250000000000000000000000000ad4983fab683bdeb37dfa7a054d7bebe4fa8bbd4000000000000000000000000160e30468afc32abcb6057435d4bc53c5e678a5200000000000000000000000000000000000000000000000000091fd2890111f4 args=[0xAd4983fab683BdEB37DFA7a054D7bEBE4FA8BBd4, 0x160E30468AFC32ABCb6057435d4Bc53c5e678A52, 2568263892537844 [2.568e15]]
  • test_mint_revertOrder_pause_beats_zeroRecipient(uint256): Error != expected error: InvalidReceiver(0x0000000000000000000000000000000000000000) != ContractPaused(1); counterexample: calldata=0xd11d468400000000000007504bbc6811007db50ecb449ca64b14fc3c79a232b0afefb00f args=[11752591489051378554222805883769220748166464056536479390740495 [1.175e61]]
  • test_transferFrom_revertOrder_pause_beats_allowance(address,address,address,uint256): Error != expected error: InsufficientAllowance(0x1aB23D435FFE969BBc9cA780B1cb4F5b0F4a7c5B, 0, 128673117834528615551041313594059021495 [1.286e38]) != ContractPaused(0); counterexample: calldata=0xf23c26a30000000000000000000000001ab23d435ffe969bbc9ca780b1cb4f5b0f4a7c5b00000000000000000000000039e8b2d7ef377052d3a4884870fbfa1a217898d6000000000000000000000000ab33a4f2f25a66700c3d4ff8b9af98f03b95cf00000000000000000000033d52be01936160cd8a8366a14a45241246142ee2c956 args=[0x1aB23D435FFE969BBc9cA780B1cb4F5b0F4a7c5B, 0x39E8b2d7Ef377052d3A4884870Fbfa1a217898D6, 0xaB33a4F2f25a66700c3d4fF8b9Af98f03B95cf00, 310286651358560518774869661350160894264219353257003350 [3.102e53]]
  • test_transferFrom_revertOrder_pause_beats_executorPolicy(address,address,address,uint256): Error != expected error: InsufficientAllowance(0xA3b5d1C1374Acd98976850F4679d167A1d010A96, 0, 54689178620814620132760544077108774945 [5.468e37]) != ContractPaused(0); counterexample: calldata=0x1887e936000000000000000000000000a3b5d1c1374acd98976850f4679d167a1d010a9600000000000000000000000074e71e849bc08de47547e7024fafe416edd34cca000000000000000000000000be935469df54ec25124dc6dc34e97d6ba3ee8d880000000000000000000032e964d2a2e42924c0af3de361f97f0a0b016455e53d args=[0xA3b5d1C1374Acd98976850F4679d167A1d010A96, 0x74E71e849bC08de47547E7024faFe416edD34Cca, 0xBE935469dF54eC25124Dc6DC34E97d6bA3ee8D88, 19048326435757061198113662202722787539779071713338685 [1.904e52]]
  • test_transferFrom_revertOrder_zeroReceiver_beats_allowance(address,address,uint256): Error != expected error: InsufficientAllowance(0x763C058645d79Cfce1d0ad9E6FC760361250D83b, 0, 155875925585212039990352657645428310787 [1.558e38]) != InvalidReceiver(0x0000000000000000000000000000000000000000); counterexample: calldata=0x358c5df7000000000000000000000000763c058645d79cfce1d0ad9e6fc760361250d83b0000000000000000000000006acfa15101a7904d6a0a545e2bcc05d2dd291446000000000000000099ce12d6a0f2864d75449c7c2625daa4a2b4b690ed39fcb6 args=[0x763C058645d79Cfce1d0ad9E6FC760361250D83b, 0x6aCFA15101A7904D6A0a545e2BCC05d2Dd291446, 3771287012408086457000260587654859508685791161801967795382 [3.771e57]]
  • test_transferFrom_revertOrder_zeroReceiver_beats_executorPolicy(address,address,uint256): Error != expected error: InsufficientAllowance(0x09CEe6e806a9aA961e1C6Ea3965383191fba7d62, 0, 338) != InvalidReceiver(0x0000000000000000000000000000000000000000); counterexample: calldata=0x396a1c6500000000000000000000000009cee6e806a9aa961e1c6ea3965383191fba7d62000000000000000000000000c84d8103b58aef655bfd024729b8748aa5fd9de10000000000000000000000000000000000000000000000000000000000000152 args=[0x09CEe6e806a9aA961e1C6Ea3965383191fba7d62, 0xc84D8103B58AEf655BFd024729B8748Aa5FD9dE1, 338]
  • test_transfer_revertOrder_pause_beats_zeroReceiver(address,uint256): Error != expected error: InvalidReceiver(0x0000000000000000000000000000000000000000) != ContractPaused(0); counterexample: calldata=0x2774baaf000000000000000000000000c6fcb8f1e944688dec01aa4b8f1a48d3cb038d6700000000000000000000003a9121915522a9de24aa8900007cede8fdb7b2da2e args=[0xc6fcB8f1e944688Dec01aA4b8f1A48D3cB038D67, 85595647211804914546532433454628608550885360065070 [8.559e49]]
  • test_transfer_revertOrder_pause_beats_zeroSender(address,uint256): Error != expected error: InvalidSender(0x0000000000000000000000000000000000000000) != ContractPaused(0); counterexample: calldata=0x51f2c938000000000000000000000000c8546725b2a112240d8d7aa39e3050aa1eb38671000000000000000000000000000003d6e8d5580a3f104801d30ed78515cdee99 args=[0xc8546725b2A112240D8D7aa39e3050Aa1eB38671, 334466772956278383506797965829035994771097 [3.344e41]]
    [FAIL: Error != expected error: EmptyBatch() != ContractPaused(1)] test_batchMint_revertOrder_pause_beats_emptyBatch() (gas: 107868)
    [FAIL: Error != expected error: EmptyBatch() != ContractPaused(2)] test_batchBurn_revertOrder_pause_beats_emptyBatch() (gas: 109637)
    [FAIL: Error != expected error: LengthMismatch(2, 1) != ContractPaused(1)] test_batchMint_revertOrder_pause_beats_lengthMismatch() (gas: 114448)
    [FAIL: Error != expected error: LengthMismatch(2, 1) != ContractPaused(2)] test_batchBurn_revertOrder_pause_beats_lengthMismatch() (gas: 116196)
    [FAIL: vm.store: cannot use precompile 0x8453000000000000000000000000000000000002 as an argument] test_pendingPolicyAdmin_success_zeroForBuiltinsEvenWithStoragePoison() (gas: 5654)

@stevieraykatz
stevieraykatz merged commit 03b7d33 into main May 30, 2026
5 checks passed
@stevieraykatz
stevieraykatz deleted the amiecorso/bop-215-b20-mocks-reorder-cross-cutting-checks branch May 30, 2026 00:43
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.

2 participants