commit
551945a56e
@ -0,0 +1,5 @@ |
||||
--- |
||||
'openzeppelin-solidity': minor |
||||
--- |
||||
|
||||
`GovernorSequentialProposalId`: Adds a `Governor` extension that sequentially numbers proposal ids instead of using the hash. |
@ -0,0 +1,5 @@ |
||||
--- |
||||
'openzeppelin-solidity': minor |
||||
--- |
||||
|
||||
`Address`: bubble up revert data on `sendValue` failed call |
@ -0,0 +1,5 @@ |
||||
--- |
||||
'openzeppelin-solidity': patch |
||||
--- |
||||
|
||||
`ERC7579Utils`: Add ABI decoding checks on calldata bounds within `decodeBatch` |
@ -0,0 +1,5 @@ |
||||
--- |
||||
'openzeppelin-solidity': minor |
||||
--- |
||||
|
||||
`IGovernor`: Add the `getProposalId` function to the governor interface. |
@ -0,0 +1,5 @@ |
||||
--- |
||||
'openzeppelin-solidity': patch |
||||
--- |
||||
|
||||
`ERC4626`: Use the `asset` getter in `totalAssets`, `_deposit` and `_withdraw`. |
@ -1,8 +0,0 @@ |
||||
#!/usr/bin/env bash |
||||
|
||||
set -euo pipefail |
||||
|
||||
if [ "${CI:-"false"}" != "true" ]; then |
||||
npm run test:generation |
||||
npm run lint |
||||
fi |
@ -0,0 +1,2 @@ |
||||
npm run test:generation |
||||
npx lint-staged |
Binary file not shown.
@ -0,0 +1,76 @@ |
||||
// SPDX-License-Identifier: MIT |
||||
|
||||
pragma solidity ^0.8.20; |
||||
|
||||
import {Governor} from "../Governor.sol"; |
||||
|
||||
/** |
||||
* @dev Extension of {Governor} that changes the numbering of proposal ids from the default hash-based approach to |
||||
* sequential ids. |
||||
*/ |
||||
abstract contract GovernorSequentialProposalId is Governor { |
||||
uint256 private _latestProposalId; |
||||
mapping(uint256 proposalHash => uint256 proposalId) private _proposalIds; |
||||
|
||||
/** |
||||
* @dev The {latestProposalId} may only be initialized if it hasn't been set yet |
||||
* (through initialization or the creation of a proposal). |
||||
*/ |
||||
error GovernorAlreadyInitializedLatestProposalId(); |
||||
|
||||
/** |
||||
* @dev See {IGovernor-getProposalId}. |
||||
*/ |
||||
function getProposalId( |
||||
address[] memory targets, |
||||
uint256[] memory values, |
||||
bytes[] memory calldatas, |
||||
bytes32 descriptionHash |
||||
) public view virtual override returns (uint256) { |
||||
uint256 proposalHash = hashProposal(targets, values, calldatas, descriptionHash); |
||||
uint256 storedProposalId = _proposalIds[proposalHash]; |
||||
if (storedProposalId == 0) { |
||||
revert GovernorNonexistentProposal(0); |
||||
} |
||||
return storedProposalId; |
||||
} |
||||
|
||||
/** |
||||
* @dev Returns the latest proposal id. A return value of 0 means no proposals have been created yet. |
||||
*/ |
||||
function latestProposalId() public view virtual returns (uint256) { |
||||
return _latestProposalId; |
||||
} |
||||
|
||||
/** |
||||
* @dev See {IGovernor-_propose}. |
||||
* Hook into the proposing mechanism to increment proposal count. |
||||
*/ |
||||
function _propose( |
||||
address[] memory targets, |
||||
uint256[] memory values, |
||||
bytes[] memory calldatas, |
||||
string memory description, |
||||
address proposer |
||||
) internal virtual override returns (uint256) { |
||||
uint256 proposalHash = hashProposal(targets, values, calldatas, keccak256(bytes(description))); |
||||
uint256 storedProposalId = _proposalIds[proposalHash]; |
||||
if (storedProposalId == 0) { |
||||
_proposalIds[proposalHash] = ++_latestProposalId; |
||||
} |
||||
return super._propose(targets, values, calldatas, description, proposer); |
||||
} |
||||
|
||||
/** |
||||
* @dev Internal function to set the {latestProposalId}. This function is helpful when transitioning |
||||
* from another governance system. The next proposal id will be `newLatestProposalId` + 1. |
||||
* |
||||
* May only call this function if the current value of {latestProposalId} is 0. |
||||
*/ |
||||
function _initializeLatestProposalId(uint256 newLatestProposalId) internal virtual { |
||||
if (_latestProposalId != 0) { |
||||
revert GovernorAlreadyInitializedLatestProposalId(); |
||||
} |
||||
_latestProposalId = newLatestProposalId; |
||||
} |
||||
} |
@ -0,0 +1,39 @@ |
||||
// SPDX-License-Identifier: MIT |
||||
|
||||
pragma solidity ^0.8.20; |
||||
|
||||
import {Governor} from "../../governance/Governor.sol"; |
||||
import {GovernorSettings} from "../../governance/extensions/GovernorSettings.sol"; |
||||
import {GovernorCountingSimple} from "../../governance/extensions/GovernorCountingSimple.sol"; |
||||
import {GovernorVotesQuorumFraction} from "../../governance/extensions/GovernorVotesQuorumFraction.sol"; |
||||
import {GovernorSequentialProposalId} from "../../governance/extensions/GovernorSequentialProposalId.sol"; |
||||
|
||||
abstract contract GovernorSequentialProposalIdMock is |
||||
GovernorSettings, |
||||
GovernorVotesQuorumFraction, |
||||
GovernorCountingSimple, |
||||
GovernorSequentialProposalId |
||||
{ |
||||
function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { |
||||
return super.proposalThreshold(); |
||||
} |
||||
|
||||
function getProposalId( |
||||
address[] memory targets, |
||||
uint256[] memory values, |
||||
bytes[] memory calldatas, |
||||
bytes32 descriptionHash |
||||
) public view virtual override(Governor, GovernorSequentialProposalId) returns (uint256) { |
||||
return super.getProposalId(targets, values, calldatas, descriptionHash); |
||||
} |
||||
|
||||
function _propose( |
||||
address[] memory targets, |
||||
uint256[] memory values, |
||||
bytes[] memory calldatas, |
||||
string memory description, |
||||
address proposer |
||||
) internal virtual override(Governor, GovernorSequentialProposalId) returns (uint256 proposalId) { |
||||
return super._propose(targets, values, calldatas, description, proposer); |
||||
} |
||||
} |
@ -1,4 +1,4 @@ |
||||
certora-cli==4.13.1 |
||||
# File uses a custom name (fv-requirements.txt) so that it isn't picked by Netlify's build |
||||
# whose latest Python version is 0.3.8, incompatible with most recent versions of Halmos |
||||
halmos==0.2.0 |
||||
halmos==0.2.3 |
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,5 +0,0 @@ |
||||
#!/usr/bin/env bash |
||||
|
||||
set -euo pipefail |
||||
|
||||
if git status &>/dev/null; then git config core.hooksPath .githooks; fi |
@ -0,0 +1,10 @@ |
||||
#!/usr/bin/env bash |
||||
|
||||
# This script sets the node `--max-old-space-size` to 8192 if it is not set already. |
||||
# All existing `NODE_OPTIONS` are retained as is. |
||||
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:-}" |
||||
|
||||
if [[ $NODE_OPTIONS != *"--max-old-space-size"* ]]; then |
||||
export NODE_OPTIONS="${NODE_OPTIONS} --max-old-space-size=8192" |
||||
fi |
@ -1,5 +1,8 @@ |
||||
{ |
||||
"name": "solhint-plugin-openzeppelin", |
||||
"version": "0.0.0", |
||||
"private": true |
||||
"private": true, |
||||
"dependencies": { |
||||
"minimatch": "^3.1.2" |
||||
} |
||||
} |
||||
|
@ -1,5 +1,4 @@ |
||||
{ |
||||
"detectors_to_run": "arbitrary-send-erc20,array-by-reference,incorrect-shift,name-reused,rtlo,suicidal,uninitialized-state,uninitialized-storage,arbitrary-send-erc20-permit,controlled-array-length,controlled-delegatecall,delegatecall-loop,msg-value-loop,reentrancy-eth,unchecked-transfer,weak-prng,domain-separator-collision,erc20-interface,erc721-interface,locked-ether,mapping-deletion,shadowing-abstract,tautology,write-after-write,boolean-cst,reentrancy-no-eth,reused-constructor,tx-origin,unchecked-lowlevel,unchecked-send,variable-scope,void-cst,events-access,events-maths,incorrect-unary,boolean-equal,cyclomatic-complexity,deprecated-standards,erc20-indexed,function-init-state,pragma,unused-state,reentrancy-unlimited-gas,constable-states,immutable-states,var-read-using-this", |
||||
"filter_paths": "contracts/mocks,contracts/vendor,contracts-exposed", |
||||
"compile_force_framework": "hardhat" |
||||
"filter_paths": "contracts/mocks,contracts/vendor,contracts-exposed" |
||||
} |
||||
|
@ -1,3 +1,3 @@ |
||||
## Testing |
||||
|
||||
Unit test are critical to OpenZeppelin Contracts. They help ensure code quality and mitigate against security vulnerabilities. The directory structure within the `/test` directory corresponds to the `/contracts` directory. |
||||
Unit tests are critical to OpenZeppelin Contracts. They help ensure code quality and mitigate against security vulnerabilities. The directory structure within the `/test` directory corresponds to the `/contracts` directory. |
||||
|
@ -0,0 +1,421 @@ |
||||
// SPDX-License-Identifier: UNLICENSED |
||||
pragma solidity ^0.8.24; |
||||
|
||||
// Parts of this test file are adapted from Adam Egyed (@adamegyed) proof of concept available at: |
||||
// https://github.com/adamegyed/erc7579-execute-vulnerability/tree/4589a30ff139e143d6c57183ac62b5c029217a90 |
||||
// |
||||
// solhint-disable no-console |
||||
|
||||
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; |
||||
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; |
||||
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; |
||||
import {PackedUserOperation, IAccount, IEntryPoint} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol"; |
||||
import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol"; |
||||
import {ERC7579Utils, Mode, CallType, ExecType, ModeSelector, ModePayload, Execution} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; |
||||
import {Test, Vm, console} from "forge-std/Test.sol"; |
||||
|
||||
contract SampleAccount is IAccount, Ownable { |
||||
using ECDSA for *; |
||||
using MessageHashUtils for *; |
||||
using ERC4337Utils for *; |
||||
using ERC7579Utils for *; |
||||
|
||||
IEntryPoint internal constant ENTRY_POINT = IEntryPoint(payable(0x0000000071727De22E5E9d8BAf0edAc6f37da032)); |
||||
|
||||
event Log(bool duringValidation, Execution[] calls); |
||||
|
||||
error UnsupportedCallType(CallType callType); |
||||
|
||||
constructor(address initialOwner) Ownable(initialOwner) {} |
||||
|
||||
function validateUserOp( |
||||
PackedUserOperation calldata userOp, |
||||
bytes32 userOpHash, |
||||
uint256 missingAccountFunds |
||||
) external override returns (uint256 validationData) { |
||||
require(msg.sender == address(ENTRY_POINT), "only from EP"); |
||||
// Check signature |
||||
if (userOpHash.toEthSignedMessageHash().recover(userOp.signature) != owner()) { |
||||
revert OwnableUnauthorizedAccount(_msgSender()); |
||||
} |
||||
|
||||
// If this is an execute call with a batch operation, log the call details from the calldata |
||||
if (bytes4(userOp.callData[0x00:0x04]) == this.execute.selector) { |
||||
(CallType callType, , , ) = Mode.wrap(bytes32(userOp.callData[0x04:0x24])).decodeMode(); |
||||
|
||||
if (callType == ERC7579Utils.CALLTYPE_BATCH) { |
||||
// Remove the selector |
||||
bytes calldata params = userOp.callData[0x04:]; |
||||
|
||||
// Use the same vulnerable assignment technique here, but assert afterwards that the checks aren't |
||||
// broken here by comparing to the result of `abi.decode(...)`. |
||||
bytes calldata executionCalldata; |
||||
assembly ("memory-safe") { |
||||
let dataptr := add(params.offset, calldataload(add(params.offset, 0x20))) |
||||
executionCalldata.offset := add(dataptr, 32) |
||||
executionCalldata.length := calldataload(dataptr) |
||||
} |
||||
// Check that this decoding step is done correctly. |
||||
(, bytes memory executionCalldataMemory) = abi.decode(params, (bytes32, bytes)); |
||||
|
||||
require( |
||||
keccak256(executionCalldata) == keccak256(executionCalldataMemory), |
||||
"decoding during validation failed" |
||||
); |
||||
// Now, we know that we have `bytes calldata executionCalldata` as would be decoded by the solidity |
||||
// builtin decoder for the `execute` function. |
||||
|
||||
// This is where the vulnerability from ExecutionLib results in a different result between validation |
||||
// andexecution. |
||||
|
||||
emit Log(true, executionCalldata.decodeBatch()); |
||||
} |
||||
} |
||||
|
||||
if (missingAccountFunds > 0) { |
||||
(bool success, ) = payable(msg.sender).call{value: missingAccountFunds}(""); |
||||
success; // Silence warning. The entrypoint should validate the result. |
||||
} |
||||
|
||||
return ERC4337Utils.SIG_VALIDATION_SUCCESS; |
||||
} |
||||
|
||||
function execute(Mode mode, bytes calldata executionCalldata) external payable { |
||||
require(msg.sender == address(this) || msg.sender == address(ENTRY_POINT), "not auth"); |
||||
|
||||
(CallType callType, ExecType execType, , ) = mode.decodeMode(); |
||||
|
||||
// check if calltype is batch or single |
||||
if (callType == ERC7579Utils.CALLTYPE_SINGLE) { |
||||
executionCalldata.execSingle(execType); |
||||
} else if (callType == ERC7579Utils.CALLTYPE_BATCH) { |
||||
executionCalldata.execBatch(execType); |
||||
|
||||
emit Log(false, executionCalldata.decodeBatch()); |
||||
} else if (callType == ERC7579Utils.CALLTYPE_DELEGATECALL) { |
||||
executionCalldata.execDelegateCall(execType); |
||||
} else { |
||||
revert UnsupportedCallType(callType); |
||||
} |
||||
} |
||||
} |
||||
|
||||
contract ERC7579UtilsTest is Test { |
||||
using MessageHashUtils for *; |
||||
using ERC4337Utils for *; |
||||
using ERC7579Utils for *; |
||||
|
||||
IEntryPoint private constant ENTRYPOINT = IEntryPoint(payable(0x0000000071727De22E5E9d8BAf0edAc6f37da032)); |
||||
address private _owner; |
||||
uint256 private _ownerKey; |
||||
address private _account; |
||||
address private _beneficiary; |
||||
address private _recipient1; |
||||
address private _recipient2; |
||||
|
||||
constructor() { |
||||
vm.etch(0x0000000071727De22E5E9d8BAf0edAc6f37da032, vm.readFileBinary("test/bin/EntryPoint070.bytecode")); |
||||
vm.etch(0xEFC2c1444eBCC4Db75e7613d20C6a62fF67A167C, vm.readFileBinary("test/bin/SenderCreator070.bytecode")); |
||||
|
||||
// signing key |
||||
(_owner, _ownerKey) = makeAddrAndKey("owner"); |
||||
|
||||
// ERC-4337 account |
||||
_account = address(new SampleAccount(_owner)); |
||||
vm.deal(_account, 1 ether); |
||||
|
||||
// other |
||||
_beneficiary = makeAddr("beneficiary"); |
||||
_recipient1 = makeAddr("recipient1"); |
||||
_recipient2 = makeAddr("recipient2"); |
||||
} |
||||
|
||||
function testExecuteBatchDecodeCorrectly() public { |
||||
Execution[] memory calls = new Execution[](2); |
||||
calls[0] = Execution({target: _recipient1, value: 1 wei, callData: ""}); |
||||
calls[1] = Execution({target: _recipient2, value: 1 wei, callData: ""}); |
||||
|
||||
PackedUserOperation[] memory userOps = new PackedUserOperation[](1); |
||||
userOps[0] = PackedUserOperation({ |
||||
sender: _account, |
||||
nonce: 0, |
||||
initCode: "", |
||||
callData: abi.encodeCall( |
||||
SampleAccount.execute, |
||||
( |
||||
ERC7579Utils.encodeMode( |
||||
ERC7579Utils.CALLTYPE_BATCH, |
||||
ERC7579Utils.EXECTYPE_DEFAULT, |
||||
ModeSelector.wrap(0x00), |
||||
ModePayload.wrap(0x00) |
||||
), |
||||
ERC7579Utils.encodeBatch(calls) |
||||
) |
||||
), |
||||
accountGasLimits: _packGas(500_000, 500_000), |
||||
preVerificationGas: 0, |
||||
gasFees: _packGas(1, 1), |
||||
paymasterAndData: "", |
||||
signature: "" |
||||
}); |
||||
|
||||
(uint8 v, bytes32 r, bytes32 s) = vm.sign( |
||||
_ownerKey, |
||||
this.hashUserOperation(userOps[0]).toEthSignedMessageHash() |
||||
); |
||||
userOps[0].signature = abi.encodePacked(r, s, v); |
||||
|
||||
vm.recordLogs(); |
||||
ENTRYPOINT.handleOps(userOps, payable(_beneficiary)); |
||||
|
||||
assertEq(_recipient1.balance, 1 wei); |
||||
assertEq(_recipient2.balance, 1 wei); |
||||
|
||||
_collectAndPrintLogs(false); |
||||
} |
||||
|
||||
function testExecuteBatchDecodeEmpty() public { |
||||
bytes memory fakeCalls = abi.encodePacked( |
||||
uint256(1), // Length of execution[] |
||||
uint256(0x20), // offset |
||||
uint256(uint160(_recipient1)), // target |
||||
uint256(1), // value: 1 wei |
||||
uint256(0x60), // offset of data |
||||
uint256(0) // length of |
||||
); |
||||
|
||||
PackedUserOperation[] memory userOps = new PackedUserOperation[](1); |
||||
userOps[0] = PackedUserOperation({ |
||||
sender: _account, |
||||
nonce: 0, |
||||
initCode: "", |
||||
callData: abi.encodeCall( |
||||
SampleAccount.execute, |
||||
( |
||||
ERC7579Utils.encodeMode( |
||||
ERC7579Utils.CALLTYPE_BATCH, |
||||
ERC7579Utils.EXECTYPE_DEFAULT, |
||||
ModeSelector.wrap(0x00), |
||||
ModePayload.wrap(0x00) |
||||
), |
||||
abi.encodePacked( |
||||
uint256(0x70) // fake offset pointing to paymasterAndData |
||||
) |
||||
) |
||||
), |
||||
accountGasLimits: _packGas(500_000, 500_000), |
||||
preVerificationGas: 0, |
||||
gasFees: _packGas(1, 1), |
||||
paymasterAndData: abi.encodePacked(address(0), fakeCalls), |
||||
signature: "" |
||||
}); |
||||
|
||||
(uint8 v, bytes32 r, bytes32 s) = vm.sign( |
||||
_ownerKey, |
||||
this.hashUserOperation(userOps[0]).toEthSignedMessageHash() |
||||
); |
||||
userOps[0].signature = abi.encodePacked(r, s, v); |
||||
|
||||
vm.expectRevert( |
||||
abi.encodeWithSelector( |
||||
IEntryPoint.FailedOpWithRevert.selector, |
||||
0, |
||||
"AA23 reverted", |
||||
abi.encodeWithSelector(ERC7579Utils.ERC7579DecodingError.selector) |
||||
) |
||||
); |
||||
ENTRYPOINT.handleOps(userOps, payable(_beneficiary)); |
||||
|
||||
_collectAndPrintLogs(false); |
||||
} |
||||
|
||||
function testExecuteBatchDecodeDifferent() public { |
||||
bytes memory execCallData = abi.encodePacked( |
||||
uint256(0x20), // offset pointing to the next segment |
||||
uint256(5), // Length of execution[] |
||||
uint256(0), // offset of calls[0], and target (!!) |
||||
uint256(0x20), // offset of calls[1], and value (!!) |
||||
uint256(0), // offset of calls[2], and rel offset of data (!!) |
||||
uint256(0) // offset of calls[3]. |
||||
// There is one more to read by the array length, but it's not present here. This will be |
||||
// paymasterAndData.length during validation, pointing to an all-zero call. |
||||
// During execution, the offset will be 0, pointing to a call with value. |
||||
); |
||||
|
||||
PackedUserOperation[] memory userOps = new PackedUserOperation[](1); |
||||
userOps[0] = PackedUserOperation({ |
||||
sender: _account, |
||||
nonce: 0, |
||||
initCode: "", |
||||
callData: abi.encodePacked( |
||||
SampleAccount.execute.selector, |
||||
ERC7579Utils.encodeMode( |
||||
ERC7579Utils.CALLTYPE_BATCH, |
||||
ERC7579Utils.EXECTYPE_DEFAULT, |
||||
ModeSelector.wrap(0x00), |
||||
ModePayload.wrap(0x00) |
||||
), |
||||
uint256(0x5c), // offset pointing to the next segment |
||||
uint224(type(uint224).max), // Padding to align the `bytes` types |
||||
// type(uint256).max, // unknown padding |
||||
uint256(execCallData.length), // Length of the data |
||||
execCallData |
||||
), |
||||
accountGasLimits: _packGas(500_000, 500_000), |
||||
preVerificationGas: 0, |
||||
gasFees: _packGas(1, 1), |
||||
paymasterAndData: abi.encodePacked(uint256(0), uint256(0)), // padding length to create an offset |
||||
signature: "" |
||||
}); |
||||
|
||||
(uint8 v, bytes32 r, bytes32 s) = vm.sign( |
||||
_ownerKey, |
||||
this.hashUserOperation(userOps[0]).toEthSignedMessageHash() |
||||
); |
||||
userOps[0].signature = abi.encodePacked(r, s, v); |
||||
|
||||
vm.expectRevert( |
||||
abi.encodeWithSelector( |
||||
IEntryPoint.FailedOpWithRevert.selector, |
||||
0, |
||||
"AA23 reverted", |
||||
abi.encodeWithSelector(ERC7579Utils.ERC7579DecodingError.selector) |
||||
) |
||||
); |
||||
ENTRYPOINT.handleOps(userOps, payable(_beneficiary)); |
||||
|
||||
_collectAndPrintLogs(true); |
||||
} |
||||
|
||||
function testDecodeBatch() public { |
||||
// BAD: buffer empty |
||||
vm.expectRevert(ERC7579Utils.ERC7579DecodingError.selector); |
||||
this.callDecodeBatch(""); |
||||
|
||||
// BAD: buffer too short |
||||
vm.expectRevert(ERC7579Utils.ERC7579DecodingError.selector); |
||||
this.callDecodeBatch(abi.encodePacked(uint128(0))); |
||||
|
||||
// GOOD |
||||
this.callDecodeBatch(abi.encode(0)); |
||||
// Note: Solidity also supports this even though it's odd. Offset 0 means array is at the same location, which |
||||
// is interpreted as an array of length 0, which doesn't require any more data |
||||
// solhint-disable-next-line var-name-mixedcase |
||||
uint256[] memory _1 = abi.decode(abi.encode(0), (uint256[])); |
||||
_1; |
||||
|
||||
// BAD: offset is out of bounds |
||||
vm.expectRevert(ERC7579Utils.ERC7579DecodingError.selector); |
||||
this.callDecodeBatch(abi.encode(1)); |
||||
|
||||
// GOOD |
||||
this.callDecodeBatch(abi.encode(32, 0)); |
||||
|
||||
// BAD: reported array length extends beyond bounds |
||||
vm.expectRevert(ERC7579Utils.ERC7579DecodingError.selector); |
||||
this.callDecodeBatch(abi.encode(32, 1)); |
||||
|
||||
// GOOD |
||||
this.callDecodeBatch(abi.encode(32, 1, 0)); |
||||
|
||||
// GOOD |
||||
// |
||||
// 0000000000000000000000000000000000000000000000000000000000000020 (32) offset |
||||
// 0000000000000000000000000000000000000000000000000000000000000001 ( 1) array length |
||||
// 0000000000000000000000000000000000000000000000000000000000000020 (32) element 0 offset |
||||
// 000000000000000000000000xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (recipient) target for element #0 |
||||
// 000000000000000000000000000000000000000000000000000000000000002a (42) value for element #0 |
||||
// 0000000000000000000000000000000000000000000000000000000000000060 (96) offset to calldata for element #0 |
||||
// 000000000000000000000000000000000000000000000000000000000000000c (12) length of the calldata for element #0 |
||||
// 48656c6c6f20576f726c64210000000000000000000000000000000000000000 (..) buffer for the calldata for element #0 |
||||
assertEq( |
||||
bytes("Hello World!"), |
||||
this.callDecodeBatchAndGetFirstBytes( |
||||
abi.encode(32, 1, 32, _recipient1, 42, 96, 12, bytes12("Hello World!")) |
||||
) |
||||
); |
||||
|
||||
// This is invalid, the first element of the array points is out of bounds |
||||
// but we allow it past initial validation, because solidity will validate later when the bytes field is accessed |
||||
// |
||||
// 0000000000000000000000000000000000000000000000000000000000000020 (32) offset |
||||
// 0000000000000000000000000000000000000000000000000000000000000001 ( 1) array length |
||||
// 0000000000000000000000000000000000000000000000000000000000000020 (32) element 0 offset |
||||
// <missing element> |
||||
bytes memory invalid = abi.encode(32, 1, 32); |
||||
this.callDecodeBatch(invalid); |
||||
vm.expectRevert(); |
||||
this.callDecodeBatchAndGetFirst(invalid); |
||||
|
||||
// this is invalid: the bytes field of the first element of the array is out of bounds |
||||
// but we allow it past initial validation, because solidity will validate later when the bytes field is accessed |
||||
// |
||||
// 0000000000000000000000000000000000000000000000000000000000000020 (32) offset |
||||
// 0000000000000000000000000000000000000000000000000000000000000001 ( 1) array length |
||||
// 0000000000000000000000000000000000000000000000000000000000000020 (32) element 0 offset |
||||
// 000000000000000000000000xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (recipient) target for element #0 |
||||
// 000000000000000000000000000000000000000000000000000000000000002a (42) value for element #0 |
||||
// 0000000000000000000000000000000000000000000000000000000000000060 (96) offset to calldata for element #0 |
||||
// <missing data> |
||||
bytes memory invalidDeeply = abi.encode(32, 1, 32, _recipient1, 42, 96); |
||||
this.callDecodeBatch(invalidDeeply); |
||||
// Note that this is ok because we don't return the value. Returning it would introduce a check that would fails. |
||||
this.callDecodeBatchAndGetFirst(invalidDeeply); |
||||
vm.expectRevert(); |
||||
this.callDecodeBatchAndGetFirstBytes(invalidDeeply); |
||||
} |
||||
|
||||
function callDecodeBatch(bytes calldata executionCalldata) public pure { |
||||
ERC7579Utils.decodeBatch(executionCalldata); |
||||
} |
||||
|
||||
function callDecodeBatchAndGetFirst(bytes calldata executionCalldata) public pure { |
||||
ERC7579Utils.decodeBatch(executionCalldata)[0]; |
||||
} |
||||
|
||||
function callDecodeBatchAndGetFirstBytes(bytes calldata executionCalldata) public pure returns (bytes calldata) { |
||||
return ERC7579Utils.decodeBatch(executionCalldata)[0].callData; |
||||
} |
||||
|
||||
function hashUserOperation(PackedUserOperation calldata useroperation) public view returns (bytes32) { |
||||
return useroperation.hash(address(ENTRYPOINT), block.chainid); |
||||
} |
||||
|
||||
function _collectAndPrintLogs(bool includeTotalValue) internal { |
||||
Vm.Log[] memory logs = vm.getRecordedLogs(); |
||||
for (uint256 i = 0; i < logs.length; i++) { |
||||
if (logs[i].emitter == _account) { |
||||
_printDecodedCalls(logs[i].data, includeTotalValue); |
||||
} |
||||
} |
||||
} |
||||
|
||||
function _printDecodedCalls(bytes memory logData, bool includeTotalValue) internal pure { |
||||
(bool duringValidation, Execution[] memory calls) = abi.decode(logData, (bool, Execution[])); |
||||
|
||||
console.log( |
||||
string.concat( |
||||
"Batch execute contents, as read during ", |
||||
duringValidation ? "validation" : "execution", |
||||
": " |
||||
) |
||||
); |
||||
console.log(" Execution[] length: %s", calls.length); |
||||
|
||||
uint256 totalValue = 0; |
||||
for (uint256 i = 0; i < calls.length; ++i) { |
||||
console.log(string.concat(" calls[", vm.toString(i), "].target = ", vm.toString(calls[i].target))); |
||||
console.log(string.concat(" calls[", vm.toString(i), "].value = ", vm.toString(calls[i].value))); |
||||
console.log(string.concat(" calls[", vm.toString(i), "].data = ", vm.toString(calls[i].callData))); |
||||
totalValue += calls[i].value; |
||||
} |
||||
|
||||
if (includeTotalValue) { |
||||
console.log(" Total value: %s", totalValue); |
||||
} |
||||
} |
||||
|
||||
function _packGas(uint256 upper, uint256 lower) internal pure returns (bytes32) { |
||||
return bytes32(uint256((upper << 128) | uint128(lower))); |
||||
} |
||||
} |
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -0,0 +1 @@ |
||||
[{"inputs":[{"internalType":"bytes","name":"initCode","type":"bytes"}],"name":"createSender","outputs":[{"internalType":"address","name":"sender","type":"address"}],"stateMutability":"nonpayable","type":"function"}] |
Binary file not shown.
@ -0,0 +1,202 @@ |
||||
const { ethers } = require('hardhat'); |
||||
const { expect } = require('chai'); |
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); |
||||
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); |
||||
|
||||
const { GovernorHelper } = require('../../helpers/governance'); |
||||
const { VoteType } = require('../../helpers/enums'); |
||||
const iterate = require('../../helpers/iterate'); |
||||
|
||||
const TOKENS = [ |
||||
{ Token: '$ERC20Votes', mode: 'blocknumber' }, |
||||
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' }, |
||||
]; |
||||
|
||||
const name = 'OZ-Governor'; |
||||
const version = '1'; |
||||
const tokenName = 'MockToken'; |
||||
const tokenSymbol = 'MTKN'; |
||||
const tokenSupply = ethers.parseEther('100'); |
||||
const votingDelay = 4n; |
||||
const votingPeriod = 16n; |
||||
const value = ethers.parseEther('1'); |
||||
|
||||
async function deployToken(contractName) { |
||||
try { |
||||
return await ethers.deployContract(contractName, [tokenName, tokenSymbol, tokenName, version]); |
||||
} catch (error) { |
||||
if (error.message == 'incorrect number of arguments to constructor') { |
||||
// ERC20VotesLegacyMock has a different construction that uses version='1' by default.
|
||||
return ethers.deployContract(contractName, [tokenName, tokenSymbol, tokenName]); |
||||
} |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
describe('GovernorSequentialProposalId', function () { |
||||
for (const { Token, mode } of TOKENS) { |
||||
const fixture = async () => { |
||||
const [owner, proposer, voter1, voter2, voter3, voter4, userEOA] = await ethers.getSigners(); |
||||
const receiver = await ethers.deployContract('CallReceiverMock'); |
||||
|
||||
const token = await deployToken(Token, [tokenName, tokenSymbol, version]); |
||||
const mock = await ethers.deployContract('$GovernorSequentialProposalIdMock', [ |
||||
name, // name
|
||||
votingDelay, // initialVotingDelay
|
||||
votingPeriod, // initialVotingPeriod
|
||||
0n, // initialProposalThreshold
|
||||
token, // tokenAddress
|
||||
10n, // quorumNumeratorValue
|
||||
]); |
||||
|
||||
await owner.sendTransaction({ to: mock, value }); |
||||
await token.$_mint(owner, tokenSupply); |
||||
|
||||
const helper = new GovernorHelper(mock, mode); |
||||
await helper.connect(owner).delegate({ token: token, to: voter1, value: ethers.parseEther('10') }); |
||||
await helper.connect(owner).delegate({ token: token, to: voter2, value: ethers.parseEther('7') }); |
||||
await helper.connect(owner).delegate({ token: token, to: voter3, value: ethers.parseEther('5') }); |
||||
await helper.connect(owner).delegate({ token: token, to: voter4, value: ethers.parseEther('2') }); |
||||
|
||||
return { |
||||
owner, |
||||
proposer, |
||||
voter1, |
||||
voter2, |
||||
voter3, |
||||
voter4, |
||||
userEOA, |
||||
receiver, |
||||
token, |
||||
mock, |
||||
helper, |
||||
}; |
||||
}; |
||||
|
||||
describe(`using ${Token}`, function () { |
||||
beforeEach(async function () { |
||||
Object.assign(this, await loadFixture(fixture)); |
||||
|
||||
this.proposal = this.helper.setProposal( |
||||
[ |
||||
{ |
||||
target: this.receiver.target, |
||||
data: this.receiver.interface.encodeFunctionData('mockFunction'), |
||||
value, |
||||
}, |
||||
], |
||||
'<proposal description>', |
||||
); |
||||
}); |
||||
|
||||
it('sequential proposal ids', async function () { |
||||
for (const i of iterate.range(1, 10)) { |
||||
this.proposal.description = `<proposal description #${i}>`; |
||||
|
||||
expect(this.mock.hashProposal(...this.proposal.shortProposal)).to.eventually.equal(this.proposal.hash); |
||||
await expect(this.mock.getProposalId(...this.proposal.shortProposal)).revertedWithCustomError( |
||||
this.mock, |
||||
'GovernorNonexistentProposal', |
||||
); |
||||
expect(this.mock.latestProposalId()).to.eventually.equal(i - 1); |
||||
|
||||
await expect(this.helper.connect(this.proposer).propose()) |
||||
.to.emit(this.mock, 'ProposalCreated') |
||||
.withArgs( |
||||
i, |
||||
this.proposer, |
||||
this.proposal.targets, |
||||
this.proposal.values, |
||||
this.proposal.signatures, |
||||
this.proposal.data, |
||||
anyValue, |
||||
anyValue, |
||||
this.proposal.description, |
||||
); |
||||
|
||||
expect(this.mock.hashProposal(...this.proposal.shortProposal)).to.eventually.equal(this.proposal.hash); |
||||
expect(this.mock.getProposalId(...this.proposal.shortProposal)).to.eventually.equal(i); |
||||
expect(this.mock.latestProposalId()).to.eventually.equal(i); |
||||
} |
||||
}); |
||||
|
||||
it('sequential proposal ids with offset start', async function () { |
||||
const offset = 69420; |
||||
await this.mock.$_initializeLatestProposalId(offset); |
||||
|
||||
for (const i of iterate.range(offset + 1, offset + 10)) { |
||||
this.proposal.description = `<proposal description #${i}>`; |
||||
|
||||
expect(this.mock.hashProposal(...this.proposal.shortProposal)).to.eventually.equal(this.proposal.hash); |
||||
await expect(this.mock.getProposalId(...this.proposal.shortProposal)).revertedWithCustomError( |
||||
this.mock, |
||||
'GovernorNonexistentProposal', |
||||
); |
||||
expect(this.mock.latestProposalId()).to.eventually.equal(i - 1); |
||||
|
||||
await expect(this.helper.connect(this.proposer).propose()) |
||||
.to.emit(this.mock, 'ProposalCreated') |
||||
.withArgs( |
||||
i, |
||||
this.proposer, |
||||
this.proposal.targets, |
||||
this.proposal.values, |
||||
this.proposal.signatures, |
||||
this.proposal.data, |
||||
anyValue, |
||||
anyValue, |
||||
this.proposal.description, |
||||
); |
||||
|
||||
expect(this.mock.hashProposal(...this.proposal.shortProposal)).to.eventually.equal(this.proposal.hash); |
||||
expect(this.mock.getProposalId(...this.proposal.shortProposal)).to.eventually.equal(i); |
||||
expect(this.mock.latestProposalId()).to.eventually.equal(i); |
||||
} |
||||
}); |
||||
|
||||
it('can only initialize latest proposal id from 0', async function () { |
||||
await this.helper.propose(); |
||||
expect(this.mock.latestProposalId()).to.eventually.equal(1); |
||||
await expect(this.mock.$_initializeLatestProposalId(2)).to.be.revertedWithCustomError( |
||||
this.mock, |
||||
'GovernorAlreadyInitializedLatestProposalId', |
||||
); |
||||
}); |
||||
|
||||
it('cannot repropose same proposal', async function () { |
||||
await this.helper.connect(this.proposer).propose(); |
||||
await expect(this.helper.connect(this.proposer).propose()) |
||||
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState') |
||||
.withArgs(await this.proposal.id, 0, ethers.ZeroHash); |
||||
}); |
||||
|
||||
it('nominal workflow', async function () { |
||||
await this.helper.connect(this.proposer).propose(); |
||||
await this.helper.waitForSnapshot(); |
||||
|
||||
await expect(this.mock.connect(this.voter1).castVote(1, VoteType.For)) |
||||
.to.emit(this.mock, 'VoteCast') |
||||
.withArgs(this.voter1, 1, VoteType.For, ethers.parseEther('10'), ''); |
||||
|
||||
await expect(this.mock.connect(this.voter2).castVote(1, VoteType.For)) |
||||
.to.emit(this.mock, 'VoteCast') |
||||
.withArgs(this.voter2, 1, VoteType.For, ethers.parseEther('7'), ''); |
||||
|
||||
await expect(this.mock.connect(this.voter3).castVote(1, VoteType.For)) |
||||
.to.emit(this.mock, 'VoteCast') |
||||
.withArgs(this.voter3, 1, VoteType.For, ethers.parseEther('5'), ''); |
||||
|
||||
await expect(this.mock.connect(this.voter4).castVote(1, VoteType.Abstain)) |
||||
.to.emit(this.mock, 'VoteCast') |
||||
.withArgs(this.voter4, 1, VoteType.Abstain, ethers.parseEther('2'), ''); |
||||
|
||||
await this.helper.waitForDeadline(); |
||||
|
||||
expect(this.helper.execute()) |
||||
.to.eventually.emit(this.mock, 'ProposalExecuted') |
||||
.withArgs(1) |
||||
.emit(this.receiver, 'MockFunctionCalled'); |
||||
}); |
||||
}); |
||||
} |
||||
}); |
@ -0,0 +1,31 @@ |
||||
const { ethers } = require('hardhat'); |
||||
const { setCode } = require('@nomicfoundation/hardhat-network-helpers'); |
||||
const fs = require('fs'); |
||||
const path = require('path'); |
||||
|
||||
const INSTANCES = { |
||||
entrypoint: { |
||||
address: '0x0000000071727De22E5E9d8BAf0edAc6f37da032', |
||||
abi: JSON.parse(fs.readFileSync(path.resolve(__dirname, '../bin/EntryPoint070.abi'), 'utf-8')), |
||||
bytecode: fs.readFileSync(path.resolve(__dirname, '../bin/EntryPoint070.bytecode'), 'hex'), |
||||
}, |
||||
sendercreator: { |
||||
address: '0xEFC2c1444eBCC4Db75e7613d20C6a62fF67A167C', |
||||
abi: JSON.parse(fs.readFileSync(path.resolve(__dirname, '../bin/SenderCreator070.abi'), 'utf-8')), |
||||
bytecode: fs.readFileSync(path.resolve(__dirname, '../bin/SenderCreator070.bytecode'), 'hex'), |
||||
}, |
||||
}; |
||||
|
||||
function deployEntrypoint() { |
||||
return Promise.all( |
||||
Object.entries(INSTANCES).map(([name, { address, abi, bytecode }]) => |
||||
setCode(address, '0x' + bytecode.replace(/0x/, '')) |
||||
.then(() => ethers.getContractAt(abi, address)) |
||||
.then(instance => ({ [name]: instance })), |
||||
), |
||||
).then(namedInstances => Object.assign(...namedInstances)); |
||||
} |
||||
|
||||
module.exports = { |
||||
deployEntrypoint, |
||||
}; |
Loading…
Reference in new issue