diff --git a/packages/boba/account-abstraction/contracts/core/BaseAccount.sol b/packages/boba/account-abstraction/contracts/core/BaseAccount.sol index 6c06e9184e..95b2639914 100644 --- a/packages/boba/account-abstraction/contracts/core/BaseAccount.sol +++ b/packages/boba/account-abstraction/contracts/core/BaseAccount.sol @@ -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. diff --git a/packages/boba/account-abstraction/contracts/core/EntryPoint.sol b/packages/boba/account-abstraction/contracts/core/EntryPoint.sol index a25fa7d949..b5473a5e68 100644 --- a/packages/boba/account-abstraction/contracts/core/EntryPoint.sol +++ b/packages/boba/account-abstraction/contracts/core/EntryPoint.sol @@ -16,8 +16,10 @@ import "../utils/Exec.sol"; import "./StakeManager.sol"; import "./SenderCreator.sol"; import "./Helpers.sol"; +import "./NonceManager.sol"; +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; -contract EntryPoint is IEntryPoint, StakeManager { +contract EntryPoint is IEntryPoint, StakeManager, NonceManager, ReentrancyGuard { using UserOperationLib for UserOperation; @@ -87,7 +89,7 @@ contract EntryPoint is IEntryPoint, StakeManager { * @param ops the operations to execute * @param beneficiary the address to receive the fees */ - function handleOps(UserOperation[] calldata ops, address payable beneficiary) public { + function handleOps(UserOperation[] calldata ops, address payable beneficiary) public nonReentrant { uint256 opslen = ops.length; UserOpInfo[] memory opInfos = new UserOpInfo[](opslen); @@ -100,6 +102,7 @@ contract EntryPoint is IEntryPoint, StakeManager { } uint256 collected = 0; + emit BeforeExecution(); for (uint256 i = 0; i < opslen; i++) { collected += _executeUserOp(i, ops[i], opInfos[i]); @@ -117,7 +120,7 @@ contract EntryPoint is IEntryPoint, StakeManager { function handleAggregatedOps( UserOpsPerAggregator[] calldata opsPerAggregator, address payable beneficiary - ) public { + ) public nonReentrant { uint256 opasLen = opsPerAggregator.length; uint256 totalOps = 0; @@ -142,6 +145,8 @@ contract EntryPoint is IEntryPoint, StakeManager { UserOpInfo[] memory opInfos = new UserOpInfo[](totalOps); + emit BeforeExecution(); + uint256 opIndex = 0; for (uint256 a = 0; a < opasLen; a++) { UserOpsPerAggregator calldata opa = opsPerAggregator[a]; @@ -349,7 +354,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 +517,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(); diff --git a/packages/boba/account-abstraction/contracts/core/Helpers.sol b/packages/boba/account-abstraction/contracts/core/Helpers.sol index d0bb0c9196..ef362bb240 100644 --- a/packages/boba/account-abstraction/contracts/core/Helpers.sol +++ b/packages/boba/account-abstraction/contracts/core/Helpers.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.12; +/* solhint-disable no-inline-assembly */ + /** * returned data from validateUserOp. * validateUserOp returns a uint256, with is created by `_packedValidationData` and parsed by `_parseValidationData` @@ -63,3 +65,16 @@ pragma solidity ^0.8.12; function _packValidationData(bool sigFailed, uint48 validUntil, uint48 validAfter) pure returns (uint256) { return (sigFailed ? 1 : 0) | (uint256(validUntil) << 160) | (uint256(validAfter) << (160 + 48)); } + +/** + * keccak function over calldata. + * @dev copy calldata into memory, do keccak and drop allocated memory. Strangely, this is more efficient than letting solidity do it. + */ + function calldataKeccak(bytes calldata data) pure returns (bytes32 ret) { + assembly { + let mem := mload(0x40) + let len := data.length + calldatacopy(mem, data.offset, len) + ret := keccak256(mem, len) + } + } diff --git a/packages/boba/account-abstraction/contracts/core/NonceManager.sol b/packages/boba/account-abstraction/contracts/core/NonceManager.sol new file mode 100644 index 0000000000..f0bc20dbf4 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/core/NonceManager.sol @@ -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; + } + +} diff --git a/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol b/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol index c25288b38b..69ce75c8bd 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/IEntryPoint.sol @@ -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 @@ -45,6 +46,12 @@ interface IEntryPoint is IStakeManager { */ event UserOperationRevertReason(bytes32 indexed userOpHash, address indexed sender, uint256 nonce, bytes revertReason); + /** + * an event emitted by handleOps(), before starting the execution loop. + * any event emitted before this event, is part of the validation. + */ + event BeforeExecution(); + /** * signature aggregator used by the following UserOperationEvents within this bundle. */ diff --git a/packages/boba/account-abstraction/contracts/interfaces/INonceManager.sol b/packages/boba/account-abstraction/contracts/interfaces/INonceManager.sol new file mode 100644 index 0000000000..fe649130b2 --- /dev/null +++ b/packages/boba/account-abstraction/contracts/interfaces/INonceManager.sol @@ -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; +} diff --git a/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol b/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol index dfff42791f..437c276063 100644 --- a/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol +++ b/packages/boba/account-abstraction/contracts/interfaces/UserOperation.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.12; /* solhint-disable no-inline-assembly */ +import {calldataKeccak} from "../core/Helpers.sol"; + /** * User Operation struct * @param sender the sender account of this request. @@ -59,19 +61,24 @@ library UserOperationLib { } function pack(UserOperation calldata userOp) internal pure returns (bytes memory ret) { - //lighter signature scheme. must match UserOp.ts#packUserOp - bytes calldata sig = userOp.signature; - // copy directly the userOp from calldata up to (but not including) the signature. - // this encoding depends on the ABI encoding of calldata, but is much lighter to copy - // than referencing each field separately. - assembly { - let ofs := userOp - let len := sub(sub(sig.offset, ofs), 32) - ret := mload(0x40) - mstore(0x40, add(ret, add(len, 32))) - mstore(ret, len) - calldatacopy(add(ret, 32), ofs, len) - } + address sender = getSender(userOp); + uint256 nonce = userOp.nonce; + bytes32 hashInitCode = calldataKeccak(userOp.initCode); + bytes32 hashCallData = calldataKeccak(userOp.callData); + uint256 callGasLimit = userOp.callGasLimit; + uint256 verificationGasLimit = userOp.verificationGasLimit; + uint256 preVerificationGas = userOp.preVerificationGas; + uint256 maxFeePerGas = userOp.maxFeePerGas; + uint256 maxPriorityFeePerGas = userOp.maxPriorityFeePerGas; + bytes32 hashPaymasterAndData = calldataKeccak(userOp.paymasterAndData); + + return abi.encode( + sender, nonce, + hashInitCode, hashCallData, + callGasLimit, verificationGasLimit, preVerificationGas, + maxFeePerGas, maxPriorityFeePerGas, + hashPaymasterAndData + ); } function hash(UserOperation calldata userOp) internal pure returns (bytes32) { diff --git a/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol b/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol index 65fe9e68f2..6c1a198253 100644 --- a/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol +++ b/packages/boba/account-abstraction/contracts/samples/SimpleAccount.sol @@ -10,6 +10,7 @@ import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import "../core/BaseAccount.sol"; +import "./callback/TokenCallbackHandler.sol"; /** * minimal account. @@ -17,15 +18,9 @@ import "../core/BaseAccount.sol"; * has execute, eth handling methods * has a single signer that can send requests through the entryPoint. */ -contract SimpleAccount is BaseAccount, UUPSUpgradeable, Initializable { +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; @@ -37,11 +32,6 @@ contract SimpleAccount is BaseAccount, UUPSUpgradeable, Initializable { _; } - /// @inheritdoc BaseAccount - function nonce() public view virtual override returns (uint256) { - return _nonce; - } - /// @inheritdoc BaseAccount function entryPoint() public view virtual override returns (IEntryPoint) { return _entryPoint; @@ -99,11 +89,6 @@ contract SimpleAccount is BaseAccount, UUPSUpgradeable, Initializable { 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) { diff --git a/packages/boba/account-abstraction/contracts/samples/callback/TokenCallbackHandler.sol b/packages/boba/account-abstraction/contracts/samples/callback/TokenCallbackHandler.sol new file mode 100644 index 0000000000..d7ed9cbbbd --- /dev/null +++ b/packages/boba/account-abstraction/contracts/samples/callback/TokenCallbackHandler.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +/* solhint-disable no-empty-blocks */ + +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import "@openzeppelin/contracts/token/ERC777/IERC777Recipient.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; + +/** + * Token callback handler. + * Handles supported tokens' callbacks, allowing account receiving these tokens. + */ +contract TokenCallbackHandler is IERC777Recipient, IERC721Receiver, IERC1155Receiver { + function tokensReceived( + address, + address, + address, + uint256, + bytes calldata, + bytes calldata + ) external pure override { + } + + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external pure override returns (bytes4) { + return IERC721Receiver.onERC721Received.selector; + } + + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes calldata + ) external pure override returns (bytes4) { + return IERC1155Receiver.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, + address, + uint256[] calldata, + uint256[] calldata, + bytes calldata + ) external pure override returns (bytes4) { + return IERC1155Receiver.onERC1155BatchReceived.selector; + } + + function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { + return + interfaceId == type(IERC721Receiver).interfaceId || + interfaceId == type(IERC1155Receiver).interfaceId || + interfaceId == type(IERC165).interfaceId; + } +} diff --git a/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol index 3d1c8bd434..4cc0978d5c 100644 --- a/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol +++ b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Fallback.sol @@ -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 */ diff --git a/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol index bc3468638e..2a687ada50 100644 --- a/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol +++ b/packages/boba/account-abstraction/contracts/samples/gnosis/EIP4337Manager.sol @@ -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())); diff --git a/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol b/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol index d42e918ec2..5b840e4f05 100644 --- a/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol +++ b/packages/boba/account-abstraction/contracts/test/MaliciousAccount.sol @@ -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; } } diff --git a/packages/boba/account-abstraction/eip/EIPS/eip-4337.md b/packages/boba/account-abstraction/eip/EIPS/eip-4337.md index 4e9550864b..27c5f9e431 100644 --- a/packages/boba/account-abstraction/eip/EIPS/eip-4337.md +++ b/packages/boba/account-abstraction/eip/EIPS/eip-4337.md @@ -29,16 +29,16 @@ This proposal takes a different approach, avoiding any adjustments to the consen * **Try to support other use cases** * Privacy-preserving applications * Atomic multi-operations (similar goal to [EIP-3074](./eip-3074.md)) - * Pay tx fees with [EIP-20](./eip-20.md) tokens, allow developers to pay fees for their users, and [EIP-3074](./eip-3074.md)-like **sponsored transaction** use cases more generally + * Pay tx fees with [ERC-20](./eip-20.md) tokens, allow developers to pay fees for their users, and [EIP-3074](./eip-3074.md)-like **sponsored transaction** use cases more generally * Support aggregated signature (e.g. BLS) ## Specification ### Definitions -* **UserOperation** - a structure that describes a transaction to be sent on behalf of a user. To avoid confusion, it is named "UserOperation" instead of "transaction." +* **UserOperation** - a structure that describes a transaction to be sent on behalf of a user. To avoid confusion, it is not named "transaction". * Like a transaction, it contains "sender", "to", "calldata", "maxFeePerGas", "maxPriorityFee", "signature", "nonce" - * unlike transaction, it contains several other fields, described below + * unlike a transaction, it contains several other fields, described below * also, the "nonce" and "signature" fields usage is not defined by the protocol, but by each account implementation * **Sender** - the account contract sending a user operation. * **EntryPoint** - a singleton contract to execute bundles of UserOperations. Bundlers/Clients whitelist the supported entrypoint. @@ -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 | @@ -121,11 +121,14 @@ interface IAccount { } ``` -The `userOpHash` is a hash over the userOp (except signature), entryPoint and chainId. The account: +The `userOpHash` is a hash over the userOp (except signature), entryPoint and chainId. + +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. @@ -159,7 +162,7 @@ interface IAggregator { #### Using signature aggregators -An account signify it uses signature aggregation returning its address from `validateUserOp`. +An account signifies it uses signature aggregation returning its address from `validateUserOp`. During `simulateValidation`, this aggregator is returned (in the `ValidationResultWithAggregator`) The bundler should first accept the aggregator (validate its stake info and that it is not throttled/banned) @@ -180,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`: @@ -188,11 +192,22 @@ In the execution loop, the `handleOps` call must perform the following steps for ![](../assets/eip-4337/image1.png) 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 (and not add to the mempool) `UserOperation` that fails the validation +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 [EIP-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: +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: ![](../assets/eip-4337/image2.png) @@ -289,7 +304,6 @@ The simulated call performs the full validation, by calling: Either `validateUserOp` or `validatePaymasterUserOp` may return a "validAfter" and "validUntil" timestamps, which is the time-range that this UserOperation is valid on-chain. The simulateValidation call returns this range. A node MAY drop a UserOperation if it expires too soon (e.g. wouldn't make it to the next block) - If the `ValidationResult` includes `sigFail`, the client SHOULD drop the `UserOperation`. The operations differ in their opcode banning policy. @@ -320,7 +334,7 @@ An address `A` is associated with: 1. Slots of contract `A` address itself. 2. Slot `A` on any other address. -3. Slots of type `keccak256(A || X) + n` on any other address. (to cover `mapping(address => value)`, which is usually used for balance in EIP-20 tokens). +3. Slots of type `keccak256(A || X) + n` on any other address. (to cover `mapping(address => value)`, which is usually used for balance in ERC-20 tokens). `n` is an offset value up to 128, to allow accessing fields in the format `mapping(address => struct)` @@ -393,9 +407,9 @@ The value of MIN_STAKE_VALUE is determined per chain, and specified in the "bund Under the following special conditions, unstaked entities still can be used: -- An entity that doesn't use any storage at all, or only the senders's storage (not the entity's storage - that does require a stake) -- If the UserOp doesn't create a new account (that is initCode is empty), then the entity may also use [storage associated with the sender](#storage-associated-with-an-address)) -- A paymaster that has a “postOp()” method (that is, validatePaymasterUserOp returns “context”) must be staked +* An entity that doesn't use any storage at all, or only the senders's storage (not the entity's storage - that does require a stake) +* If the UserOp doesn't create a new account (that is initCode is empty), then the entity may also use [storage associated with the sender](#storage-associated-with-an-address)) +* A paymaster that has a “postOp()” method (that is, validatePaymasterUserOp returns “context”) must be staked #### Specification. @@ -456,10 +470,10 @@ The entry point-based approach allows for a clean separation between verificatio Paymasters facilitate transaction sponsorship, allowing third-party-designed mechanisms to pay for transactions. Many of these mechanisms _could_ be done by having the paymaster wrap a `UserOperation` with their own, but there are some important fundamental limitations to that approach: -* No possibility for "passive" paymasters (eg. that accept fees in some EIP-20 token at an exchange rate pulled from an on-chain DEX) +* No possibility for "passive" paymasters (eg. that accept fees in some ERC-20 token at an exchange rate pulled from an on-chain DEX) * Paymasters run the risk of getting griefed, as users could send ops that appear to pay the paymaster but then change their behavior after a block -The paymaster scheme allows a contract to passively pay on users' behalf under arbitrary conditions. It even allows EIP-20 token paymasters to secure a guarantee that they would only need to pay if the user pays them: the paymaster contract can check that there is sufficient approved EIP-20 balance in the `validatePaymasterUserOp` method, and then extract it with `transferFrom` in the `postOp` call; if the op itself transfers out or de-approves too much of the EIP-20s, the inner `postOp` will fail and revert the execution and the outer `postOp` can extract payment (note that because of storage access restrictions the EIP-20 would need to be a wrapper defined within the paymaster itself). +The paymaster scheme allows a contract to passively pay on users' behalf under arbitrary conditions. It even allows ERC-20 token paymasters to secure a guarantee that they would only need to pay if the user pays them: the paymaster contract can check that there is sufficient approved ERC-20 balance in the `validatePaymasterUserOp` method, and then extract it with `transferFrom` in the `postOp` call; if the op itself transfers out or de-approves too much of the ERC-20s, the inner `postOp` will fail and revert the execution and the outer `postOp` can extract payment (note that because of storage access restrictions the ERC-20 would need to be a wrapper defined within the paymaster itself). ### First-time account creation @@ -898,7 +912,7 @@ An array of reputation entries with the fields: ## Backwards Compatibility -This EIP does not change the consensus layer, so there are no backwards compatibility issues for Ethereum as a whole. Unfortunately it is not easily compatible with pre-[EIP-4337](./eip-4337.md) accounts, because those accounts do not have a `validateUserOp` function. If the account has a function for authorizing a trusted op submitter, then this could be fixed by creating an [EIP-4337](./eip-4337.md) compatible account that re-implements the verification logic as a wrapper and setting it to be the original account's trusted op submitter. +This EIP does not change the consensus layer, so there are no backwards compatibility issues for Ethereum as a whole. Unfortunately it is not easily compatible with pre-[ERC-4337](./eip-4337.md) accounts, because those accounts do not have a `validateUserOp` function. If the account has a function for authorizing a trusted op submitter, then this could be fixed by creating an [ERC-4337](./eip-4337.md) compatible account that re-implements the verification logic as a wrapper and setting it to be the original account's trusted op submitter. ## Reference Implementation @@ -906,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_ [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, 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): @@ -915,4 +929,4 @@ Verification would need to cover two primary claims (not including claims needed ## Copyright -Copyright and related rights waived via [CC0](../LICENSE.md). +Copyright and related rights waived via [CC0](../LICENSE.md). \ No newline at end of file diff --git a/packages/boba/account-abstraction/gascalc/GasChecker.ts b/packages/boba/account-abstraction/gascalc/GasChecker.ts index dc32b751ad..393e11742e 100644 --- a/packages/boba/account-abstraction/gascalc/GasChecker.ts +++ b/packages/boba/account-abstraction/gascalc/GasChecker.ts @@ -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' @@ -52,7 +53,7 @@ interface GasTestInfo { export const DefaultGasTestInfo: Partial = { dest: 'self', // destination is the account itself. destValue: parseEther('0'), - destCallData: '0xaffed0e0', // nonce() + destCallData: '0xb0d691fe', // entryPoint() gasPrice: 10e9 } @@ -111,6 +112,8 @@ export class GasChecker { ]) } + createdAccounts = new Set() + /** * create accounts up to this counter. * make sure they all have balance. @@ -123,15 +126,33 @@ export class GasChecker { hexConcat([ SimpleAccountFactory__factory.bytecode, defaultAbiCoder.encode(['address'], [this.entryPoint().address]) - ]), 0, 2708636) + ]), 0, 2885201) 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 } @@ -338,15 +362,17 @@ export class GasCheckCollector { fs.appendFileSync(gasCheckerLogFile, s + '\n') } - write('== gas estimate of direct calling the account\'s "execFromEntryPoint" method') - write(' the destination is "account.nonce()", which is known to be "hot" address used by this account') + write('== gas estimate of direct calling the account\'s "execute" method') + write(' the destination is "account.entryPoint()", which is known to be "hot" address used by this account') write(' it little higher than EOA call: its an exec from entrypoint (or account owner) into account contract, verifying msg.sender and exec to target)') - Object.values(gasEstimatePerExec).forEach(({ title, accountEst }) => { - write(`- gas estimate "${title}" - ${accountEst}`) - }) + + write(table(Object.values(gasEstimatePerExec).map((row) => [ + `gas estimate "${row.title}"`, row.accountEst + ]), this.tableConfig)) const tableOutput = table(this.tabRows, this.tableConfig) write(tableOutput) + process.exit(0) } addRow (res: GasTestResult): void { diff --git a/packages/boba/account-abstraction/reports/gas-checker.txt b/packages/boba/account-abstraction/reports/gas-checker.txt index b01ac4e819..17dea2e6f5 100644 --- a/packages/boba/account-abstraction/reports/gas-checker.txt +++ b/packages/boba/account-abstraction/reports/gas-checker.txt @@ -1,35 +1,39 @@ -== gas estimate of direct calling the account's "execFromEntryPoint" method - the destination is "account.nonce()", which is known to be "hot" address used by this account +== gas estimate of direct calling the account's "execute" method + 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" - 31033 -- gas estimate "big tx 5k" - 127284 +╔══════════════════════════╤════════╗ +║ gas estimate "simple" │ 29014 ║ +╟──────────────────────────┼────────╢ +║ gas estimate "big tx 5k" │ 125260 ║ +╚══════════════════════════╧════════╝ + ╔════════════════════════════════╤═══════╤═══════════════╤════════════════╤═════════════════════╗ ║ handleOps description │ count │ total gasUsed │ per UserOp gas │ per UserOp overhead ║ ║ │ │ │ (delta for │ (compared to ║ ║ │ │ │ one UserOp) │ account.exec()) ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple │ 1 │ 75948 │ │ ║ +║ simple │ 1 │ 81901 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple - diff from previous │ 2 │ │ 41449 │ 10416 ║ +║ simple - diff from previous │ 2 │ │ 44212 │ 15198 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple │ 10 │ 449053 │ │ ║ +║ simple │ 10 │ 479854 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple - diff from previous │ 11 │ │ 41560 │ 10527 ║ +║ simple - diff from previous │ 11 │ │ 44236 │ 15222 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster │ 1 │ 82245 │ │ ║ +║ simple paymaster │ 1 │ 88172 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster with diff │ 2 │ │ 40408 │ 9375 ║ +║ simple paymaster with diff │ 2 │ │ 43165 │ 14151 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster │ 10 │ 446306 │ │ ║ +║ simple paymaster │ 10 │ 476994 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ simple paymaster with diff │ 11 │ │ 40604 │ 9571 ║ +║ simple paymaster with diff │ 11 │ │ 43260 │ 14246 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx 5k │ 1 │ 177693 │ │ ║ +║ big tx 5k │ 1 │ 182958 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx - diff from previous │ 2 │ │ 142772 │ 15488 ║ +║ big tx - diff from previous │ 2 │ │ 144723 │ 19463 ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx 5k │ 10 │ 1468115 │ │ ║ +║ big tx 5k │ 10 │ 1485438 │ │ ║ ╟────────────────────────────────┼───────┼───────────────┼────────────────┼─────────────────────╢ -║ big tx - diff from previous │ 11 │ │ 144290 │ 17006 ║ +║ big tx - diff from previous │ 11 │ │ 144712 │ 19452 ║ ╚════════════════════════════════╧═══════╧═══════════════╧════════════════╧═════════════════════╝ diff --git a/packages/boba/account-abstraction/test/UserOp.ts b/packages/boba/account-abstraction/test/UserOp.ts index 0872d477f5..c391f1e9a6 100644 --- a/packages/boba/account-abstraction/test/UserOp.ts +++ b/packages/boba/account-abstraction/test/UserOp.ts @@ -13,66 +13,25 @@ import { import { UserOperation } from './UserOperation' import { Create2Factory } from '../src/Create2Factory' -function encode (typevalues: Array<{ type: string, val: any }>, forSignature: boolean): string { - const types = typevalues.map(typevalue => typevalue.type === 'bytes' && forSignature ? 'bytes32' : typevalue.type) - const values = typevalues.map((typevalue) => typevalue.type === 'bytes' && forSignature ? keccak256(typevalue.val) : typevalue.val) - return defaultAbiCoder.encode(types, values) -} - -// export function packUserOp(op: UserOperation, hashBytes = true): string { -// if ( !hashBytes || true ) { -// return packUserOp1(op, hashBytes) -// } -// -// const opEncoding = Object.values(testUtil.interface.functions).find(func => func.name == 'packUserOp')!.inputs[0] -// let packed = defaultAbiCoder.encode([opEncoding], [{...op, signature:'0x'}]) -// packed = '0x'+packed.slice(64+2) //skip first dword (length) -// packed = packed.slice(0,packed.length-64) //remove signature (the zero-length) -// return packed -// } - export function packUserOp (op: UserOperation, forSignature = true): string { if (forSignature) { - // lighter signature scheme (must match UserOperation#pack): do encode a zero-length signature, but strip afterwards the appended zero-length value - const userOpType = { - components: [ - { type: 'address', name: 'sender' }, - { type: 'uint256', name: 'nonce' }, - { type: 'bytes', name: 'initCode' }, - { type: 'bytes', name: 'callData' }, - { type: 'uint256', name: 'callGasLimit' }, - { type: 'uint256', name: 'verificationGasLimit' }, - { type: 'uint256', name: 'preVerificationGas' }, - { type: 'uint256', name: 'maxFeePerGas' }, - { type: 'uint256', name: 'maxPriorityFeePerGas' }, - { type: 'bytes', name: 'paymasterAndData' }, - { type: 'bytes', name: 'signature' } - ], - name: 'userOp', - type: 'tuple' - } - let encoded = defaultAbiCoder.encode([userOpType as any], [{ ...op, signature: '0x' }]) - // remove leading word (total length) and trailing word (zero-length signature) - encoded = '0x' + encoded.slice(66, encoded.length - 64) - return encoded - } - const typevalues = [ - { type: 'address', val: op.sender }, - { type: 'uint256', val: op.nonce }, - { type: 'bytes', val: op.initCode }, - { type: 'bytes', val: op.callData }, - { type: 'uint256', val: op.callGasLimit }, - { type: 'uint256', val: op.verificationGasLimit }, - { type: 'uint256', val: op.preVerificationGas }, - { type: 'uint256', val: op.maxFeePerGas }, - { type: 'uint256', val: op.maxPriorityFeePerGas }, - { type: 'bytes', val: op.paymasterAndData } - ] - if (!forSignature) { - // for the purpose of calculating gas cost, also hash signature - typevalues.push({ type: 'bytes', val: op.signature }) + return defaultAbiCoder.encode( + ['address', 'uint256', 'bytes32', 'bytes32', + 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', + 'bytes32'], + [op.sender, op.nonce, keccak256(op.initCode), keccak256(op.callData), + op.callGasLimit, op.verificationGasLimit, op.preVerificationGas, op.maxFeePerGas, op.maxPriorityFeePerGas, + keccak256(op.paymasterAndData)]) + } else { + // for the purpose of calculating gas cost encode also signature (and no keccak of bytes) + return defaultAbiCoder.encode( + ['address', 'uint256', 'bytes', 'bytes', + 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', + 'bytes', 'bytes'], + [op.sender, op.nonce, op.initCode, op.callData, + op.callGasLimit, op.verificationGasLimit, op.preVerificationGas, op.maxFeePerGas, op.maxPriorityFeePerGas, + op.paymasterAndData, op.signature]) } - return encode(typevalues, forSignature) } export function packUserOp1 (op: UserOperation): string { @@ -82,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 @@ -115,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, @@ -160,13 +119,13 @@ export function fillUserOpDefaults (op: Partial, 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, entryPoint?: EntryPoint): Promise { +export async function fillUserOp (op: Partial, entryPoint?: EntryPoint, getNonceFunction = 'getNonce'): Promise { const op1 = { ...op } const provider = entryPoint?.provider if (op.initCode != null) { @@ -198,8 +157,8 @@ export async function fillUserOp (op: Partial, 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(address)'], 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') @@ -232,9 +191,9 @@ export async function fillUserOp (op: Partial, entryPoint?: Entry return op2 } -export async function fillAndSign (op: Partial, signer: Wallet | Signer, entryPoint?: EntryPoint): Promise { +export async function fillAndSign (op: Partial, signer: Wallet | Signer, entryPoint?: EntryPoint, getNonceFunction = 'getNonce'): Promise { 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)) diff --git a/packages/boba/account-abstraction/test/entrypoint.test.ts b/packages/boba/account-abstraction/test/entrypoint.test.ts index d4502b44a6..a3f06d9132 100644 --- a/packages/boba/account-abstraction/test/entrypoint.test.ts +++ b/packages/boba/account-abstraction/test/entrypoint.test.ts @@ -42,7 +42,8 @@ import { simulationResultCatch, createAccount, getAggregatedAccountInitCode, - simulationResultWithAggregationCatch + simulationResultWithAggregationCatch, + decodeRevertReason } from './testutils' import { DefaultsForUserOp, fillAndSign, getUserOpHash } from './UserOp' import { UserOperation } from './UserOperation' @@ -233,7 +234,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 +412,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 +422,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 +448,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 +512,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 @@ -717,6 +784,24 @@ describe('EntryPoint', function () { await calcGasUsage(rcpt, entryPoint, beneficiaryAddress) }) + it('should fail to call recursively into handleOps', async () => { + const beneficiaryAddress = createAddress() + + const callHandleOps = entryPoint.interface.encodeFunctionData('handleOps', [[], beneficiaryAddress]) + const execHandlePost = account.interface.encodeFunctionData('execute', [entryPoint.address, 0, callHandleOps]) + const op = await fillAndSign({ + sender: account.address, + callData: execHandlePost + }, accountOwner, entryPoint) + + const rcpt = await entryPoint.handleOps([op], beneficiaryAddress, { + gasLimit: 1e7 + }).then(async r => r.wait()) + + const error = rcpt.events?.find(ev => ev.event === 'UserOperationRevertReason') + expect(decodeRevertReason(error?.args?.revertReason)).to.eql('Error(ReentrancyGuard: reentrant call)', 'execution of handleOps inside a UserOp should revert') + }) + it('should report failure on insufficient verificationGas after creation', async () => { const op0 = await fillAndSign({ sender: account.address, @@ -1026,8 +1111,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 () => { diff --git a/packages/boba/account-abstraction/test/gnosis.test.ts b/packages/boba/account-abstraction/test/gnosis.test.ts index 784b7f914b..24819edf06 100644 --- a/packages/boba/account-abstraction/test/gnosis.test.ts +++ b/packages/boba/account-abstraction/test/gnosis.test.ts @@ -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) diff --git a/packages/boba/account-abstraction/test/simple-wallet.test.ts b/packages/boba/account-abstraction/test/simple-wallet.test.ts index d5350e0eb6..1a57054d36 100644 --- a/packages/boba/account-abstraction/test/simple-wallet.test.ts +++ b/packages/boba/account-abstraction/test/simple-wallet.test.ts @@ -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() diff --git a/packages/boba/account-abstraction/test/y.bls.test.ts b/packages/boba/account-abstraction/test/y.bls.test.ts index 16cfcc42a0..6dae29ee09 100644 --- a/packages/boba/account-abstraction/test/y.bls.test.ts +++ b/packages/boba/account-abstraction/test/y.bls.test.ts @@ -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) diff --git a/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol b/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol index ae59961fb9..9497ebdff0 100644 --- a/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol +++ b/packages/boba/bundler/contracts/tests/TestOpcodesAccount.sol @@ -16,6 +16,7 @@ contract Dummy { contract TestOpcodesAccount is TestRuleAccount { event TestMessage(address eventSender); + event ExecutionMessage(); function runRule(string memory rule) public virtual override returns (uint) { if (eq(rule, "number")) return block.number; @@ -31,6 +32,10 @@ contract TestOpcodesAccount is TestRuleAccount { } return super.runRule(rule); } + + function execEvent() public { + emit ExecutionMessage(); + } } contract TestOpcodesAccountFactory { diff --git a/packages/boba/bundler/contracts/tests/TestRulesAccount.sol b/packages/boba/bundler/contracts/tests/TestRulesAccount.sol index d0c5256ddf..91c12f08cf 100644 --- a/packages/boba/bundler/contracts/tests/TestRulesAccount.sol +++ b/packages/boba/bundler/contracts/tests/TestRulesAccount.sol @@ -31,7 +31,12 @@ contract TestRulesAccount is IAccount, IPaymaster { return keccak256(bytes(a)) == keccak256(bytes(b)); } - event TestMessage(address eventSender); + event TestFromValidation(); + event TestMessage(); + + function execSendMessage() public { + emit TestMessage(); + } function runRule(string memory rule) public returns (uint) { if (eq(rule, "")) return 0; @@ -50,7 +55,7 @@ contract TestRulesAccount is IAccount, IPaymaster { else if (eq(rule, "inner-revert")) return coin.reverting(); else if (eq(rule, "emit-msg")) { - emit TestMessage(address(this)); + emit TestFromValidation(); return 0;} revert(string.concat("unknown rule: ", rule)); diff --git a/packages/boba/bundler/src/UserOpMethodHandler.ts b/packages/boba/bundler/src/UserOpMethodHandler.ts index 8d2bbe4e06..4ca2a2824e 100644 --- a/packages/boba/bundler/src/UserOpMethodHandler.ts +++ b/packages/boba/bundler/src/UserOpMethodHandler.ts @@ -240,8 +240,14 @@ export class UserOpMethodHandler { _filterLogs(userOpEvent: UserOperationEventEvent, logs: Log[]): Log[] { let startIndex = -1 let endIndex = -1 + const events = Object.values(this.entryPoint.interface.events) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const beforeExecutionTopic = this.entryPoint.interface.getEventTopic(events.find(e => e.name === 'BeforeExecution')!) logs.forEach((log, index) => { - if (log?.topics[0] === userOpEvent.topics[0]) { + if (log?.topics[0] === beforeExecutionTopic) { + // all UserOp execution events start after the "BeforeExecution" event. + startIndex = endIndex = index + } else if (log?.topics[0] === userOpEvent.topics[0]) { // process UserOperationEvent if (log.topics[1] === userOpEvent.topics[1]) { // it's our userOpHash. save as end of logs array diff --git a/packages/boba/bundler/src/runner/runop.ts b/packages/boba/bundler/src/runner/runop.ts index 5d1fa8b151..2860027256 100644 --- a/packages/boba/bundler/src/runner/runop.ts +++ b/packages/boba/bundler/src/runner/runop.ts @@ -240,7 +240,7 @@ async function main(): Promise { } const dest = addr - const data = keccak256(Buffer.from('nonce()')).slice(0, 10) + const data = keccak256(Buffer.from('entryPoint()')).slice(0, 10) console.log('data=', data) await client.runUserOp(dest, data) console.log('after run1') diff --git a/packages/boba/bundler/test/UserOpMethodHandler.test.ts b/packages/boba/bundler/test/UserOpMethodHandler.test.ts index ced96aadbf..b3b4f1e37f 100644 --- a/packages/boba/bundler/test/UserOpMethodHandler.test.ts +++ b/packages/boba/bundler/test/UserOpMethodHandler.test.ts @@ -16,7 +16,7 @@ import { Signer, Wallet } from 'ethers' import { DeterministicDeployer, SimpleAccountAPI } from '@boba/bundler_sdk' import { postExecutionDump } from '@boba/bundler_utils/dist/postExecCheck' import { - SampleRecipient, TestRuleAccount, TestOpcodesAccount__factory + SampleRecipient, TestRulesAccount, TestRulesAccount__factory } from '../dist/src/types' import { resolveHexlify } from '@boba/bundler_utils' import { UserOperationEventEvent } from '@boba/accountabstraction' @@ -166,20 +166,21 @@ describe('UserOpMethodHandler', function () { const transactionReceipt = await event!.getTransactionReceipt() assert.isNotNull(transactionReceipt) const logs = transactionReceipt.logs.filter(log => log.address === entryPoint.address) - const deployedEvent = entryPoint.interface.parseLog(logs[0]) - const depositedEvent = entryPoint.interface.parseLog(logs[1]) + .map(log => entryPoint.interface.parseLog(log)) + expect(logs.map(log => log.name)).to.eql([ + 'AccountDeployed', + 'Deposited', + 'BeforeExecution', + 'UserOperationEvent' + ]) const [senderEvent] = await sampleRecipient.queryFilter(sampleRecipient.filters.Sender(), transactionReceipt.blockHash) - const userOperationEvent = entryPoint.interface.parseLog(logs[2]) + const userOperationEvent = logs[3] - assert.equal(deployedEvent.args.sender, userOperation.sender) - assert.equal(userOperationEvent.name, 'UserOperationEvent') assert.equal(userOperationEvent.args.success, true) const expectedTxOrigin = await methodHandler.signer.getAddress() assert.equal(senderEvent.args.txOrigin, expectedTxOrigin, 'sample origin should be bundler') assert.equal(senderEvent.args.msgSender, accountAddress, 'sample msgsender should be account address') - - assert.equal(depositedEvent.name, 'Deposited') }) it('getUserOperationByHash should return submitted UserOp', async () => { @@ -292,14 +293,15 @@ describe('UserOpMethodHandler', function () { describe('#getUserOperationReceipt', function () { let userOpHash: string let receipt: UserOperationReceipt - let acc: TestRuleAccount + let acc: TestRulesAccount before(async () => { - acc = await new TestOpcodesAccount__factory(signer).deploy() + acc = await new TestRulesAccount__factory(signer).deploy() + const callData = acc.interface.encodeFunctionData('execSendMessage') const op: UserOperationStruct = { sender: acc.address, initCode: '0x', nonce: 0, - callData: '0x', + callData, callGasLimit: 1e6, verificationGasLimit: 1e6, preVerificationGas: 50000, @@ -324,10 +326,9 @@ describe('UserOpMethodHandler', function () { expect(await methodHandler.getUserOperationReceipt(ethers.constants.HashZero)).to.equal(null) }) - it('receipt should contain only userOp-specific events..', async () => { + it('receipt should contain only userOp execution events..', async () => { expect(receipt.logs.length).to.equal(1) - const evParams = acc.interface.decodeEventLog('TestMessage', receipt.logs[0].data, receipt.logs[0].topics) - expect(evParams.eventSender).to.equal(acc.address) + acc.interface.decodeEventLog('TestMessage', receipt.logs[0].data, receipt.logs[0].topics) }) it('general receipt fields', () => { expect(receipt.success).to.equal(true) @@ -337,8 +338,22 @@ describe('UserOpMethodHandler', function () { // filter out BOR-specific events.. const logs = receipt.receipt.logs .filter(log => log.address !== '0x0000000000000000000000000000000000001010') - // one UserOperationEvent, and one op-specific event. - expect(logs.length).to.equal(2) + const eventNames = logs + // .filter(l => l.address == entryPoint.address) + .map(l => { + try { + return entryPoint.interface.parseLog(l) + } catch (e) { + return acc.interface.parseLog(l) + } + }) + .map(l => l.name) + expect(eventNames).to.eql([ + 'TestFromValidation', // account validateUserOp + 'BeforeExecution', // entryPoint marker + 'TestMessage', // account execution event + 'UserOperationEvent' // post-execution event + ]) }) }) }) diff --git a/packages/boba/bundler_sdk/src/BaseAccountAPI.ts b/packages/boba/bundler_sdk/src/BaseAccountAPI.ts index b5ccf47d45..cd83585c8e 100644 --- a/packages/boba/bundler_sdk/src/BaseAccountAPI.ts +++ b/packages/boba/bundler_sdk/src/BaseAccountAPI.ts @@ -257,7 +257,7 @@ export abstract class BaseAccountAPI { const partialUserOp: any = { sender: this.getAccountAddress(), - nonce: this.getNonce(), + nonce: info.nonce ?? this.getNonce(), initCode, callData, callGasLimit, diff --git a/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts b/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts index 2c6cccb668..1c0379d19f 100644 --- a/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts +++ b/packages/boba/bundler_sdk/src/SimpleAccountAPI.ts @@ -79,7 +79,7 @@ export class SimpleAccountAPI extends BaseAccountAPI { return BigNumber.from(0) } const accountContract = await this._getAccountContract() - return await accountContract.nonce() + return await accountContract.getNonce() } /** diff --git a/packages/boba/bundler_sdk/src/TransactionDetailsForUserOp.ts b/packages/boba/bundler_sdk/src/TransactionDetailsForUserOp.ts index 1e9beeac25..6419f795f3 100644 --- a/packages/boba/bundler_sdk/src/TransactionDetailsForUserOp.ts +++ b/packages/boba/bundler_sdk/src/TransactionDetailsForUserOp.ts @@ -7,4 +7,5 @@ export interface TransactionDetailsForUserOp { gasLimit?: BigNumberish maxFeePerGas?: BigNumberish maxPriorityFeePerGas?: BigNumberish + nonce?: BigNumberish } diff --git a/packages/boba/bundler_utils/src/ERC4337Utils.ts b/packages/boba/bundler_utils/src/ERC4337Utils.ts index 0432c5b688..95bdb7d627 100644 --- a/packages/boba/bundler_utils/src/ERC4337Utils.ts +++ b/packages/boba/bundler_utils/src/ERC4337Utils.ts @@ -20,12 +20,6 @@ export type NotPromise = { [P in keyof T]: Exclude> } -function encode (typevalues: Array<{ type: string, val: any }>, forSignature: boolean): string { - const types = typevalues.map(typevalue => typevalue.type === 'bytes' && forSignature ? 'bytes32' : typevalue.type) - const values = typevalues.map((typevalue) => typevalue.type === 'bytes' && forSignature ? keccak256(typevalue.val) : typevalue.val) - return defaultAbiCoder.encode(types, values) -} - /** * pack the userOperation * @param op @@ -34,73 +28,23 @@ function encode (typevalues: Array<{ type: string, val: any }>, forSignature: bo */ export function packUserOp (op: NotPromise, forSignature = true): string { if (forSignature) { - // lighter signature scheme (must match UserOperation#pack): do encode a zero-length signature, but strip afterwards the appended zero-length value - const userOpType = { - components: [ - { - type: 'address', - name: 'sender' - }, - { - type: 'uint256', - name: 'nonce' - }, - { - type: 'bytes', - name: 'initCode' - }, - { - type: 'bytes', - name: 'callData' - }, - { - type: 'uint256', - name: 'callGasLimit' - }, - { - type: 'uint256', - name: 'verificationGasLimit' - }, - { - type: 'uint256', - name: 'preVerificationGas' - }, - { - type: 'uint256', - name: 'maxFeePerGas' - }, - { - type: 'uint256', - name: 'maxPriorityFeePerGas' - }, - { - type: 'bytes', - name: 'paymasterAndData' - }, - { - type: 'bytes', - name: 'signature' - } - ], - name: 'userOp', - type: 'tuple' - } - // console.log('hard-coded userOpType', userOpType) - // console.log('from ABI userOpType', UserOpType) - let encoded = defaultAbiCoder.encode([userOpType as any], [{ - ...op, - signature: '0x' - }]) - // remove leading word (total length) and trailing word (zero-length signature) - encoded = '0x' + encoded.slice(66, encoded.length - 64) - return encoded + return defaultAbiCoder.encode( + ['address', 'uint256', 'bytes32', 'bytes32', + 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', + 'bytes32'], + [op.sender, op.nonce, keccak256(op.initCode), keccak256(op.callData), + op.callGasLimit, op.verificationGasLimit, op.preVerificationGas, op.maxFeePerGas, op.maxPriorityFeePerGas, + keccak256(op.paymasterAndData)]) + } else { + // for the purpose of calculating gas cost encode also signature (and no keccak of bytes) + return defaultAbiCoder.encode( + ['address', 'uint256', 'bytes', 'bytes', + 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', + 'bytes', 'bytes'], + [op.sender, op.nonce, op.initCode, op.callData, + op.callGasLimit, op.verificationGasLimit, op.preVerificationGas, op.maxFeePerGas, op.maxPriorityFeePerGas, + op.paymasterAndData, op.signature]) } - - const typevalues = (UserOpType as any).components.map((c: { name: keyof typeof op, type: string }) => ({ - type: c.type, - val: op[c.name] - })) - return encode(typevalues, forSignature) } /**