AA-161 validate Nonce in EntryPoint (#247)

* EntryPoint to manage Nonce

- NonceManager to handle 2d nonces
   - _validateAndUpdateNonce() called after validateUserOp
   - getNonce(sender, key)

- remove nonce checking from BaseAccount
- BaseAccount implements getNonce() using ep.getNonce
This commit is contained in:
Dror Tirosh
2023-04-09 18:31:09 +03:00
committed by GitHub
parent 1b85cfbd46
commit 19918cda7c
18 changed files with 292 additions and 100 deletions

View File

@@ -2,8 +2,7 @@
pragma solidity ^0.8.12;
/* solhint-disable avoid-low-level-calls */
/* solhint-disable no-inline-assembly */
/* solhint-disable reason-string */
/* solhint-disable no-empty-blocks */
import "../interfaces/IAccount.sol";
import "../interfaces/IEntryPoint.sol";
@@ -22,10 +21,13 @@ abstract contract BaseAccount is IAccount {
uint256 constant internal SIG_VALIDATION_FAILED = 1;
/**
* return the account nonce.
* subclass should return a nonce value that is used both by _validateAndUpdateNonce, and by the external provider (to read the current nonce)
* Return the account nonce.
* This method returns the next sequential nonce.
* For a nonce of a specific key, use `entrypoint.getNonce(account, key)`
*/
function nonce() public view virtual returns (uint256);
function getNonce() public view virtual returns (uint256) {
return entryPoint().getNonce(address(this), 0);
}
/**
* return the entryPoint used by this account.
@@ -41,9 +43,7 @@ abstract contract BaseAccount is IAccount {
external override virtual returns (uint256 validationData) {
_requireFromEntryPoint();
validationData = _validateSignature(userOp, userOpHash);
if (userOp.initCode.length == 0) {
_validateAndUpdateNonce(userOp);
}
_validateNonce(userOp.nonce);
_payPrefund(missingAccountFunds);
}
@@ -71,12 +71,23 @@ abstract contract BaseAccount is IAccount {
internal virtual returns (uint256 validationData);
/**
* validate the current nonce matches the UserOperation nonce.
* then it should update the account's state to prevent replay of this UserOperation.
* called only if initCode is empty (since "nonce" field is used as "salt" on account creation)
* @param userOp the op to validate.
* Validate the nonce of the UserOperation.
* This method may validate the nonce requirement of this account.
* e.g.
* To limit the nonce to use sequenced UserOps only (no "out of order" UserOps):
* `require(nonce < type(uint64).max)`
* For a hypothetical account that *requires* the nonce to be out-of-order:
* `require(nonce & type(uint64).max == 0)`
*
* The actual nonce uniqueness is managed by the EntryPoint, and thus no other
* action is needed by the account itself.
*
* @param nonce to validate
*
* solhint-disable-next-line no-empty-blocks
*/
function _validateAndUpdateNonce(UserOperation calldata userOp) internal virtual;
function _validateNonce(uint256 nonce) internal view virtual {
}
/**
* sends to the entrypoint (msg.sender) the missing funds for this transaction.

View File

@@ -16,8 +16,9 @@ import "../utils/Exec.sol";
import "./StakeManager.sol";
import "./SenderCreator.sol";
import "./Helpers.sol";
import "./NonceManager.sol";
contract EntryPoint is IEntryPoint, StakeManager {
contract EntryPoint is IEntryPoint, StakeManager, NonceManager {
using UserOperationLib for UserOperation;
@@ -349,7 +350,8 @@ contract EntryPoint is IEntryPoint, StakeManager {
* @param initCode the constructor code to be passed into the UserOperation.
*/
function getSenderAddress(bytes calldata initCode) public {
revert SenderAddressResult(senderCreator.createSender(initCode));
address sender = senderCreator.createSender(initCode);
revert SenderAddressResult(sender);
}
function _simulationOnlyValidations(UserOperation calldata userOp) internal view {
@@ -511,6 +513,11 @@ contract EntryPoint is IEntryPoint, StakeManager {
uint256 gasUsedByValidateAccountPrepayment;
(uint256 requiredPreFund) = _getRequiredPrefund(mUserOp);
(gasUsedByValidateAccountPrepayment, validationData) = _validateAccountPrepayment(opIndex, userOp, outOpInfo, requiredPreFund);
if (!_validateAndUpdateNonce(mUserOp.sender, mUserOp.nonce)) {
revert FailedOp(opIndex, "AA25 invalid account nonce");
}
//a "marker" where account opcode validation is done and paymaster opcode validation is about to start
// (used only by off-chain simulateValidation)
numberMarker();

View File

@@ -0,0 +1,40 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;
import "../interfaces/IEntryPoint.sol";
/**
* nonce management functionality
*/
contract NonceManager is INonceManager {
/**
* The next valid sequence number for a given nonce key.
*/
mapping(address => mapping(uint192 => uint256)) public nonceSequenceNumber;
function getNonce(address sender, uint192 key)
public view override returns (uint256 nonce) {
return nonceSequenceNumber[sender][key] | (uint256(key) << 64);
}
// allow an account to manually increment its own nonce.
// (mainly so that during construction nonce can be made non-zero,
// to "absorb" the gas cost of first nonce increment to 1st transaction (construction),
// not to 2nd transaction)
function incrementNonce(uint192 key) public override {
nonceSequenceNumber[msg.sender][key]++;
}
/**
* validate nonce uniqueness for this account.
* called just after validateUserOp()
*/
function _validateAndUpdateNonce(address sender, uint256 nonce) internal returns (bool) {
uint192 key = uint192(nonce >> 64);
uint64 seq = uint64(nonce);
return nonceSequenceNumber[sender][key]++ == seq;
}
}

View File

@@ -12,8 +12,9 @@ pragma solidity ^0.8.12;
import "./UserOperation.sol";
import "./IStakeManager.sol";
import "./IAggregator.sol";
import "./INonceManager.sol";
interface IEntryPoint is IStakeManager {
interface IEntryPoint is IStakeManager, INonceManager {
/***
* An event emitted after each successful request

View File

@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;
interface INonceManager {
/**
* Return the next nonce for this sender.
* Within a given key, the nonce values are sequenced (starting with zero, and incremented by one on each userop)
* But UserOp with different keys can come with arbitrary order.
*
* @param sender the account address
* @param key the high 192 bit of the nonce
* @return nonce a full nonce to pass for next UserOp with this sender.
*/
function getNonce(address sender, uint192 key)
external view returns (uint256 nonce);
/**
* Manually increment the nonce of the sender.
* This method is exposed just for completeness..
* Account does NOT need to call it, neither during validation, nor elsewhere,
* as the EntryPoint will update the nonce regardless.
* Possible use-case is call it with various keys to "initialize" their nonces to one, so that future
* UserOperations will not pay extra for the first transaction with a given key.
*/
function incrementNonce(uint192 key) external;
}

View File

@@ -21,12 +21,6 @@ import "./callback/TokenCallbackHandler.sol";
contract SimpleAccount is BaseAccount, TokenCallbackHandler, UUPSUpgradeable, Initializable {
using ECDSA for bytes32;
//filler member, to push the nonce and owner to the same slot
// the "Initializeble" class takes 2 bytes in the first slot
bytes28 private _filler;
//explicit sizes of nonce, to fit a single storage cell with "owner"
uint96 private _nonce;
address public owner;
IEntryPoint private immutable _entryPoint;
@@ -38,11 +32,6 @@ contract SimpleAccount is BaseAccount, TokenCallbackHandler, UUPSUpgradeable, In
_;
}
/// @inheritdoc BaseAccount
function nonce() public view virtual override returns (uint256) {
return _nonce;
}
/// @inheritdoc BaseAccount
function entryPoint() public view virtual override returns (IEntryPoint) {
return _entryPoint;
@@ -100,11 +89,6 @@ contract SimpleAccount is BaseAccount, TokenCallbackHandler, UUPSUpgradeable, In
require(msg.sender == address(entryPoint()) || msg.sender == owner, "account: not Owner or EntryPoint");
}
/// implement template method of BaseAccount
function _validateAndUpdateNonce(UserOperation calldata userOp) internal override {
require(_nonce++ == userOp.nonce, "account: invalid nonce");
}
/// implement template method of BaseAccount
function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash)
internal override virtual returns (uint256 validationData) {

View File

@@ -50,6 +50,14 @@ contract EIP4337Fallback is DefaultCallbackHandler, IAccount, IERC1271 {
return abi.decode(ret, (uint256));
}
/**
* Helper for wallet to get the next nonce.
*/
function getNonce() public returns (uint256 nonce) {
bytes memory ret = delegateToManager();
(nonce) = abi.decode(ret, (uint256));
}
/**
* called from the Safe. delegate actual work to EIP4337Manager
*/
@@ -78,4 +86,4 @@ contract EIP4337Fallback is DefaultCallbackHandler, IAccount, IERC1271 {
return 0xffffffff;
}
}
}
}

View File

@@ -55,10 +55,8 @@ contract EIP4337Manager is IAccount, GnosisSafeStorage, Executor {
validationData = SIG_VALIDATION_FAILED;
}
if (userOp.initCode.length == 0) {
require(uint256(nonce) == userOp.nonce, "account: invalid nonce");
nonce = bytes32(uint256(nonce) + 1);
}
// mimic normal Safe nonce behaviour: prevent parallel nonces
require(userOp.nonce < type(uint64).max, "account: nonsequential nonce");
if (missingAccountFunds > 0) {
//Note: MAY pay more than the minimum, to deposit for future transactions
@@ -105,6 +103,12 @@ contract EIP4337Manager is IAccount, GnosisSafeStorage, Executor {
}
}
/**
* Helper for wallet to get the next nonce.
*/
function getNonce() public view returns (uint256) {
return IEntryPoint(entryPoint).getNonce(address(this), 0);
}
/**
* set up a safe as EIP-4337 enabled.
@@ -158,7 +162,8 @@ contract EIP4337Manager is IAccount, GnosisSafeStorage, Executor {
sig[64] = bytes1(uint8(27));
sig[2] = bytes1(uint8(1));
sig[35] = bytes1(uint8(1));
UserOperation memory userOp = UserOperation(address(safe), uint256(nonce), "", "", 0, 1000000, 0, 0, 0, "", sig);
uint256 nonce = uint256(IEntryPoint(manager.entryPoint()).getNonce(address(safe), 0));
UserOperation memory userOp = UserOperation(address(safe), nonce, "", "", 0, 1000000, 0, 0, 0, "", sig);
UserOperation[] memory userOps = new UserOperation[](1);
userOps[0] = userOp;
IEntryPoint _entryPoint = IEntryPoint(payable(manager.entryPoint()));

View File

@@ -12,11 +12,12 @@ contract MaliciousAccount is IAccount {
function validateUserOp(UserOperation calldata userOp, bytes32, uint256 missingAccountFunds)
external returns (uint256 validationData) {
ep.depositTo{value : missingAccountFunds}(address(this));
// Now calculate basefee per EntryPoint.getUserOpGasPrice() and compare it to the basefe we pass off-chain as nonce
// Now calculate basefee per EntryPoint.getUserOpGasPrice() and compare it to the basefe we pass off-chain in the signature
uint256 externalBaseFee = abi.decode(userOp.signature, (uint256));
uint256 requiredGas = userOp.callGasLimit + userOp.verificationGasLimit + userOp.preVerificationGas;
uint256 gasPrice = missingAccountFunds / requiredGas;
uint256 basefee = gasPrice - userOp.maxPriorityFeePerGas;
require (basefee == userOp.nonce, "Revert after first validation");
require (basefee == externalBaseFee, "Revert after first validation");
return 0;
}
}

View File

@@ -12,6 +12,7 @@ const deploySimpleAccountFactory: DeployFunction = async function (hre: HardhatR
from,
args: [entrypoint.address],
gasLimit: 6e6,
log: true,
deterministicDeployment: true
})
console.log('==SimpleAccountFactory addr=', ret.address)

View File

@@ -51,7 +51,7 @@ To avoid Ethereum consensus changes, we do not attempt to create new transaction
| Field | Type | Description
| - | - | - |
| `sender` | `address` | The account making the operation |
| `nonce` | `uint256` | Anti-replay parameter; also used as the salt for first-time account creation |
| `nonce` | `uint256` | Anti-replay parameter |
| `initCode` | `bytes` | The initCode of the account (needed if and only if the account is not yet on-chain and needs to be created) |
| `callData` | `bytes` | The data to pass to the `sender` during the main execution call |
| `callGasLimit` | `uint256` | The amount of gas to allocate the main execution call |
@@ -128,6 +128,7 @@ The account:
* MUST validate the caller is a trusted EntryPoint
* If the account does not support signature aggregation, it MUST validate the signature is a valid signature of the `userOpHash`, and
SHOULD return SIG_VALIDATION_FAILED (and not revert) on signature mismatch. Any other error should revert.
* The MAY check the nonce field, but should not implement the replay protection mechanism: the EntryPoint maintains uniqueness of nonces per user account.
* MUST pay the entryPoint (caller) at least the "missingAccountFunds" (which might be zero, in case current account's deposit is high enough)
* The account MAY pay more than this minimum, to cover future transactions (it can always issue `withdrawTo` to retrieve it)
* The return value MUST be packed of `authorizer`, `validUntil` and `validAfter` timestamps.
@@ -182,6 +183,7 @@ The entry point's `handleOps` function must perform the following steps (we firs
* **Create the account if it does not yet exist**, using the initcode provided in the `UserOperation`. If the account does not exist, _and_ the initcode is empty, or does not deploy a contract at the "sender" address, the call must fail.
* **Call `validateUserOp` on the account**, passing in the `UserOperation`, the required fee and aggregator (if there is one). The account should verify the operation's signature, and pay the fee if the account considers the operation valid. If any `validateUserOp` call fails, `handleOps` must skip execution of at least that operation, and may revert entirely.
* Validate the account's deposit in the entryPoint is high enough to cover the max possible cost (cover the already-done verification and max execution gas)
* Validate the nonce uniqueness. see [Keep Nonce Uniqueness](#keep-nonce-uniqueness) below
In the execution loop, the `handleOps` call must perform the following steps for each `UserOperation`:
@@ -192,6 +194,17 @@ In the execution loop, the `handleOps` call must perform the following steps for
Before accepting a `UserOperation`, bundlers should use an RPC method to locally call the `simulateValidation` function of the entry point, to verify that the signature is correct and the operation actually pays fees; see the [Simulation section below](#simulation) for details.
A node/bundler SHOULD drop (not add to the mempool) a `UserOperation` that fails the validation
### Keep Nonce Uniqueness
The EntryPoint maintains nonce uniqueness for each submitted UserOperation using the following algorithm:
* The nonce is treated as 2 separate fields:
* 64-bit "sequence"
* 192-bit "key"
* Within each "key", the "sequence" value must have consecutive values, starting with zero.
* That is, a nonce with a new "key" value is allowed, as long as the "sequence" part is zero. The next nonce for that key must be "1", and so on.
* The EntryPoint exports a method `getNonce(address sender, uint192 key)` to return the next valid nonce for this key.
* The behaviour of a "classic" sequential nonce can be achieved by validating that the "key" part is always zero.
### Extension: paymasters
We extend the entry point logic to support **paymasters** that can sponsor transactions for other users. This feature can be used to allow application developers to subsidize fees for their users, allow users to pay fees with [ERC-20](./eip-20.md) tokens and many other use cases. When the paymaster is not equal to the zero address, the entry point implements a different flow:
@@ -907,7 +920,7 @@ See `https://github.com/eth-infinitism/account-abstraction/tree/main/contracts`
## Security Considerations
The entry point contract will need to be very heavily audited and formally verified, because it will serve as a central trust point for _all_ [ERC-4337](./eip-4337.md). In total, this architecture reduces auditing and formal verification load for the ecosystem, because the amount of work that individual _accounts_ have to do becomes much smaller (they need only verify the `validateUserOp` function and its "check signature, increment nonce and pay fees" logic) and check that other functions are `msg.sender == ENTRY_POINT` gated (perhaps also allowing `msg.sender == self`), but it is nevertheless the case that this is done precisely by concentrating security risk in the entry point contract that needs to be verified to be very robust.
The entry point contract will need to be very heavily audited and formally verified, because it will serve as a central trust point for _all_ [EIP-4337](./eip-4337.md). In total, this architecture reduces auditing and formal verification load for the ecosystem, because the amount of work that individual _accounts_ have to do becomes much smaller (they need only verify the `validateUserOp` function and its "check signature, and pay fees" logic) and check that other functions are `msg.sender == ENTRY_POINT` gated (perhaps also allowing `msg.sender == self`), but it is nevertheless the case that this is done precisely by concentrating security risk in the entry point contract that needs to be verified to be very robust.
Verification would need to cover two primary claims (not including claims needed to protect paymasters, and claims needed to establish p2p-level DoS resistance):

View File

@@ -14,12 +14,13 @@ import {
} from '../typechain'
import { BigNumberish, Wallet } from 'ethers'
import hre from 'hardhat'
import { fillAndSign } from '../test/UserOp'
import { fillAndSign, fillUserOp, signUserOp } from '../test/UserOp'
import { TransactionReceipt } from '@ethersproject/abstract-provider'
import { table, TableUserConfig } from 'table'
import { Create2Factory } from '../src/Create2Factory'
import * as fs from 'fs'
import { SimpleAccountInterface } from '../typechain/contracts/samples/SimpleAccount'
import { UserOperation } from '../test/UserOperation'
const gasCheckerLogFile = './reports/gas-checker.txt'
@@ -111,6 +112,8 @@ export class GasChecker {
])
}
createdAccounts = new Set<string>()
/**
* create accounts up to this counter.
* make sure they all have balance.
@@ -127,11 +130,29 @@ export class GasChecker {
console.log('factaddr', factoryAddress)
const fact = SimpleAccountFactory__factory.connect(factoryAddress, ethersSigner)
// create accounts
const creationOps: UserOperation[] = []
for (const n of range(count)) {
const salt = n
// const initCode = this.accountInitCode(fact, salt)
const addr = await fact.getAddress(this.accountOwner.address, salt)
if (!this.createdAccounts.has(addr)) {
// explicit call to fillUseROp with no "entryPoint", to make sure we manually fill everything and
// not attempt to fill from blockchain.
const op = signUserOp(await fillUserOp({
sender: addr,
nonce: 0,
callGasLimit: 30000,
verificationGasLimit: 1000000,
// paymasterAndData: paymaster,
preVerificationGas: 1,
maxFeePerGas: 0
}), this.accountOwner, this.entryPoint().address, await provider.getNetwork().then(net => net.chainId))
creationOps.push(op)
this.createdAccounts.add(addr)
}
this.accounts[addr] = this.accountOwner
// deploy if not already deployed.
await fact.createAccount(this.accountOwner.address, salt)
@@ -140,6 +161,7 @@ export class GasChecker {
await GasCheckCollector.inst.entryPoint.depositTo(addr, { value: minDepositOrBalance.mul(5) })
}
}
await this.entryPoint().handleOps(creationOps, ethersSigner.getAddress())
}
/**
@@ -238,7 +260,9 @@ export class GasChecker {
title: info.title
// receipt: rcpt
}
if (info.diffLastGas) { ret1.gasDiff = gasDiff }
if (info.diffLastGas) {
ret1.gasDiff = gasDiff
}
console.debug(ret1)
return ret1
}

View File

@@ -2,9 +2,9 @@
the destination is "account.entryPoint()", which is known to be "hot" address used by this account
it little higher than EOA call: its an exec from entrypoint (or account owner) into account contract, verifying msg.sender and exec to target)
╔══════════════════════════╤════════╗
║ gas estimate "simple" │ 28992
║ gas estimate "simple" │ 29014
╟──────────────────────────┼────────╢
║ gas estimate "big tx 5k" │ 125237
║ gas estimate "big tx 5k" │ 125260
╚══════════════════════════╧════════╝
╔════════════════════════════════╤═══════╤═══════════════╤════════════════╤═════════════════════╗
@@ -12,28 +12,28 @@
║ │ │ │ (delta for │ (compared to ║
║ │ │ │ one UserOp) │ account.exec()) ║
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ simple │ 1 │ 76822 │ │ ║
║ simple │ 1 │ 78743 │ │ ║
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ simple - diff from previous │ 2 │ │ 42237 │ 13245
║ simple - diff from previous │ 2 │ │ 44162 │ 15148
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ simple │ 10 │ 457444 │ │ ║
║ simple │ 10 │ 476282 │ │ ║
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ simple - diff from previous │ 11 │ │ 42465 │ 13473
║ simple - diff from previous │ 11 │ │ 44174 │ 15160
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ simple paymaster │ 1 │ 83105 │ │ ║
║ simple paymaster │ 1 │ 85002 │ │ ║
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ simple paymaster with diff │ 2 │ │ 41274 │ 12282
║ simple paymaster with diff │ 2 │ │ 43139 │ 14125
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ simple paymaster │ 10 │ 454764 │ │ ║
║ simple paymaster │ 10 │ 473554 │ │ ║
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ simple paymaster with diff │ 11 │ │ 41429 │ 12437
║ simple paymaster with diff │ 11 │ │ 43150 │ 14136
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ big tx 5k │ 1 │ 177879 │ │ ║
║ big tx 5k │ 1 │ 179788 │ │ ║
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ big tx - diff from previous │ 2 │ │ 142808 │ 17571
║ big tx - diff from previous │ 2 │ │ 144673 │ 19413
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ big tx 5k │ 10 │ 1463196 │ │ ║
║ big tx 5k │ 10 │ 1481914 │ │ ║
╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢
║ big tx - diff from previous │ 11 │ │ 142893 │ 17656
║ big tx - diff from previous │ 11 │ │ 144698 │ 19438
╚════════════════════════════════╧═══════╧═══════════════╧════════════════╧═════════════════════╝

View File

@@ -41,8 +41,8 @@ export function packUserOp1 (op: UserOperation): string {
'bytes32', // initCode
'bytes32', // callData
'uint256', // callGasLimit
'uint', // verificationGasLimit
'uint', // preVerificationGas
'uint256', // verificationGasLimit
'uint256', // preVerificationGas
'uint256', // maxFeePerGas
'uint256', // maxPriorityFeePerGas
'bytes32' // paymasterAndData
@@ -74,7 +74,7 @@ export const DefaultsForUserOp: UserOperation = {
initCode: '0x',
callData: '0x',
callGasLimit: 0,
verificationGasLimit: 100000, // default verification gas. will add create2 cost (3200+200*length) if initCode exists
verificationGasLimit: 150000, // default verification gas. will add create2 cost (3200+200*length) if initCode exists
preVerificationGas: 21000, // should also cover calldata cost.
maxFeePerGas: 0,
maxPriorityFeePerGas: 1e9,
@@ -119,13 +119,13 @@ export function fillUserOpDefaults (op: Partial<UserOperation>, defaults = Defau
// - calculate sender by eth_call the deployment code
// - default verificationGasLimit estimateGas of deployment code plus default 100000
// no initCode:
// - update nonce from account.nonce()
// - update nonce from account.getNonce()
// entryPoint param is only required to fill in "sender address when specifying "initCode"
// nonce: assume contract as "nonce()" function, and fill in.
// nonce: assume contract as "getNonce()" function, and fill in.
// sender - only in case of construction: fill sender from initCode.
// callGasLimit: VERY crude estimation (by estimating call to account, and add rough entryPoint overhead
// verificationGasLimit: hard-code default at 100k. should add "create2" cost
export async function fillUserOp (op: Partial<UserOperation>, entryPoint?: EntryPoint): Promise<UserOperation> {
export async function fillUserOp (op: Partial<UserOperation>, entryPoint?: EntryPoint, getNonceFunction = 'getNonce'): Promise<UserOperation> {
const op1 = { ...op }
const provider = entryPoint?.provider
if (op.initCode != null) {
@@ -157,8 +157,8 @@ export async function fillUserOp (op: Partial<UserOperation>, entryPoint?: Entry
}
if (op1.nonce == null) {
if (provider == null) throw new Error('must have entryPoint to autofill nonce')
const c = new Contract(op.sender!, ['function nonce() view returns(uint256)'], provider)
op1.nonce = await c.nonce().catch(rethrow())
const c = new Contract(op.sender!, [`function ${getNonceFunction}() view returns(uint256)`], provider)
op1.nonce = await c[getNonceFunction]().catch(rethrow())
}
if (op1.callGasLimit == null && op.callData != null) {
if (provider == null) throw new Error('must have entryPoint for callGasLimit estimate')
@@ -191,9 +191,9 @@ export async function fillUserOp (op: Partial<UserOperation>, entryPoint?: Entry
return op2
}
export async function fillAndSign (op: Partial<UserOperation>, signer: Wallet | Signer, entryPoint?: EntryPoint): Promise<UserOperation> {
export async function fillAndSign (op: Partial<UserOperation>, signer: Wallet | Signer, entryPoint?: EntryPoint, getNonceFunction = 'getNonce'): Promise<UserOperation> {
const provider = entryPoint?.provider
const op2 = await fillUserOp(op, entryPoint)
const op2 = await fillUserOp(op, entryPoint, getNonceFunction)
const chainId = await provider!.getNetwork().then(net => net.chainId)
const message = arrayify(getUserOpHash(op2, entryPoint!.address, chainId))

View File

@@ -233,7 +233,7 @@ describe('EntryPoint', function () {
// using wrong nonce
const op = await fillAndSign({ sender: account.address, nonce: 1234 }, accountOwner, entryPoint)
await expect(entryPoint.callStatic.simulateValidation(op)).to
.revertedWith('AA23 reverted: account: invalid nonce')
.revertedWith('AA25 invalid account nonce')
})
it('should report signature failure without revert', async () => {
@@ -411,7 +411,8 @@ describe('EntryPoint', function () {
const userOp: UserOperation = {
sender: maliciousAccount.address,
nonce: block.baseFeePerGas,
nonce: await entryPoint.getNonce(maliciousAccount.address, 0),
signature: defaultAbiCoder.encode(['uint256'], [block.baseFeePerGas]),
initCode: '0x',
callData: '0x',
callGasLimit: '0x' + 1e5.toString(16),
@@ -420,8 +421,7 @@ describe('EntryPoint', function () {
// we need maxFeeperGas > block.basefee + maxPriorityFeePerGas so requiredPrefund onchain is basefee + maxPriorityFeePerGas
maxFeePerGas: block.baseFeePerGas.mul(3),
maxPriorityFeePerGas: block.baseFeePerGas,
paymasterAndData: '0x',
signature: '0x'
paymasterAndData: '0x'
}
try {
await expect(entryPoint.simulateValidation(userOp, { gasLimit: 1e6 }))
@@ -447,13 +447,14 @@ describe('EntryPoint', function () {
sender: testRevertAccount.address,
callGasLimit: 1e5,
maxFeePerGas: 1,
nonce: await entryPoint.getNonce(testRevertAccount.address, 0),
verificationGasLimit: 1e5,
callData: badData.data!
}
const beneficiaryAddress = createAddress()
await expect(entryPoint.simulateValidation(badOp, { gasLimit: 3e5 }))
.to.revertedWith('ValidationResult')
const tx = await entryPoint.handleOps([badOp], beneficiaryAddress, { gasLimit: 3e5 })
const tx = await entryPoint.handleOps([badOp], beneficiaryAddress) // { gasLimit: 3e5 })
const receipt = await tx.wait()
const userOperationRevertReasonEvent = receipt.events?.find(event => event.event === 'UserOperationRevertReason')
expect(userOperationRevertReasonEvent?.event).to.equal('UserOperationRevertReason')
@@ -510,6 +511,71 @@ describe('EntryPoint', function () {
})
})
describe('2d nonces', () => {
const beneficiaryAddress = createAddress()
let sender: string
const key = 1
const keyShifted = BigNumber.from(key).shl(64)
before(async () => {
const { proxy } = await createAccount(ethersSigner, accountOwner.address, entryPoint.address)
sender = proxy.address
await fund(sender)
})
it('should fail nonce with new key and seq!=0', async () => {
const op = await fillAndSign({
sender,
nonce: keyShifted.add(1)
}, accountOwner, entryPoint)
await expect(entryPoint.callStatic.handleOps([op], beneficiaryAddress)).to.revertedWith('AA25 invalid account nonce')
})
describe('with key=1, seq=1', () => {
before(async () => {
const op = await fillAndSign({
sender,
nonce: keyShifted
}, accountOwner, entryPoint)
await entryPoint.handleOps([op], beneficiaryAddress)
})
it('should get next nonce value by getNonce', async () => {
expect(await entryPoint.getNonce(sender, key)).to.eql(keyShifted.add(1))
})
it('should allow to increment nonce of different key', async () => {
const op = await fillAndSign({
sender,
nonce: await entryPoint.getNonce(sender, key)
}, accountOwner, entryPoint)
await entryPoint.callStatic.handleOps([op], beneficiaryAddress)
})
it('should allow manual nonce increment', async () => {
// must be called from account itself
const incNonceKey = 5
const incrementCallData = entryPoint.interface.encodeFunctionData('incrementNonce', [incNonceKey])
const callData = account.interface.encodeFunctionData('execute', [entryPoint.address, 0, incrementCallData])
const op = await fillAndSign({
sender,
callData,
nonce: await entryPoint.getNonce(sender, key)
}, accountOwner, entryPoint)
await entryPoint.handleOps([op], beneficiaryAddress)
expect(await entryPoint.getNonce(sender, incNonceKey)).to.equal(BigNumber.from(incNonceKey).shl(64).add(1))
})
it('should fail with nonsequential seq', async () => {
const op = await fillAndSign({
sender,
nonce: keyShifted.add(3)
}, accountOwner, entryPoint)
await expect(entryPoint.callStatic.handleOps([op], beneficiaryAddress)).to.revertedWith('AA25 invalid account nonce')
})
})
})
describe('without paymaster (account pays in eth)', () => {
describe('#handleOps', () => {
let counter: TestCounter
@@ -1026,8 +1092,7 @@ describe('EntryPoint', function () {
addr = await entryPoint.callStatic.getSenderAddress(initCode).catch(e => e.errorArgs.sender)
await ethersSigner.sendTransaction({ to: addr, value: parseEther('0.1') })
userOp = await fillAndSign({
initCode,
nonce: 10
initCode
}, accountOwner, entryPoint)
})
it('simulateValidation should return aggregator and its stake', async () => {

View File

@@ -103,7 +103,7 @@ describe('Gnosis Proxy', function () {
it('should fail from wrong entrypoint', async function () {
const op = await fillAndSign({
sender: proxy.address
}, owner, entryPoint)
}, owner, entryPoint, 'getNonce')
const anotherEntryPoint = await new EntryPoint__factory(ethersSigner).deploy()
@@ -116,14 +116,14 @@ describe('Gnosis Proxy', function () {
nonce: 1234,
callGasLimit: 1e6,
callData: safe_execTxCallData
}, owner, entryPoint)
await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('account: invalid nonce')
}, owner, entryPoint, 'getNonce')
await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('AA25 invalid account nonce')
op = await fillAndSign({
sender: proxy.address,
callGasLimit: 1e6,
callData: safe_execTxCallData
}, owner, entryPoint)
}, owner, entryPoint, 'getNonce')
// invalidate the signature
op.callGasLimit = 1
await expect(entryPoint.handleOps([op], beneficiary)).to.revertedWith('FailedOp(0, "AA24 signature error")')
@@ -134,7 +134,7 @@ describe('Gnosis Proxy', function () {
sender: proxy.address,
callGasLimit: 1e6,
callData: safe_execTxCallData
}, owner, entryPoint)
}, owner, entryPoint, 'getNonce')
const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait())
console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash)
@@ -151,7 +151,8 @@ describe('Gnosis Proxy', function () {
sender: proxy.address,
callGasLimit: 1e6,
callData: safe_execFailTxCallData
}, owner, entryPoint)
}, owner, entryPoint, 'getNonce')
const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait())
console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash)
@@ -183,7 +184,7 @@ describe('Gnosis Proxy', function () {
sender: counterfactualAddress,
initCode,
verificationGasLimit: 400000
}, owner, entryPoint)
}, owner, entryPoint, 'getNonce')
const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait())
console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash)
@@ -200,7 +201,7 @@ describe('Gnosis Proxy', function () {
const op = await fillAndSign({
sender: counterfactualAddress,
callData: safe_execTxCallData
}, owner, entryPoint)
}, owner, entryPoint, 'getNonce')
const rcpt = await entryPoint.handleOps([op], beneficiary).then(async r => r.wait())
console.log('gasUsed=', rcpt.gasUsed, rcpt.transactionHash)

View File

@@ -2,31 +2,36 @@ import { Wallet } from 'ethers'
import { ethers } from 'hardhat'
import { expect } from 'chai'
import {
ERC1967Proxy__factory,
SimpleAccount,
SimpleAccountFactory__factory,
SimpleAccount__factory,
TestUtil,
TestUtil__factory
} from '../typechain'
import {
createAccount,
createAddress,
createAccountOwner,
deployEntryPoint,
getBalance,
isDeployed,
ONE_ETH,
createAccount, HashZero
HashZero
} from './testutils'
import { fillUserOpDefaults, getUserOpHash, packUserOp, signUserOp } from './UserOp'
import { parseEther } from 'ethers/lib/utils'
import { UserOperation } from './UserOperation'
describe('SimpleAccount', function () {
const entryPoint = '0x'.padEnd(42, '2')
let entryPoint: string
let accounts: string[]
let testUtil: TestUtil
let accountOwner: Wallet
const ethersSigner = ethers.provider.getSigner()
before(async function () {
entryPoint = await deployEntryPoint().then(e => e.address)
accounts = await ethers.provider.listAccounts()
// ignore in geth.. this is just a sanity test. should be refactored to use a single-account mode..
if (accounts.length < 2) this.skip()
@@ -59,11 +64,18 @@ describe('SimpleAccount', function () {
let expectedPay: number
const actualGasPrice = 1e9
// for testing directly validateUserOp, we initialize the account with EOA as entryPoint.
let entryPointEoa: string
before(async () => {
// that's the account of ethersSigner
const entryPoint = accounts[2];
({ proxy: account } = await createAccount(await ethers.getSigner(entryPoint), accountOwner.address, entryPoint))
entryPointEoa = accounts[2]
const epAsSigner = await ethers.getSigner(entryPointEoa)
// cant use "SimpleAccountFactory", since it attempts to increment nonce first
const implementation = await new SimpleAccount__factory(ethersSigner).deploy(entryPointEoa)
const proxy = await new ERC1967Proxy__factory(ethersSigner).deploy(implementation.address, '0x')
account = SimpleAccount__factory.connect(proxy.address, epAsSigner)
await ethersSigner.sendTransaction({ from: accounts[0], to: account.address, value: parseEther('0.2') })
const callGasLimit = 200000
const verificationGasLimit = 100000
@@ -75,9 +87,9 @@ describe('SimpleAccount', function () {
callGasLimit,
verificationGasLimit,
maxFeePerGas
}), accountOwner, entryPoint, chainId)
}), accountOwner, entryPointEoa, chainId)
userOpHash = await getUserOpHash(userOp, entryPoint, chainId)
userOpHash = await getUserOpHash(userOp, entryPointEoa, chainId)
expectedPay = actualGasPrice * (callGasLimit + verificationGasLimit)
@@ -91,20 +103,13 @@ describe('SimpleAccount', function () {
expect(preBalance - postBalance).to.eql(expectedPay)
})
it('should increment nonce', async () => {
expect(await account.nonce()).to.equal(1)
})
it('should reject same TX on nonce error', async () => {
await expect(account.validateUserOp(userOp, userOpHash, 0)).to.revertedWith('invalid nonce')
})
it('should return NO_SIG_VALIDATION on wrong signature', async () => {
const userOpHash = HashZero
const deadline = await account.callStatic.validateUserOp({ ...userOp, nonce: 1 }, userOpHash, 0)
expect(deadline).to.eq(1)
})
})
context('SimpleAccountFactory', () => {
it('sanity: check deployer', async () => {
const ownerAddr = createAddress()

View File

@@ -184,8 +184,7 @@ describe('bls account', function () {
await fund(senderAddress, '0.01')
const userOp = await fillUserOp({
sender: senderAddress,
initCode,
nonce: 2
initCode
}, entrypoint)
const requestHash = await blsAgg.getUserOpHash(userOp)
const sigParts = signer3.sign(requestHash)