Description
With --via-ir and the optimizer both enabled, a call to OpenZeppelin's Checkpoints.upperLookupRecent reverts with Panic(0x11) (arithmetic underflow). The same source compiled with either flag alone, or with neither, returns the correct value.
The only subtraction reachable in that call is index - 1 in the final statement:
return index == 0 ? 0 : _unsafeAccess(self._checkpoints, index - 1)._value;
In the failing case index is 0, so the ternary should short-circuit and the subtraction should never be evaluated. The underflow therefore occurs on a path the source makes unreachable.
Expected: getPastVotes returns 0 for a timepoint before the account's first checkpoint. Actual: Panic(0x11).
Reproduced on 0.8.36 and 0.8.37.
Environment
- Compiler version: 0.8.37 (also reproduces on 0.8.36)
- Compilation pipeline: IR (via_ir = true); required together with optimizer = true
- Target EVM version: osaka
- Framework/IDE: Foundry 1.7.1
- EVM execution environment: revm, via forge test
- Operating system: Ubuntu on WSL2
Steps to Reproduce
Minimal reproduction: https://github.com/jorge-tb/solidity-via-ir-optimization-erc20votes-bug-repro
A minimal ERC-20 with ERC20Votes and timestamp clock mode. One mint, one self-delegation, then a getPastVotes query one second before the delegation timestamp.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {Time} from "@openzeppelin/contracts/utils/types/Time.sol";
import {ERC6372Utils} from "@openzeppelin/contracts/utils/ERC6372Utils.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract Minimal is ERC20, EIP712, ERC20Votes {
constructor() ERC20("VIA_IR", "VIR") EIP712("VIA_IR", "1") {}
function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
super._update(from, to, value);
}
function mint(uint256 amount) external {
_mint(msg.sender, amount);
}
function clock() public view override returns (uint48) {
return Time.timestamp();
}
function CLOCK_MODE() public view override returns (string memory) {
return ERC6372Utils.timestampClockMode(clock);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.36;
import {Test} from "forge-std/Test.sol";
import {Minimal} from "../src/Minimal.sol";
contract MinimalTest is Test {
Minimal minimal;
address stakeholder;
uint256 immutable totalTokens = 100;
function setUp() public {
stakeholder = makeAddr("stakeholder");
minimal = new Minimal();
vm.prank(stakeholder);
minimal.mint(totalTokens);
}
function test_GetPastVotes_ReturnsZeroBeforeDelegation() public {
uint256 delegationTimestamp = block.timestamp + 1 days;
vm.warp(delegationTimestamp);
vm.prank(stakeholder);
minimal.delegate(stakeholder);
vm.warp(delegationTimestamp + 1 days);
assertEq(minimal.getPastVotes(stakeholder, delegationTimestamp - 1), 0);
assertEq(minimal.getPastVotes(stakeholder, delegationTimestamp), totalTokens);
}
}
Result matrix
via_ir |
optimizer |
Result |
false |
false |
passes |
false |
true |
passes |
true |
false |
passes |
true |
true |
Panic(0x11) |
forge test passes with via_ir = false, and with via_ir = true and the optimizer disabled. It fails with both enabled, at every optimizer_runs value tested: 1, 200, 2000, 20000, 20000000.
Dependency: openzeppelin-contracts 5.7.0 (commit cab19933c33c2ad1d4c7a84864a3601dddfd16f3).
Description
With --via-ir and the optimizer both enabled, a call to OpenZeppelin's Checkpoints.upperLookupRecent reverts with Panic(0x11) (arithmetic underflow). The same source compiled with either flag alone, or with neither, returns the correct value.
The only subtraction reachable in that call is index - 1 in the final statement:
return index == 0 ? 0 : _unsafeAccess(self._checkpoints, index - 1)._value;In the failing case index is 0, so the ternary should short-circuit and the subtraction should never be evaluated. The underflow therefore occurs on a path the source makes unreachable.
Expected: getPastVotes returns 0 for a timepoint before the account's first checkpoint. Actual: Panic(0x11).
Reproduced on 0.8.36 and 0.8.37.
Environment
Steps to Reproduce
Minimal reproduction: https://github.com/jorge-tb/solidity-via-ir-optimization-erc20votes-bug-repro
A minimal ERC-20 with ERC20Votes and timestamp clock mode. One mint, one self-delegation, then a getPastVotes query one second before the delegation timestamp.
Result matrix
via_iroptimizerfalsefalsefalsetruetruefalsetruetruePanic(0x11)forge test passes with via_ir = false, and with via_ir = true and the optimizer disabled. It fails with both enabled, at every optimizer_runs value tested: 1, 200, 2000, 20000, 20000000.
Dependency: openzeppelin-contracts 5.7.0 (commit cab19933c33c2ad1d4c7a84864a3601dddfd16f3).