Skip to content

Commit e95e1e3

Browse files
ilikesymmetryclaude
andcommitted
fix(b20): consume allowance on privileged transferFrom (L-04)
Audit finding L-04 (BOP-230): allowance deduction in privileged transferFrom diverged between the Rust precompile and the Solidity reference. The Rust precompile consumes the spender's allowance on every transferFrom — there is no `privileged` exception for allowance accounting; a privileged caller only bypasses the executor-policy check (an infinite allowance is never decremented). The Solidity reference instead skipped the entire allowance block during the factory bootstrap window, neither checking nor decrementing. Align the Solidity reference to the Rust behavior: consume allowance unconditionally, gating only the executor-policy check on the bootstrap window. - transferFrom / transferFromWithMemo: call _consumeAllowance unconditionally; keep the executor-policy check behind `!_isPrivileged() && msg.sender != from` - Correct the _transfer natspec to reflect that allowance consumption is unconditional and only the executor-policy check honors the bypass - Add fuzz regression tests (transferFrom + transferFromWithMemo): a privileged call decrements allowance by the spent amount, and still reverts InsufficientAllowance when the allowance is insufficient Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 633d98d commit e95e1e3

3 files changed

Lines changed: 198 additions & 25 deletions

File tree

test/lib/mocks/MockB20.sol

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -186,20 +186,21 @@ abstract contract MockB20 is IB20 {
186186
returns (bool)
187187
{
188188
_requireNonZeroActors(from, to);
189-
if (!_isPrivileged()) {
190-
// Allowance is consumed unconditionally outside the factory
191-
// bootstrap window. Matches OZ ERC20 and the Rust precompile,
192-
// both of which carve no exception for `msg.sender == from`.
193-
_consumeAllowance(from, msg.sender, amount);
194-
if (msg.sender != from) {
195-
// Read the executor policy ID out of the transfer-side packed
196-
// slot. Cold here; warm by the time _transfer reads the same
197-
// slot for sender + receiver. Skipped when the caller is the
198-
// owner — sender-policy already covers `from` inside _transfer.
199-
uint64 executorPolicyId = MockB20Storage.layout().transferPolicyIds.executor;
200-
if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(executorPolicyId, msg.sender)) {
201-
revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorPolicyId);
202-
}
189+
// Allowance is consumed unconditionally — including during the factory
190+
// bootstrap window (`_isPrivileged()`). Matches the Rust precompile,
191+
// which carves no `privileged` exception for allowance accounting
192+
// (BOP-230 / L-04); only the executor-policy check below is bypassed
193+
// for a privileged caller. An infinite allowance is still not
194+
// decremented (handled inside `_consumeAllowance`).
195+
_consumeAllowance(from, msg.sender, amount);
196+
if (!_isPrivileged() && msg.sender != from) {
197+
// Read the executor policy ID out of the transfer-side packed
198+
// slot. Cold here; warm by the time _transfer reads the same
199+
// slot for sender + receiver. Skipped when the caller is the
200+
// owner — sender-policy already covers `from` inside _transfer.
201+
uint64 executorPolicyId = MockB20Storage.layout().transferPolicyIds.executor;
202+
if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(executorPolicyId, msg.sender)) {
203+
revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorPolicyId);
203204
}
204205
}
205206
_transfer(from, to, amount);
@@ -235,13 +236,15 @@ abstract contract MockB20 is IB20 {
235236
returns (bool)
236237
{
237238
_requireNonZeroActors(from, to);
238-
if (!_isPrivileged()) {
239-
_consumeAllowance(from, msg.sender, amount);
240-
if (msg.sender != from) {
241-
uint64 executorPolicyId = MockB20Storage.layout().transferPolicyIds.executor;
242-
if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(executorPolicyId, msg.sender)) {
243-
revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorPolicyId);
244-
}
239+
// Allowance is consumed unconditionally — including during the factory
240+
// bootstrap window — matching the Rust precompile (BOP-230 / L-04).
241+
// Only the executor-policy check below is bypassed for a privileged
242+
// caller; infinite allowance is still not decremented.
243+
_consumeAllowance(from, msg.sender, amount);
244+
if (!_isPrivileged() && msg.sender != from) {
245+
uint64 executorPolicyId = MockB20Storage.layout().transferPolicyIds.executor;
246+
if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(executorPolicyId, msg.sender)) {
247+
revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorPolicyId);
245248
}
246249
}
247250
_transfer(from, to, amount);
@@ -702,10 +705,12 @@ abstract contract MockB20 is IB20 {
702705
/// every external caller (`transfer`, `transferFrom`,
703706
/// `transferWithMemo`, `transferFromWithMemo`) before reaching
704707
/// this helper. `transferFrom` / `transferFromWithMemo`
705-
/// additionally consume allowance and check the executor
706-
/// policy in their bodies before calling here; both of those
707-
/// checks ALSO honor the bootstrap bypass, consistent with
708-
/// the policy bypass below.
708+
/// additionally consume the allowance (unconditionally —
709+
/// including in the bootstrap window, matching the Rust
710+
/// precompile, see BOP-230 / L-04) and check the executor
711+
/// policy in their bodies before calling here; only the
712+
/// executor-policy check honors the bootstrap bypass,
713+
/// consistent with the sender/receiver policy bypass below.
709714
function _transfer(address from, address to, uint256 amount) internal {
710715
if (!_isPrivileged()) {
711716
// One SLOAD pulls both policy IDs we need for the transfer

test/unit/B20/erc20/transferFrom.t.sol

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,91 @@ contract B20TransferFromTest is B20Test {
364364

365365
assertEq(token.balanceOf(to), amount, "transfer must succeed despite blocked executor policy");
366366
}
367+
368+
// ============================================================
369+
// REGRESSION: PRIVILEGED BOOTSTRAP ALLOWANCE (BOP-230 / L-04)
370+
// ============================================================
371+
//
372+
// A privileged transferFrom (factory caller during the bootstrap
373+
// window) consumes allowance exactly like an ordinary transferFrom:
374+
// the allowance is both CHECKED and DECREMENTED. The Rust precompile
375+
// carves no `privileged` exception for allowance accounting — only the
376+
// executor-policy check is bypassed for a privileged caller. Before
377+
// BOP-230 the Solidity reference skipped the entire allowance block
378+
// during the window (neither checking nor decrementing); these tests
379+
// pin the corrected, Rust-aligned behavior.
380+
//
381+
// To enter the window: set allowance/balance while still initialized,
382+
// then reopen the bootstrap window via vm.store on the initialized
383+
// slot, then call as the factory. The privileged spender is the
384+
// factory address.
385+
386+
/// @notice Verifies a privileged transferFrom reverts InsufficientAllowance when allowance is below the spend
387+
/// @dev Pins that the allowance check is unconditional — a privileged caller is still
388+
/// rejected for insufficient allowance; before BOP-230 the privileged path skipped
389+
/// the check entirely. Regression: BOP-230 / L-04.
390+
function test_transferFrom_revert_privileged_insufficientAllowance(
391+
address from,
392+
address to,
393+
uint256 allowanceAmount,
394+
uint256 spendAmount
395+
) public {
396+
_assumeValidActor(from);
397+
_assumeValidActor(to);
398+
allowanceAmount = bound(allowanceAmount, 0, type(uint128).max - 1);
399+
spendAmount = bound(spendAmount, allowanceAmount + 1, type(uint128).max);
400+
401+
_mint(from, spendAmount);
402+
vm.prank(from);
403+
token.approve(address(factory), allowanceAmount);
404+
405+
// Reopen the factory bootstrap window so the factory caller is privileged.
406+
vm.store(address(token), MockB20Storage.initializedSlot(), bytes32(0));
407+
408+
vm.prank(address(factory));
409+
vm.expectRevert(
410+
abi.encodeWithSelector(IB20.InsufficientAllowance.selector, address(factory), allowanceAmount, spendAmount)
411+
);
412+
token.transferFrom(from, to, spendAmount);
413+
}
414+
415+
/// @notice Verifies a privileged transferFrom decrements allowance by the spent amount
416+
/// @dev Allowance is consumed during the bootstrap window exactly as outside it, matching
417+
/// the Rust precompile. Before BOP-230 the privileged path left the allowance
418+
/// untouched. Regression: BOP-230 / L-04.
419+
function test_transferFrom_success_privileged_decrementsAllowance(
420+
address from,
421+
address to,
422+
uint256 allowanceAmount,
423+
uint256 spendAmount
424+
) public {
425+
_assumeValidActor(from);
426+
_assumeValidActor(to);
427+
vm.assume(from != to);
428+
allowanceAmount = bound(allowanceAmount, 1, type(uint128).max);
429+
vm.assume(allowanceAmount != type(uint256).max);
430+
spendAmount = bound(spendAmount, 0, allowanceAmount);
431+
432+
_mint(from, spendAmount);
433+
vm.prank(from);
434+
token.approve(address(factory), allowanceAmount);
435+
436+
// Reopen the factory bootstrap window so the factory caller is privileged.
437+
vm.store(address(token), MockB20Storage.initializedSlot(), bytes32(0));
438+
439+
vm.prank(address(factory));
440+
token.transferFrom(from, to, spendAmount);
441+
442+
assertEq(
443+
token.allowance(from, address(factory)),
444+
allowanceAmount - spendAmount,
445+
"privileged transferFrom must decrement allowance by the spent amount"
446+
);
447+
assertEq(token.balanceOf(to), spendAmount, "to must receive the spent amount");
448+
assertEq(
449+
uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, address(factory)))),
450+
allowanceAmount - spendAmount,
451+
"allowances[from][factory] slot must reflect the consumed amount"
452+
);
453+
}
367454
}

test/unit/B20/memo/transferFromWithMemo.t.sol

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,85 @@ contract B20TransferFromWithMemoTest is B20Test {
177177
);
178178
assertEq(token.balanceOf(to), amount, "to must receive the transferred amount");
179179
}
180+
181+
// ============================================================
182+
// REGRESSION: PRIVILEGED BOOTSTRAP ALLOWANCE (BOP-230 / L-04)
183+
// ============================================================
184+
//
185+
// Mirrors the transferFrom regression: a privileged transferFromWithMemo
186+
// (factory caller during the bootstrap window) consumes allowance exactly
187+
// like an ordinary call — both checked and decremented — while only the
188+
// executor-policy check stays bypassed, matching the Rust precompile. See
189+
// transferFrom.t.sol for the canonical discussion.
190+
191+
/// @notice Verifies a privileged transferFromWithMemo reverts InsufficientAllowance when allowance is below the spend
192+
/// @dev Pins that the allowance check is unconditional — a privileged caller is still
193+
/// rejected for insufficient allowance; before BOP-230 the privileged path skipped
194+
/// the check entirely. Regression: BOP-230 / L-04.
195+
function test_transferFromWithMemo_revert_privileged_insufficientAllowance(
196+
address from,
197+
address to,
198+
uint256 allowanceAmount,
199+
uint256 spendAmount,
200+
bytes32 memo
201+
) public {
202+
_assumeValidActor(from);
203+
_assumeValidActor(to);
204+
allowanceAmount = bound(allowanceAmount, 0, type(uint128).max - 1);
205+
spendAmount = bound(spendAmount, allowanceAmount + 1, type(uint128).max);
206+
207+
_mint(from, spendAmount);
208+
vm.prank(from);
209+
token.approve(address(factory), allowanceAmount);
210+
211+
// Reopen the factory bootstrap window so the factory caller is privileged.
212+
vm.store(address(token), MockB20Storage.initializedSlot(), bytes32(0));
213+
214+
vm.prank(address(factory));
215+
vm.expectRevert(
216+
abi.encodeWithSelector(IB20.InsufficientAllowance.selector, address(factory), allowanceAmount, spendAmount)
217+
);
218+
token.transferFromWithMemo(from, to, spendAmount, memo);
219+
}
220+
221+
/// @notice Verifies a privileged transferFromWithMemo decrements allowance by the spent amount
222+
/// @dev Allowance is consumed during the bootstrap window exactly as outside it, matching
223+
/// the Rust precompile. Before BOP-230 the privileged path left the allowance
224+
/// untouched. Regression: BOP-230 / L-04.
225+
function test_transferFromWithMemo_success_privileged_decrementsAllowance(
226+
address from,
227+
address to,
228+
uint256 allowanceAmount,
229+
uint256 spendAmount,
230+
bytes32 memo
231+
) public {
232+
_assumeValidActor(from);
233+
_assumeValidActor(to);
234+
vm.assume(from != to);
235+
allowanceAmount = bound(allowanceAmount, 1, type(uint128).max);
236+
vm.assume(allowanceAmount != type(uint256).max);
237+
spendAmount = bound(spendAmount, 0, allowanceAmount);
238+
239+
_mint(from, spendAmount);
240+
vm.prank(from);
241+
token.approve(address(factory), allowanceAmount);
242+
243+
// Reopen the factory bootstrap window so the factory caller is privileged.
244+
vm.store(address(token), MockB20Storage.initializedSlot(), bytes32(0));
245+
246+
vm.prank(address(factory));
247+
token.transferFromWithMemo(from, to, spendAmount, memo);
248+
249+
assertEq(
250+
token.allowance(from, address(factory)),
251+
allowanceAmount - spendAmount,
252+
"privileged transferFromWithMemo must decrement allowance by the spent amount"
253+
);
254+
assertEq(token.balanceOf(to), spendAmount, "to must receive the spent amount");
255+
assertEq(
256+
uint256(vm.load(address(token), MockB20Storage.allowanceSlot(from, address(factory)))),
257+
allowanceAmount - spendAmount,
258+
"allowances[from][factory] slot must reflect the consumed amount"
259+
);
260+
}
180261
}

0 commit comments

Comments
 (0)