diff --git a/.solcover.js b/.solcover.js index eb6a6a2..bdfee3c 100644 --- a/.solcover.js +++ b/.solcover.js @@ -1,4 +1,12 @@ module.exports = { skipFiles: ['mocks'], istanbulReporter: ['html', 'lcov', 'text-summary'], + // Work around stack too deep for coverage + configureYulOptimizer: true, + solcOptimizerDetails: { + yul: true, + yulDetails: { + optimizerSteps: '', + }, + }, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index b1d50ba..504b688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## XX-XX-2024 + +- `AccountCore`: Added a simple ERC-4337 account implementation with minimal logic to process user operations. +- `Account`: Extensions of {AccountCore} with recommended features that most accounts should have. +- `AbstractSigner`, `SignerECDSA`, `SignerP256`, and `SignerRSA`: Add an abstract contract, and various implementations, for contracts that deal with signature verification. Used by {AccountCore} and {ERC7739Utils}. +- `AccountSignerERC7702`: Implementation of `AbstractSigner` for ERC-7702 compatible accounts. + ## 06-11-2024 - `ERC7739Utils`: Add a library that implements a defensive rehashing mechanism to prevent replayability of smart contract signatures based on the ERC-7739. diff --git a/contracts/account/README.adoc b/contracts/account/README.adoc new file mode 100644 index 0000000..c00b9ff --- /dev/null +++ b/contracts/account/README.adoc @@ -0,0 +1,15 @@ += Account +[.readme-notice] +NOTE: This document is better viewed at https://docs.openzeppelin.com/community-contracts/api/account + +This directory includes contracts to build accounts for ERC-4337. + +== Core + +{{AccountCore}} + +{{Account}} + +== Extensions + +{{AccountSignerERC7702}} diff --git a/contracts/account/draft-Account.sol b/contracts/account/draft-Account.sol new file mode 100644 index 0000000..b849032 --- /dev/null +++ b/contracts/account/draft-Account.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import {ERC7739Signer} from "../utils/cryptography/draft-ERC7739Signer.sol"; +import {AccountCore} from "./draft-AccountCore.sol"; + +/** + * @dev Extension of {AccountCore} with recommended feature that most account abstraction implementation will want: + * + * * {ERC721Holder} and {ERC1155Holder} to accept ERC-712 and ERC-1155 token transfers transfers. + * * {ERC7739Signer} for ERC-1271 signature support with ERC-7739 replay protection + * + * NOTE: To use this contract, the {ERC7739Signer-_rawSignatureValidation} function must be + * implemented using a specific signature verification algorithm. See {SignerECDSA}, {SignerP256} or {SignerRSA}. + */ +abstract contract Account is AccountCore, ERC721Holder, ERC1155Holder, ERC7739Signer {} diff --git a/contracts/account/draft-AccountCore.sol b/contracts/account/draft-AccountCore.sol new file mode 100644 index 0000000..3ac4255 --- /dev/null +++ b/contracts/account/draft-AccountCore.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {PackedUserOperation, IAccount, IEntryPoint, IAccountExecute} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol"; +import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {AbstractSigner} from "../utils/cryptography/AbstractSigner.sol"; + +/** + * @dev A simple ERC4337 account implementation. This base implementation only includes the minimal logic to process + * user operations. + * + * Developers must implement the {AbstractSigner-_rawSignatureValidation} function to define the account's validation logic. + * + * IMPORTANT: Implementing a mechanism to validate signatures is a security-sensitive operation as it may allow an + * attacker to bypass the account's security measures. Check out {SignerECDSA}, {SignerP256}, or {SignerRSA} for + * digital signature validation implementations. + */ +abstract contract AccountCore is AbstractSigner, EIP712, IAccount, IAccountExecute { + using MessageHashUtils for bytes32; + + bytes32 internal constant _PACKED_USER_OPERATION = + keccak256( + "PackedUserOperation(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData,address entrypoint)" + ); + + /** + * @dev Unauthorized call to the account. + */ + error AccountUnauthorized(address sender); + + /** + * @dev Revert if the caller is not the entry point or the account itself. + */ + modifier onlyEntryPointOrSelf() { + _checkEntryPointOrSelf(); + _; + } + + /** + * @dev Revert if the caller is not the entry point. + */ + modifier onlyEntryPoint() { + _checkEntryPoint(); + _; + } + + /** + * @dev Canonical entry point for the account that forwards and validates user operations. + */ + function entryPoint() public view virtual returns (IEntryPoint) { + return IEntryPoint(0x0000000071727De22E5E9d8BAf0edAc6f37da032); + } + + /** + * @dev Return the account nonce for the canonical sequence. + */ + function getNonce() public view virtual returns (uint256) { + return getNonce(0); + } + + /** + * @dev Return the account nonce for a given sequence (key). + */ + function getNonce(uint192 key) public view virtual returns (uint256) { + return entryPoint().getNonce(address(this), key); + } + + /** + * @inheritdoc IAccount + */ + function validateUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 missingAccountFunds + ) public virtual onlyEntryPoint returns (uint256) { + uint256 validationData = _rawSignatureValidation(_signableUserOpHash(userOp, userOpHash), userOp.signature) + ? ERC4337Utils.SIG_VALIDATION_SUCCESS + : ERC4337Utils.SIG_VALIDATION_FAILED; + _payPrefund(missingAccountFunds); + return validationData; + } + + /** + * @inheritdoc IAccountExecute + */ + function executeUserOp( + PackedUserOperation calldata userOp, + bytes32 /*userOpHash*/ + ) public virtual onlyEntryPointOrSelf { + (address target, uint256 value, bytes memory data) = abi.decode(userOp.callData[4:], (address, uint256, bytes)); + Address.functionCallWithValue(target, data, value); + } + + /** + * @dev Returns the digest used by an offchain signer instead of the opaque `userOpHash`. + * + * Given the `userOpHash` calculation is defined by ERC-4337, offchain signers + * may need to sign again this hash by rehashing it with other schemes (e.g. ERC-191). + * + * Returns a typehash following EIP-712 typed data hashing for readability. + */ + function _signableUserOpHash( + PackedUserOperation calldata userOp, + bytes32 /* userOpHash */ + ) internal view virtual returns (bytes32) { + return + _hashTypedDataV4( + keccak256( + abi.encode( + _PACKED_USER_OPERATION, + userOp.sender, + userOp.nonce, + keccak256(userOp.initCode), + keccak256(userOp.callData), + userOp.accountGasLimits, + userOp.preVerificationGas, + userOp.gasFees, + keccak256(userOp.paymasterAndData), + entryPoint() + ) + ) + ); + } + + /** + * @dev Sends the missing funds for executing the user operation to the {entrypoint}. + * The `missingAccountFunds` must be defined by the entrypoint when calling {validateUserOp}. + */ + function _payPrefund(uint256 missingAccountFunds) internal virtual { + if (missingAccountFunds > 0) { + (bool success, ) = payable(msg.sender).call{value: missingAccountFunds}(""); + success; // Silence warning. The entrypoint should validate the result. + } + } + + /** + * @dev Ensures the caller is the {entrypoint}. + */ + function _checkEntryPoint() internal view virtual { + address sender = msg.sender; + if (sender != address(entryPoint())) { + revert AccountUnauthorized(sender); + } + } + + /** + * @dev Ensures the caller is the {entrypoint} or the account itself. + */ + function _checkEntryPointOrSelf() internal view virtual { + address sender = msg.sender; + if (sender != address(this) && sender != address(entryPoint())) { + revert AccountUnauthorized(sender); + } + } + + /** + * @dev Receive Ether. + */ + receive() external payable virtual {} +} diff --git a/contracts/account/extensions/AccountSignerERC7702.sol b/contracts/account/extensions/AccountSignerERC7702.sol new file mode 100644 index 0000000..879939c --- /dev/null +++ b/contracts/account/extensions/AccountSignerERC7702.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {AccountCore} from "../draft-AccountCore.sol"; + +/** + * @dev {Account} implementation whose low-level signature validation is done by an EOA. + */ +abstract contract AccountSignerERC7702 is AccountCore { + /** + * @dev Validates the signature using the EOA's address (ie. `address(this)`). + */ + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, signature); + return address(this) == recovered && err == ECDSA.RecoverError.NoError; + } +} diff --git a/contracts/mocks/CallReceiverMock.sol b/contracts/mocks/CallReceiverMock.sol new file mode 100644 index 0000000..70a00ca --- /dev/null +++ b/contracts/mocks/CallReceiverMock.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {CallReceiverMock} from "@openzeppelin/contracts/mocks/CallReceiverMock.sol"; + +contract CallReceiverMockExtended is CallReceiverMock { + event MockFunctionCalledExtra(address caller, uint256 value); + + function mockFunctionExtra() public payable { + emit MockFunctionCalledExtra(msg.sender, msg.value); + } +} diff --git a/contracts/mocks/Create2Mock.sol b/contracts/mocks/Create2Mock.sol new file mode 100644 index 0000000..81cf533 --- /dev/null +++ b/contracts/mocks/Create2Mock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; + +contract Create2Mock { + function $deploy(uint256 amount, bytes32 salt, bytes memory bytecode) external returns (address) { + return Create2.deploy(amount, salt, bytecode); + } + + function $computeAddress(bytes32 salt, bytes32 bytecodeHash) external view returns (address) { + return Create2.computeAddress(salt, bytecodeHash, address(this)); + } + + function $computeAddress(bytes32 salt, bytes32 bytecodeHash, address deployer) external pure returns (address) { + return Create2.computeAddress(salt, bytecodeHash, deployer); + } +} diff --git a/contracts/mocks/ERC1155Mock.sol b/contracts/mocks/ERC1155Mock.sol new file mode 100644 index 0000000..290284e --- /dev/null +++ b/contracts/mocks/ERC1155Mock.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; + +abstract contract ERC1155Mock is ERC1155 {} diff --git a/contracts/mocks/ERC721Mock.sol b/contracts/mocks/ERC721Mock.sol new file mode 100644 index 0000000..f72a940 --- /dev/null +++ b/contracts/mocks/ERC721Mock.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +abstract contract ERC721Mock is ERC721 {} diff --git a/contracts/mocks/ERC7739SignerMock.sol b/contracts/mocks/ERC7739SignerMock.sol deleted file mode 100644 index a5f12fc..0000000 --- a/contracts/mocks/ERC7739SignerMock.sol +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.20; - -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; -import {ERC7739Signer} from "../utils/cryptography/draft-ERC7739Signer.sol"; - -contract ERC7739SignerMock is ERC7739Signer { - address private immutable _eoa; - - constructor(address eoa) EIP712("ERC7739SignerMock", "1") { - _eoa = eoa; - } - - function _validateSignature(bytes32 hash, bytes calldata signature) internal view virtual override returns (bool) { - (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, signature); - return _eoa == recovered && err == ECDSA.RecoverError.NoError; - } -} diff --git a/contracts/mocks/account/AccountBaseMock.sol b/contracts/mocks/account/AccountBaseMock.sol new file mode 100644 index 0000000..dd03e69 --- /dev/null +++ b/contracts/mocks/account/AccountBaseMock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol"; +import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol"; +import {Account} from "../../account/draft-Account.sol"; + +abstract contract AccountBaseMock is Account { + /// Validates a user operation with a boolean signature. + function _rawSignatureValidation( + bytes32 /* userOpHash */, + bytes calldata signature + ) internal pure override returns (bool) { + return bytes1(signature[0:1]) == bytes1(0x01); + } +} diff --git a/contracts/mocks/account/AccountECDSAMock.sol b/contracts/mocks/account/AccountECDSAMock.sol new file mode 100644 index 0000000..1e478b5 --- /dev/null +++ b/contracts/mocks/account/AccountECDSAMock.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Account} from "../../account/draft-Account.sol"; +import {SignerECDSA} from "../../utils/cryptography/SignerECDSA.sol"; + +abstract contract AccountECDSAMock is Account, SignerECDSA { + constructor(address signerAddr) { + _initializeSigner(signerAddr); + } +} diff --git a/contracts/mocks/account/AccountERC7702Mock.sol b/contracts/mocks/account/AccountERC7702Mock.sol new file mode 100644 index 0000000..fa17fdc --- /dev/null +++ b/contracts/mocks/account/AccountERC7702Mock.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Account} from "../../account/draft-Account.sol"; +import {AccountSignerERC7702} from "../../account/extensions/AccountSignerERC7702.sol"; + +abstract contract AccountERC7702Mock is Account, AccountSignerERC7702 {} diff --git a/contracts/mocks/account/AccountP256Mock.sol b/contracts/mocks/account/AccountP256Mock.sol new file mode 100644 index 0000000..2bbd20c --- /dev/null +++ b/contracts/mocks/account/AccountP256Mock.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Account} from "../../account/draft-Account.sol"; +import {SignerP256} from "../../utils/cryptography/SignerP256.sol"; + +abstract contract AccountP256Mock is Account, SignerP256 { + constructor(bytes32 qx, bytes32 qy) { + _initializeSigner(qx, qy); + } +} diff --git a/contracts/mocks/account/AccountRSAMock.sol b/contracts/mocks/account/AccountRSAMock.sol new file mode 100644 index 0000000..46c1a70 --- /dev/null +++ b/contracts/mocks/account/AccountRSAMock.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Account} from "../../account/draft-Account.sol"; +import {SignerRSA} from "../../utils/cryptography/SignerRSA.sol"; + +abstract contract AccountRSAMock is Account, SignerRSA { + constructor(bytes memory e, bytes memory n) { + _initializeSigner(e, n); + } +} diff --git a/contracts/mocks/docs/utils/cryptography/ERC7739SignerECDSA.sol b/contracts/mocks/docs/utils/cryptography/ERC7739SignerECDSA.sol deleted file mode 100644 index fca13d3..0000000 --- a/contracts/mocks/docs/utils/cryptography/ERC7739SignerECDSA.sol +++ /dev/null @@ -1,21 +0,0 @@ -// contracts/ERC7739SignerECDSA.sol -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; - -import {ERC7739Signer} from "../../../../utils/cryptography/draft-ERC7739Signer.sol"; - -contract ERC7739SignerECDSA is ERC7739Signer { - address private immutable _signer; - - constructor(address signerAddr) EIP712("ERC7739SignerECDSA", "1") { - _signer = signerAddr; - } - - function _validateSignature(bytes32 hash, bytes calldata signature) internal view virtual override returns (bool) { - (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, signature); - return _signer == recovered && err == ECDSA.RecoverError.NoError; - } -} diff --git a/contracts/mocks/docs/utils/cryptography/ERC7739SignerECDSAMock.sol b/contracts/mocks/docs/utils/cryptography/ERC7739SignerECDSAMock.sol new file mode 100644 index 0000000..3cb2dcb --- /dev/null +++ b/contracts/mocks/docs/utils/cryptography/ERC7739SignerECDSAMock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ERC7739Signer} from "../../../../utils/cryptography/draft-ERC7739Signer.sol"; +import {SignerECDSA} from "../../../../utils/cryptography/SignerECDSA.sol"; + +contract ERC7739SignerECDSAMock is ERC7739Signer, SignerECDSA { + constructor(address signerAddr) EIP712("ERC7739SignerECDSA", "1") { + _initializeSigner(signerAddr); + } +} diff --git a/contracts/mocks/docs/utils/cryptography/ERC7739SignerP256Mock.sol b/contracts/mocks/docs/utils/cryptography/ERC7739SignerP256Mock.sol new file mode 100644 index 0000000..c2adb71 --- /dev/null +++ b/contracts/mocks/docs/utils/cryptography/ERC7739SignerP256Mock.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ERC7739Signer} from "../../../../utils/cryptography/draft-ERC7739Signer.sol"; +import {SignerP256} from "../../../../utils/cryptography/SignerP256.sol"; + +contract ERC7739SignerP256Mock is ERC7739Signer, SignerP256 { + constructor(bytes32 qx, bytes32 qy) EIP712("ERC7739SignerP256", "1") { + _initializeSigner(qx, qy); + } +} diff --git a/contracts/mocks/docs/utils/cryptography/ERC7739SignerRSAMock.sol b/contracts/mocks/docs/utils/cryptography/ERC7739SignerRSAMock.sol new file mode 100644 index 0000000..5b95834 --- /dev/null +++ b/contracts/mocks/docs/utils/cryptography/ERC7739SignerRSAMock.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ERC7739Signer} from "../../../../utils/cryptography/draft-ERC7739Signer.sol"; +import {SignerRSA} from "../../../../utils/cryptography/SignerRSA.sol"; + +contract ERC7739SignerRSAMock is ERC7739Signer, SignerRSA { + constructor(bytes memory e, bytes memory n) EIP712("ERC7739SignerRSA", "1") { + _initializeSigner(e, n); + } +} diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index d6e6a0f..e564938 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -5,16 +5,26 @@ NOTE: This document is better viewed at https://docs.openzeppelin.com/community- Miscellaneous contracts and libraries containing utility functions you can use to improve security, work with new data types, or safely use low-level primitives. - * {Masks}: Library to handle `bytes32` masks. - * {ERC7739Utils}: Utilities library that implements a defensive rehashing mechanism to prevent replayability of smart contract signatures based on ERC-7739. + * {AbstractSigner}: Abstract contract for internal signature validation in smart contracts. * {ERC7739Signer}: An abstract contract to validate signatures following the rehashing scheme from `ERC7739Utils`. + * {ERC7739Utils}: Utilities library that implements a defensive rehashing mechanism to prevent replayability of smart contract signatures based on ERC-7739. + * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. + * {Masks}: Library to handle `bytes32` masks. == Cryptography +{{AbstractSigner}} + {{ERC7739Signer}} {{ERC7739Utils}} +{{SignerECDSA}} + +{{SignerP256}} + +{{SignerRSA}} + == Libraries {{Masks}} diff --git a/contracts/utils/cryptography/AbstractSigner.sol b/contracts/utils/cryptography/AbstractSigner.sol new file mode 100644 index 0000000..6f109f3 --- /dev/null +++ b/contracts/utils/cryptography/AbstractSigner.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +/** + * @dev Abstract contract for signature validation. + * + * Developers must implement {_rawSignatureValidation} and use it as the lowest-level signature validation mechanism. + */ +abstract contract AbstractSigner { + /** + * @dev Signature validation algorithm. + * + * WARNING: Implementing a signature validation algorithm is a security-sensitive operation as it involves + * cryptographic verification. It is important to review and test thoroughly before deployment. Consider + * using one of the signature verification libraries (https://docs.openzeppelin.com/contracts/api/utils#ECDSA[ECDSA], + * https://docs.openzeppelin.com/contracts/api/utils#P256[P256] or https://docs.openzeppelin.com/contracts/api/utils#RSA[RSA]). + */ + function _rawSignatureValidation(bytes32 hash, bytes calldata signature) internal view virtual returns (bool); +} diff --git a/contracts/utils/cryptography/SignerECDSA.sol b/contracts/utils/cryptography/SignerECDSA.sol new file mode 100644 index 0000000..d877b3f --- /dev/null +++ b/contracts/utils/cryptography/SignerECDSA.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {AbstractSigner} from "./AbstractSigner.sol"; + +/** + * @dev Implementation of {AbstractSigner} using + * https://docs.openzeppelin.com/contracts/api/utils#ECDSA[ECDSA] signatures. + * + * For {Account} usage, an {_initializeSigner} function is provided to set the {signer} address. + * Doing so it's easier for a factory, whose likely to use initializable clones of this contract. + * + * Example of usage: + * + * ```solidity + * contract MyAccountECDSA is Account, SignerECDSA { + * constructor() EIP712("MyAccountECDSA", "1") {} + * + * function initializeSigner(address signerAddr) public virtual initializer { + * // Will revert if the signer is already initialized + * _initializeSigner(signerAddr); + * } + * } + * ``` + * + * IMPORTANT: Avoiding to call {_initializeSigner} either during construction (if used standalone) + * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable. + */ +abstract contract SignerECDSA is AbstractSigner { + /** + * @dev The {signer} is already initialized. + */ + error SignerECDSAUninitializedSigner(address signer); + + address private _signer; + + /** + * @dev Initializes the signer with the address of the native signer. This function can be called only once. + */ + function _initializeSigner(address signerAddr) internal { + if (_signer != address(0)) revert SignerECDSAUninitializedSigner(signerAddr); + _signer = signerAddr; + } + + /** + * @dev Return the signer's address. + */ + function signer() public view virtual returns (address) { + return _signer; + } + + // @inheritdoc AbstractSigner + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + (address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(hash, signature); + return signer() == recovered && err == ECDSA.RecoverError.NoError; + } +} diff --git a/contracts/utils/cryptography/SignerP256.sol b/contracts/utils/cryptography/SignerP256.sol new file mode 100644 index 0000000..175c79a --- /dev/null +++ b/contracts/utils/cryptography/SignerP256.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {P256} from "@openzeppelin/contracts/utils/cryptography/P256.sol"; +import {AbstractSigner} from "./AbstractSigner.sol"; + +/** + * @dev Implementation of {AbstractSigner} using + * https://docs.openzeppelin.com/contracts/api/utils#P256[P256] signatures. + * + * For {Account} usage, an {_initializeSigner} function is provided to set the {signer} public key. + * Doing so it's easier for a factory, whose likely to use initializable clones of this contract. + * + * Example of usage: + * + * ```solidity + * contract MyAccountP256 is Account, SignerP256 { + * constructor() EIP712("MyAccountP256", "1") {} + * + * function initializeSigner(bytes32 qx, bytes32 qy) public virtual initializer { + * // Will revert if the signer is already initialized + * _initializeSigner(qx, qy); + * } + * } + * ``` + * + * IMPORTANT: Avoiding to call {_initializeSigner} either during construction (if used standalone) + * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable. + */ +abstract contract SignerP256 is AbstractSigner { + /** + * @dev The {signer} is already initialized. + */ + error SignerP256UninitializedSigner(bytes32 qx, bytes32 qy); + + bytes32 private _qx; + bytes32 private _qy; + + /** + * @dev Initializes the signer with the P256 public key. This function can be called only once. + */ + function _initializeSigner(bytes32 qx, bytes32 qy) internal { + if (_qx != 0 || _qy != 0) revert SignerP256UninitializedSigner(qx, qy); + _qx = qx; + _qy = qy; + } + + /** + * @dev Return the signer's P256 public key. + */ + function signer() public view virtual returns (bytes32 qx, bytes32 qy) { + return (_qx, _qy); + } + + /// @inheritdoc AbstractSigner + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + if (signature.length < 0x40) return false; + bytes32 r = bytes32(signature[0x00:0x20]); + bytes32 s = bytes32(signature[0x20:0x40]); + (bytes32 qx, bytes32 qy) = signer(); + return P256.verify(hash, r, s, qx, qy); + } +} diff --git a/contracts/utils/cryptography/SignerRSA.sol b/contracts/utils/cryptography/SignerRSA.sol new file mode 100644 index 0000000..c93f22c --- /dev/null +++ b/contracts/utils/cryptography/SignerRSA.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {RSA} from "@openzeppelin/contracts/utils/cryptography/RSA.sol"; +import {AbstractSigner} from "./AbstractSigner.sol"; + +/** + * @dev Implementation of {AbstractSigner} using + * https://docs.openzeppelin.com/contracts/api/utils#RSA[RSA] signatures. + * + * For {Account} usage, an {_initializeSigner} function is provided to set the {signer} public key. + * Doing so it's easier for a factory, whose likely to use initializable clones of this contract. + * + * Example of usage: + * + * ```solidity + * contract MyAccountRSA is Account, SignerRSA { + * constructor() EIP712("MyAccountRSA", "1") {} + * + * function initializeSigner(bytes memory e, bytes memory n) external { + * // Will revert if the signer is already initialized + * _initializeSigner(e, n); + * } + * } + * ``` + * + * IMPORTANT: Avoiding to call {_initializeSigner} either during construction (if used standalone) + * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable. + */ +abstract contract SignerRSA is AbstractSigner { + /** + * @dev The {signer} is already initialized. + */ + error SignerRSAUninitializedSigner(bytes e, bytes n); + + bytes private _e; + bytes private _n; + + /** + * @dev Initializes the signer with the RSA public key. This function can be called only once. + */ + function _initializeSigner(bytes memory e, bytes memory n) internal { + if (_e.length != 0 || _n.length != 0) revert SignerRSAUninitializedSigner(e, n); + _e = e; + _n = n; + } + + /** + * @dev Return the signer's RSA public key. + */ + function signer() public view virtual returns (bytes memory e, bytes memory n) { + return (_e, _n); + } + + /// @inheritdoc AbstractSigner + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + (bytes memory e, bytes memory n) = signer(); + return RSA.pkcs1Sha256(abi.encodePacked(hash), signature, e, n); + } +} diff --git a/contracts/utils/cryptography/draft-ERC7739Signer.sol b/contracts/utils/cryptography/draft-ERC7739Signer.sol index 6ccbc04..61e820c 100644 --- a/contracts/utils/cryptography/draft-ERC7739Signer.sol +++ b/contracts/utils/cryptography/draft-ERC7739Signer.sol @@ -6,6 +6,7 @@ import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import {ShortStrings} from "@openzeppelin/contracts/utils/ShortStrings.sol"; +import {AbstractSigner} from "./AbstractSigner.sol"; import {ERC7739Utils} from "./draft-ERC7739Utils.sol"; /** @@ -14,14 +15,16 @@ import {ERC7739Utils} from "./draft-ERC7739Utils.sol"; * Linking the signature to the EIP-712 domain separator is a security measure to prevent signature replay across different * EIP-712 domains (e.g. a single offchain owner of multiple contracts). * - * This contract requires implementing the {_validateSignature} function, which passes the wrapped message hash, + * This contract requires implementing the {_rawSignatureValidation} function, which passes the wrapped message hash, * which may be either an typed data or a personal sign nested type. * - * NOTE: {EIP712} uses {ShortStrings} to optimize gas costs for short strings (up to 31 characters). - * Consider that strings longer than that will use storage, which may limit the ability of the signer to - * be used within the ERC-4337 validation phase (due to ERC-7562 storage access rules). + * NOTE: https://docs.openzeppelin.com/contracts/api/utils#EIP712[EIP-712] uses + * https://docs.openzeppelin.com/contracts/api/utils#ShortStrings[ShortStrings] to optimize gas costs for + * short strings (up to 31 characters). Consider that strings longer than that will use storage, which + * may limit the ability of the signer to be used within the ERC-4337 validation phase (due to + * https://eips.ethereum.org/EIPS/eip-7562#storage-rules[ERC-7562 storage access rules]). */ -abstract contract ERC7739Signer is EIP712, IERC1271 { +abstract contract ERC7739Signer is AbstractSigner, EIP712, IERC1271 { using ERC7739Utils for *; using MessageHashUtils for bytes32; @@ -38,29 +41,18 @@ abstract contract ERC7739Signer is EIP712, IERC1271 { // we return the magic value too as it's assumed impossible to find a preimage for it that can be used maliciously. // Useful for simulation purposes and to validate whether the contract supports ERC-7739. return - _isValidSignature(hash, signature) + (_isValidNestedTypedDataSignature(hash, signature) || _isValidNestedPersonalSignSignature(hash, signature)) ? IERC1271.isValidSignature.selector : (hash == 0x7739773977397739773977397739773977397739773977397739773977397739 && signature.length == 0) ? bytes4(0x77390001) : bytes4(0xffffffff); } - /** - * @dev Internal version of {isValidSignature} that returns a boolean. - */ - function _isValidSignature(bytes32 hash, bytes calldata signature) internal view virtual returns (bool) { - return - _isValidNestedTypedDataSignature(hash, signature) || _isValidNestedPersonalSignSignature(hash, signature); - } - /** * @dev Nested personal signature verification. */ - function _isValidNestedPersonalSignSignature( - bytes32 hash, - bytes calldata signature - ) internal view virtual returns (bool) { - return _validateSignature(_domainSeparatorV4().toTypedDataHash(hash.personalSignStructHash()), signature); + function _isValidNestedPersonalSignSignature(bytes32 hash, bytes calldata signature) private view returns (bool) { + return _rawSignatureValidation(_domainSeparatorV4().toTypedDataHash(hash.personalSignStructHash()), signature); } /** @@ -69,7 +61,7 @@ abstract contract ERC7739Signer is EIP712, IERC1271 { function _isValidNestedTypedDataSignature( bytes32 hash, bytes calldata encodedSignature - ) internal view virtual returns (bool) { + ) private view returns (bool) { // decode signature ( bytes calldata signature, @@ -93,7 +85,7 @@ abstract contract ERC7739Signer is EIP712, IERC1271 { return hash == appSeparator.toTypedDataHash(contentsHash) && bytes(contentsDescr).length != 0 && - _validateSignature( + _rawSignatureValidation( appSeparator.toTypedDataHash( ERC7739Utils.typedDataSignStructHash( contentsDescr, @@ -104,13 +96,4 @@ abstract contract ERC7739Signer is EIP712, IERC1271 { signature ); } - - /** - * @dev Signature validation algorithm. - * - * WARNING: Implementing a signature validation algorithm is a security-sensitive operation as it involves - * cryptographic verification. It is important to review and test thoroughly before deployment. Consider - * using one of the signature verification libraries ({ECDSA}, {P256} or {RSA}). - */ - function _validateSignature(bytes32 hash, bytes calldata signature) internal view virtual returns (bool); } diff --git a/contracts/utils/cryptography/draft-ERC7739Utils.sol b/contracts/utils/cryptography/draft-ERC7739Utils.sol index 320ac4b..eed6acd 100644 --- a/contracts/utils/cryptography/draft-ERC7739Utils.sol +++ b/contracts/utils/cryptography/draft-ERC7739Utils.sol @@ -7,18 +7,20 @@ pragma solidity ^0.8.20; * that are specific to an EIP-712 domain. * * This library provides methods to wrap, unwrap and operate over typed data signatures with a defensive - * rehashing mechanism that includes the application's {EIP712-_domainSeparatorV4} and preserves - * readability of the signed content using an EIP-712 nested approach. + * rehashing mechanism that includes the application's + * https://docs.openzeppelin.com/contracts/api/utils#EIP712-_domainSeparatorV4[EIP-712] + * and preserves readability of the signed content using an EIP-712 nested approach. * * A smart contract domain can validate a signature for a typed data structure in two ways: * - * - As an application validating a typed data signature. See {toNestedTypedDataHash}. - * - As a smart contract validating a raw message signature. See {toNestedPersonalSignHash}. + * - As an application validating a typed data signature. See {typedDataSignStructHash}. + * - As a smart contract validating a raw message signature. See {personalSignStructHash}. * * NOTE: A provider for a smart contract wallet would need to return this signature as the * result of a call to `personal_sign` or `eth_signTypedData`, and this may be unsupported by * API clients that expect a return value of 129 bytes, or specifically the `r,s,v` parameters - * of an {ECDSA} signature, as is for example specified for {EIP712}. + * of an https://docs.openzeppelin.com/contracts/api/utils#ECDSA[ECDSA] signature, as is for + * example specified for https://docs.openzeppelin.com/contracts/api/utils#EIP712[EIP-712]. */ library ERC7739Utils { /** diff --git a/hardhat.config.js b/hardhat.config.js index 1a944bb..07cc76c 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -34,8 +34,5 @@ module.exports = { hardfork: argv.hardfork, }, }, - exposed: { - exclude: ['@axelar-network/**/*'], - }, docgen: require('./docs/config'), }; diff --git a/scripts/checks/coverage.sh b/scripts/checks/coverage.sh index 12e2323..a591069 100755 --- a/scripts/checks/coverage.sh +++ b/scripts/checks/coverage.sh @@ -10,7 +10,7 @@ hardhat coverage if [ "${CI:-"false"}" == "true" ]; then # Foundry coverage - forge coverage --report lcov + forge coverage --report lcov --ir-minimum # Remove zero hits sed -i '/,0/d' lcov.info fi diff --git a/test/account/Account.behavior.js b/test/account/Account.behavior.js new file mode 100644 index 0000000..8cbad70 --- /dev/null +++ b/test/account/Account.behavior.js @@ -0,0 +1,312 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { impersonate } = require('../../lib/@openzeppelin-contracts/test/helpers/account'); +const { + SIG_VALIDATION_SUCCESS, + SIG_VALIDATION_FAILURE, +} = require('../../lib/@openzeppelin-contracts/test/helpers/erc4337'); +const { setBalance } = require('@nomicfoundation/hardhat-network-helpers'); +const { + shouldSupportInterfaces, +} = require('../../lib/@openzeppelin-contracts/test/utils/introspection/SupportsInterface.behavior'); + +function shouldBehaveLikeAnAccountBase() { + describe('entryPoint', function () { + it('should return the canonical entrypoint', async function () { + await this.mock.deploy(); + expect(await this.mock.entryPoint()).to.equal(this.entrypoint.target); + }); + }); + + describe('validateUserOp', function () { + beforeEach(async function () { + await setBalance(this.mock.target, ethers.parseEther('1')); + await this.mock.deploy(); + }); + + it('should revert if the caller is not the canonical entrypoint', async function () { + const selector = this.mock.interface.getFunction('executeUserOp').selector; + const operation = await this.mock + .createOp({ + callData: ethers.concat([ + selector, + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'bytes'], + [this.target.target, 0, this.target.interface.encodeFunctionData('mockFunctionExtra')], + ), + ]), + }) + .then(op => this.signUserOp(op)); + + await expect(this.mock.connect(this.other).validateUserOp(operation.packed, operation.hash(), 0)) + .to.be.revertedWithCustomError(this.mock, 'AccountUnauthorized') + .withArgs(this.other); + }); + + describe('when the caller is the canonical entrypoint', function () { + beforeEach(async function () { + this.entrypointAsSigner = await impersonate(this.entrypoint.target); + }); + + it('should return SIG_VALIDATION_SUCCESS if the signature is valid', async function () { + const selector = this.mock.interface.getFunction('executeUserOp').selector; + const operation = await this.mock + .createOp({ + callData: ethers.concat([ + selector, + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'bytes'], + [this.target.target, 0, this.target.interface.encodeFunctionData('mockFunctionExtra')], + ), + ]), + }) + .then(op => this.signUserOp(op)); + + expect( + await this.mock + .connect(this.entrypointAsSigner) + .validateUserOp.staticCall(operation.packed, operation.hash(), 0), + ).to.eq(SIG_VALIDATION_SUCCESS); + }); + + it('should return SIG_VALIDATION_FAILURE if the signature is invalid', async function () { + const selector = this.mock.interface.getFunction('executeUserOp').selector; + const operation = await this.mock.createOp({ + callData: ethers.concat([ + selector, + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'bytes'], + [this.target.target, 0, this.target.interface.encodeFunctionData('mockFunctionExtra')], + ), + ]), + }); + + operation.signature = '0x00'; + + expect( + await this.mock + .connect(this.entrypointAsSigner) + .validateUserOp.staticCall(operation.packed, operation.hash(), 0), + ).to.eq(SIG_VALIDATION_FAILURE); + }); + + it('should pay missing account funds for execution', async function () { + const selector = this.mock.interface.getFunction('executeUserOp').selector; + const operation = await this.mock + .createOp({ + callData: ethers.concat([ + selector, + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'bytes'], + [this.target.target, 0, this.target.interface.encodeFunctionData('mockFunctionExtra')], + ), + ]), + }) + .then(op => this.signUserOp(op)); + + const prevAccountBalance = await ethers.provider.getBalance(this.mock.target); + const prevEntrypointBalance = await ethers.provider.getBalance(this.entrypoint.target); + const amount = ethers.parseEther('0.1'); + + const tx = await this.mock + .connect(this.entrypointAsSigner) + .validateUserOp(operation.packed, operation.hash(), amount); + + const receipt = await tx.wait(); + const callerFees = receipt.gasUsed * tx.gasPrice; + + expect(await ethers.provider.getBalance(this.mock.target)).to.equal(prevAccountBalance - amount); + expect(await ethers.provider.getBalance(this.entrypoint.target)).to.equal( + prevEntrypointBalance + amount - callerFees, + ); + }); + }); + }); + + describe('fallback', function () { + it('should receive ether', async function () { + await this.mock.deploy(); + await setBalance(this.other.address, ethers.parseEther('1')); + + const prevBalance = await ethers.provider.getBalance(this.mock.target); + const amount = ethers.parseEther('0.1'); + await this.other.sendTransaction({ to: this.mock.target, value: amount }); + + expect(await ethers.provider.getBalance(this.mock.target)).to.equal(prevBalance + amount); + }); + }); +} + +function shouldBehaveLikeAccountHolder() { + describe('onReceived', function () { + beforeEach(async function () { + await this.mock.deploy(); + }); + + shouldSupportInterfaces(['ERC1155Receiver']); + + describe('onERC1155Received', function () { + const ids = [1n, 2n, 3n]; + const values = [1000n, 2000n, 3000n]; + const data = '0x12345678'; + + beforeEach(async function () { + [this.owner] = await ethers.getSigners(); + this.token = await ethers.deployContract('$ERC1155Mock', ['https://somedomain.com/{id}.json']); + await this.token.$_mintBatch(this.owner, ids, values, '0x'); + }); + + it('receives ERC1155 tokens from a single ID', async function () { + await this.token.connect(this.owner).safeTransferFrom(this.owner, this.mock, ids[0], values[0], data); + expect(await this.token.balanceOf(this.mock, ids[0])).to.equal(values[0]); + for (let i = 1; i < ids.length; i++) { + expect(await this.token.balanceOf(this.mock, ids[i])).to.equal(0n); + } + }); + + it('receives ERC1155 tokens from a multiple IDs', async function () { + expect( + await this.token.balanceOfBatch( + ids.map(() => this.mock), + ids, + ), + ).to.deep.equal(ids.map(() => 0n)); + await this.token.connect(this.owner).safeBatchTransferFrom(this.owner, this.mock, ids, values, data); + expect( + await this.token.balanceOfBatch( + ids.map(() => this.mock), + ids, + ), + ).to.deep.equal(values); + }); + }); + + describe('onERC721Received', function () { + it('receives an ERC721 token', async function () { + const name = 'Some NFT'; + const symbol = 'SNFT'; + const tokenId = 1n; + + const [owner] = await ethers.getSigners(); + + const token = await ethers.deployContract('$ERC721Mock', [name, symbol]); + await token.$_mint(owner, tokenId); + + await token.connect(owner).safeTransferFrom(owner, this.mock, tokenId); + + expect(await token.ownerOf(tokenId)).to.equal(this.mock.target); + }); + }); + }); +} + +function shouldBehaveLikeAnAccountBaseExecutor({ deployable = true } = {}) { + describe('executeUserOp', function () { + beforeEach(async function () { + await setBalance(this.mock.target, ethers.parseEther('1')); + expect(await ethers.provider.getCode(this.mock.target)).to.equal('0x'); + this.entrypointAsSigner = await impersonate(this.entrypoint.target); + }); + + it('should revert if the caller is not the canonical entrypoint or the account itself', async function () { + await this.mock.deploy(); + + const selector = this.mock.interface.getFunction('executeUserOp').selector; + const operation = await this.mock + .createOp({ + callData: ethers.concat([ + selector, + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'bytes'], + [this.target.target, 0, this.target.interface.encodeFunctionData('mockFunctionExtra')], + ), + ]), + }) + .then(op => this.signUserOp(op)); + + await expect(this.mock.connect(this.other).executeUserOp(operation.packed, operation.hash())) + .to.be.revertedWithCustomError(this.mock, 'AccountUnauthorized') + .withArgs(this.other); + }); + + if (deployable) { + describe('when not deployed', function () { + it('should be created with handleOps and increase nonce', async function () { + const selector = this.mock.interface.getFunction('executeUserOp').selector; + const operation = await this.mock + .createOp({ + callData: ethers.concat([ + selector, + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'bytes'], + [this.target.target, 17, this.target.interface.encodeFunctionData('mockFunctionExtra')], + ), + ]), + }) + .then(op => op.addInitCode()) + .then(op => this.signUserOp(op)); + + await expect(this.entrypoint.connect(this.entrypointAsSigner).handleOps([operation.packed], this.beneficiary)) + .to.emit(this.entrypoint, 'AccountDeployed') + .withArgs(operation.hash(), this.mock, this.factory, ethers.ZeroAddress) + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.mock, 17); + expect(await this.mock.getNonce()).to.equal(1); + }); + + it('should revert if the signature is invalid', async function () { + const selector = this.mock.interface.getFunction('executeUserOp').selector; + const operation = await this.mock + .createOp({ + callData: ethers.concat([ + selector, + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'bytes'], + [this.target.target, 17, this.target.interface.encodeFunctionData('mockFunctionExtra')], + ), + ]), + }) + .then(op => op.addInitCode()); + + operation.signature = '0x00'; + + await expect(this.entrypoint.connect(this.entrypointAsSigner).handleOps([operation.packed], this.beneficiary)) + .to.be.reverted; + }); + }); + } + + describe('when deployed', function () { + beforeEach(async function () { + await this.mock.deploy(); + }); + + it('should increase nonce and call target', async function () { + const selector = this.mock.interface.getFunction('executeUserOp').selector; + const operation = await this.mock + .createOp({ + callData: ethers.concat([ + selector, + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'bytes'], + [this.target.target, 42, this.target.interface.encodeFunctionData('mockFunctionExtra')], + ), + ]), + }) + .then(op => this.signUserOp(op)); + + expect(await this.mock.getNonce()).to.equal(0); + await expect(this.entrypoint.connect(this.entrypointAsSigner).handleOps([operation.packed], this.beneficiary)) + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.mock, 42); + expect(await this.mock.getNonce()).to.equal(1); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeAnAccountBase, + shouldBehaveLikeAccountHolder, + shouldBehaveLikeAnAccountBaseExecutor, +}; diff --git a/test/account/draft-AccountBase.test.js b/test/account/draft-AccountBase.test.js new file mode 100644 index 0000000..03f748d --- /dev/null +++ b/test/account/draft-AccountBase.test.js @@ -0,0 +1,28 @@ +const { ethers } = require('hardhat'); +const { shouldBehaveLikeAnAccountBase, shouldBehaveLikeAnAccountBaseExecutor } = require('./Account.behavior'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner } = require('../helpers/signers'); + +async function fixture() { + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMockExtended'); + const signer = new NonNativeSigner({ sign: () => ({ serialized: '0x01' }) }); + const helper = new ERC4337Helper('$AccountBaseMock'); + const smartAccount = await helper.newAccount(['AccountBase', '1']); + const signUserOp = async userOp => { + userOp.signature = await signer.signMessage(userOp.hash()); + return userOp; + }; + + return { ...helper, mock: smartAccount, signer, target, beneficiary, other, signUserOp }; +} + +describe('AccountBase', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAnAccountBase(); + shouldBehaveLikeAnAccountBaseExecutor(); +}); diff --git a/test/account/draft-AccountECDSA.test.js b/test/account/draft-AccountECDSA.test.js new file mode 100644 index 0000000..2e466c2 --- /dev/null +++ b/test/account/draft-AccountECDSA.test.js @@ -0,0 +1,71 @@ +const { ethers } = require('hardhat'); +const { + shouldBehaveLikeAnAccountBase, + shouldBehaveLikeAnAccountBaseExecutor, + shouldBehaveLikeAccountHolder, +} = require('./Account.behavior'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { shouldBehaveLikeERC7739Signer } = require('../utils/cryptography/ERC7739Signer.behavior'); +const { PackedUserOperation } = require('../helpers/eip712'); + +async function fixture() { + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMockExtended'); + const signer = ethers.Wallet.createRandom(); + const helper = new ERC4337Helper('$AccountECDSAMock'); + const smartAccount = await helper.newAccount(['AccountECDSA', '1', signer.address]); + const domain = { + name: 'AccountECDSA', + version: '1', + chainId: helper.chainId, + verifyingContract: smartAccount.address, + }; + const signUserOp = async userOp => { + const types = { PackedUserOperation }; + const packed = userOp.packed; + const typedOp = { + sender: packed.sender, + nonce: packed.nonce, + initCode: packed.initCode, + callData: packed.callData, + accountGasLimits: packed.accountGasLimits, + preVerificationGas: packed.preVerificationGas, + gasFees: packed.gasFees, + paymasterAndData: packed.paymasterAndData, + entrypoint: userOp.context.entrypoint.target, + }; + userOp.signature = await signer.signTypedData(domain, types, typedOp); + return userOp; + }; + + return { + ...helper, + domain, + mock: smartAccount, + signer, + target, + beneficiary, + other, + signUserOp, + }; +} + +describe('AccountECDSA', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAnAccountBase(); + shouldBehaveLikeAnAccountBaseExecutor(); + shouldBehaveLikeAccountHolder(); + + describe('ERC7739Signer', function () { + beforeEach(async function () { + this.mock = await this.mock.deploy(); + this.signTypedData = this.signer.signTypedData.bind(this.signer); + }); + + shouldBehaveLikeERC7739Signer(); + }); +}); diff --git a/test/account/draft-AccountP256.test.js b/test/account/draft-AccountP256.test.js new file mode 100644 index 0000000..eecd1c8 --- /dev/null +++ b/test/account/draft-AccountP256.test.js @@ -0,0 +1,68 @@ +const { ethers } = require('hardhat'); +const { + shouldBehaveLikeAnAccountBase, + shouldBehaveLikeAnAccountBaseExecutor, + shouldBehaveLikeAccountHolder, +} = require('./Account.behavior'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner, P256SigningKey } = require('../helpers/signers'); +const { shouldBehaveLikeERC7739Signer } = require('../utils/cryptography/ERC7739Signer.behavior'); +const { PackedUserOperation } = require('../helpers/eip712'); + +async function fixture() { + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMockExtended'); + const signer = new NonNativeSigner(P256SigningKey.random()); + const helper = new ERC4337Helper('$AccountP256Mock'); + const smartAccount = await helper.newAccount([ + 'AccountP256', + '1', + signer.signingKey.publicKey.qx, + signer.signingKey.publicKey.qy, + ]); + const domain = { + name: 'AccountP256', + version: '1', + chainId: helper.chainId, + verifyingContract: smartAccount.address, + }; + const signUserOp = async userOp => { + const types = { PackedUserOperation }; + const packed = userOp.packed; + const typedOp = { + sender: packed.sender, + nonce: packed.nonce, + initCode: packed.initCode, + callData: packed.callData, + accountGasLimits: packed.accountGasLimits, + preVerificationGas: packed.preVerificationGas, + gasFees: packed.gasFees, + paymasterAndData: packed.paymasterAndData, + entrypoint: userOp.context.entrypoint.target, + }; + userOp.signature = await signer.signTypedData(domain, types, typedOp); + return userOp; + }; + + return { ...helper, domain, mock: smartAccount, signer, target, beneficiary, other, signUserOp }; +} + +describe('AccountP256', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAnAccountBase(); + shouldBehaveLikeAnAccountBaseExecutor(); + shouldBehaveLikeAccountHolder(); + + describe('ERC7739Signer', function () { + beforeEach(async function () { + this.mock = await this.mock.deploy(); + this.signTypedData = this.signer.signTypedData.bind(this.signer); + }); + + shouldBehaveLikeERC7739Signer(); + }); +}); diff --git a/test/account/draft-AccountRSA.test.js b/test/account/draft-AccountRSA.test.js new file mode 100644 index 0000000..df8b77d --- /dev/null +++ b/test/account/draft-AccountRSA.test.js @@ -0,0 +1,68 @@ +const { ethers } = require('hardhat'); +const { + shouldBehaveLikeAnAccountBase, + shouldBehaveLikeAnAccountBaseExecutor, + shouldBehaveLikeAccountHolder, +} = require('./Account.behavior'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner, RSASHA256SigningKey } = require('../helpers/signers'); +const { shouldBehaveLikeERC7739Signer } = require('../utils/cryptography/ERC7739Signer.behavior'); +const { PackedUserOperation } = require('../helpers/eip712'); + +async function fixture() { + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMockExtended'); + const signer = new NonNativeSigner(RSASHA256SigningKey.random()); + const helper = new ERC4337Helper('$AccountRSAMock'); + const smartAccount = await helper.newAccount([ + 'AccountRSA', + '1', + signer.signingKey.publicKey.e, + signer.signingKey.publicKey.n, + ]); + const domain = { + name: 'AccountRSA', + version: '1', + chainId: helper.chainId, + verifyingContract: smartAccount.address, + }; + const signUserOp = async userOp => { + const types = { PackedUserOperation }; + const packed = userOp.packed; + const typedOp = { + sender: packed.sender, + nonce: packed.nonce, + initCode: packed.initCode, + callData: packed.callData, + accountGasLimits: packed.accountGasLimits, + preVerificationGas: packed.preVerificationGas, + gasFees: packed.gasFees, + paymasterAndData: packed.paymasterAndData, + entrypoint: userOp.context.entrypoint.target, + }; + userOp.signature = await signer.signTypedData(domain, types, typedOp); + return userOp; + }; + + return { ...helper, domain, mock: smartAccount, signer, target, beneficiary, other, signUserOp }; +} + +describe('AccountRSA', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeAnAccountBase(); + shouldBehaveLikeAnAccountBaseExecutor(); + shouldBehaveLikeAccountHolder(); + + describe('ERC7739Signer', function () { + beforeEach(async function () { + this.mock = await this.mock.deploy(); + this.signTypedData = this.signer.signTypedData.bind(this.signer); + }); + + shouldBehaveLikeERC7739Signer(); + }); +}); diff --git a/test/helpers/eip712.js b/test/helpers/eip712.js new file mode 100644 index 0000000..30b77f0 --- /dev/null +++ b/test/helpers/eip712.js @@ -0,0 +1,22 @@ +const { types, formatType } = require('../../lib/@openzeppelin-contracts/test/helpers/eip712'); +const { mapValues } = require('../../lib/@openzeppelin-contracts/test/helpers/iterate'); + +module.exports = { + ...types, + ...mapValues( + { + PackedUserOperation: { + sender: 'address', + nonce: 'uint256', + initCode: 'bytes', + callData: 'bytes', + accountGasLimits: 'bytes32', + preVerificationGas: 'uint256', + gasFees: 'bytes32', + paymasterAndData: 'bytes', + entrypoint: 'address', + }, + }, + formatType, + ), +}; diff --git a/test/helpers/erc4337.js b/test/helpers/erc4337.js new file mode 100644 index 0000000..1505a30 --- /dev/null +++ b/test/helpers/erc4337.js @@ -0,0 +1,104 @@ +const { setCode } = require('@nomicfoundation/hardhat-network-helpers'); +const { ethers } = require('hardhat'); +const { UserOperation: UserOperationVanilla } = require('../../lib/@openzeppelin-contracts/test/helpers/erc4337'); + +const CANONICAL_ENTRYPOINT = '0x0000000071727De22E5E9d8BAf0edAc6f37da032'; + +/// Global ERC-4337 environment helper. +class ERC4337Helper { + constructor(account, params = {}) { + this.entrypointAsPromise = ethers.deployContract('EntryPoint'); + this.factoryAsPromise = ethers.deployContract('Create2Mock'); + this.accountContractAsPromise = ethers.getContractFactory(account); + this.chainIdAsPromise = ethers.provider.getNetwork().then(({ chainId }) => chainId); + this.senderCreatorAsPromise = ethers.deployContract('SenderCreator'); + this.params = params; + } + + async wait() { + const entrypoint = await this.entrypointAsPromise; + await entrypoint.getDeployedCode().then(code => setCode(CANONICAL_ENTRYPOINT, code)); + this.entrypoint = entrypoint.attach(CANONICAL_ENTRYPOINT); + this.entrypointAsPromise = Promise.resolve(this.entrypoint); + + this.factory = await this.factoryAsPromise; + this.accountContract = await this.accountContractAsPromise; + this.chainId = await this.chainIdAsPromise; + this.senderCreator = await this.senderCreatorAsPromise; + return this; + } + + async newAccount(extraArgs = [], salt = ethers.randomBytes(32)) { + await this.wait(); + const initCode = await this.accountContract + .getDeployTransaction(...extraArgs) + .then(tx => this.factory.interface.encodeFunctionData('$deploy', [0, salt, tx.data])) + .then(deployCode => ethers.concat([this.factory.target, deployCode])); + const instance = await this.senderCreator.createSender + .staticCall(initCode) + .then(address => this.accountContract.attach(address)); + return new SmartAccount(instance, initCode, this); + } +} + +/// Represent one ERC-4337 account contract. +class SmartAccount extends ethers.BaseContract { + constructor(instance, initCode, context) { + super(instance.target, instance.interface, instance.runner, instance.deployTx); + this.address = instance.target; + this.initCode = initCode; + this.factory = '0x' + initCode.replace(/0x/, '').slice(0, 40); + this.factoryData = '0x' + initCode.replace(/0x/, '').slice(40); + this.context = context; + } + + async deploy(account = this.runner) { + this.deployTx = await account.sendTransaction({ + to: this.factory, + data: this.factoryData, + }); + return this; + } + + async createOp(args = {}) { + const params = Object.assign({ sender: this }, args); + // fetch nonce + if (!params.nonce) { + params.nonce = await this.context.entrypointAsPromise.then(entrypoint => entrypoint.getNonce(this, 0)); + } + // prepare paymaster and data + if (ethers.isAddressable(params.paymaster)) { + params.paymaster = await ethers.resolveAddress(params.paymaster); + params.paymasterVerificationGasLimit ??= 100_000n; + params.paymasterPostOpGasLimit ??= 100_000n; + params.paymasterAndData = ethers.solidityPacked( + ['address', 'uint128', 'uint128'], + [params.paymaster, params.paymasterVerificationGasLimit, params.paymasterPostOpGasLimit], + ); + } + return new UserOperation(params); + } +} + +class UserOperation extends UserOperationVanilla { + constructor(params) { + super(params); + this.context = params.sender.context; + this.senderFactory = params.sender.factory; + this.senderFactoryData = params.sender.factoryData; + } + + addInitCode() { + this.factory = this.senderFactory; + this.factoryData = this.senderFactoryData; + return this; + } + + hash() { + return super.hash(this.context.entrypoint.target, this.context.chainId); + } +} + +module.exports = { + ERC4337Helper, +}; diff --git a/test/helpers/signers.js b/test/helpers/signers.js new file mode 100644 index 0000000..e0b1692 --- /dev/null +++ b/test/helpers/signers.js @@ -0,0 +1,168 @@ +const { + AbstractSigner, + Signature, + TypedDataEncoder, + assert, + assertArgument, + concat, + dataLength, + decodeBase64, + getBytes, + getBytesCopy, + hashMessage, + hexlify, + sha256, + toBeHex, +} = require('ethers'); +const { secp256r1 } = require('@noble/curves/p256'); +const { generateKeyPairSync, privateEncrypt } = require('crypto'); + +// Lightweight version of BaseWallet +class NonNativeSigner extends AbstractSigner { + #signingKey; + + constructor(privateKey, provider) { + super(provider); + assertArgument( + privateKey && typeof privateKey.sign === 'function', + 'invalid private key', + 'privateKey', + '[ REDACTED ]', + ); + this.#signingKey = privateKey; + } + + get signingKey() { + return this.#signingKey; + } + get privateKey() { + return this.signingKey.privateKey; + } + + async getAddress() { + throw new Error("NonNativeSigner doesn't have an address"); + } + + connect(provider) { + return new NonNativeSigner(this.#signingKey, provider); + } + + async signTransaction(/*tx: TransactionRequest*/) { + throw new Error('NonNativeSigner cannot send transactions'); + } + + async signMessage(message /*: string | Uint8Array*/) /*: Promise*/ { + return this.signingKey.sign(hashMessage(message)).serialized; + } + + async signTypedData( + domain /*: TypedDataDomain*/, + types /*: Record>*/, + value /*: Record*/, + ) /*: Promise*/ { + // Populate any ENS names + const populated = await TypedDataEncoder.resolveNames(domain, types, value, async name => { + assert(this.provider != null, 'cannot resolve ENS names without a provider', 'UNSUPPORTED_OPERATION', { + operation: 'resolveName', + info: { name }, + }); + const address = await this.provider.resolveName(name); + assert(address != null, 'unconfigured ENS name', 'UNCONFIGURED_NAME', { value: name }); + return address; + }); + + return this.signingKey.sign(TypedDataEncoder.hash(populated.domain, types, populated.value)).serialized; + } +} + +class P256SigningKey { + #privateKey; + + constructor(privateKey) { + this.#privateKey = getBytes(privateKey); + } + + static random() { + return new P256SigningKey(secp256r1.utils.randomPrivateKey()); + } + + get privateKey() { + return hexlify(this.#privateKey); + } + + get publicKey() { + const publicKeyBytes = secp256r1.getPublicKey(this.#privateKey, false); + return { + qx: hexlify(publicKeyBytes.slice(0x01, 0x21)), + qy: hexlify(publicKeyBytes.slice(0x21, 0x41)), + }; + } + + sign(digest /*: BytesLike*/) /*: Signature*/ { + assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + + const sig = secp256r1.sign(getBytesCopy(digest), getBytesCopy(this.#privateKey), { + lowS: true, + }); + + return Signature.from({ + r: toBeHex(sig.r, 32), + s: toBeHex(sig.s, 32), + v: sig.recovery ? 0x1c : 0x1b, + }); + } +} + +class RSASigningKey { + #privateKey; + #publicKey; + + constructor(keyPair) { + const jwk = keyPair.publicKey.export({ format: 'jwk' }); + this.#privateKey = keyPair.privateKey; + this.#publicKey = { e: decodeBase64(jwk.e), n: decodeBase64(jwk.n) }; + } + + static random(modulusLength = 2048) { + return new RSASigningKey(generateKeyPairSync('rsa', { modulusLength })); + } + + get privateKey() { + return hexlify(this.#privateKey); + } + + get publicKey() { + return { + e: hexlify(this.#publicKey.e), + n: hexlify(this.#publicKey.n), + }; + } + + sign(digest /*: BytesLike*/) /*: Signature*/ { + assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + // SHA256 OID = 608648016503040201 (9 bytes) | NULL = 0500 (2 bytes) (explicit) | OCTET_STRING length (0x20) = 0420 (2 bytes) + return { + serialized: hexlify( + privateEncrypt(this.#privateKey, getBytes(concat(['0x3031300d060960864801650304020105000420', digest]))), + ), + }; + } +} + +class RSASHA256SigningKey extends RSASigningKey { + static random(modulusLength = 2048) { + return new RSASHA256SigningKey(generateKeyPairSync('rsa', { modulusLength })); + } + + sign(digest /*: BytesLike*/) /*: Signature*/ { + assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + return super.sign(sha256(getBytes(digest))); + } +} + +module.exports = { + NonNativeSigner, + P256SigningKey, + RSASigningKey, + RSASHA256SigningKey, +}; diff --git a/test/utils/cryptography/ERC7739Signer.behavior.js b/test/utils/cryptography/ERC7739Signer.behavior.js new file mode 100644 index 0000000..b6b4055 --- /dev/null +++ b/test/utils/cryptography/ERC7739Signer.behavior.js @@ -0,0 +1,106 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { Permit, formatType, getDomain } = require('../../../lib/@openzeppelin-contracts/test/helpers/eip712'); +const { PersonalSignHelper, TypedDataSignHelper } = require('../../helpers/erc7739'); + +function shouldBehaveLikeERC7739Signer() { + const MAGIC_VALUE = '0x1626ba7e'; + + describe('isValidSignature', function () { + beforeEach(async function () { + this.signTypedData ??= this.signer.signTypedData.bind(this.signer); + this.domain ??= await getDomain(this.mock); + }); + + describe('PersonalSign', function () { + it('returns true for a valid personal signature', async function () { + const text = 'Hello, world!'; + + const hash = PersonalSignHelper.hash(text); + const signature = await PersonalSignHelper.sign(this.signTypedData, text, this.domain); + + expect(this.mock.isValidSignature(hash, signature)).to.eventually.equal(MAGIC_VALUE); + }); + + it('returns false for an invalid personal signature', async function () { + const hash = PersonalSignHelper.hash('Message the app expects'); + const signature = await PersonalSignHelper.sign(this.signTypedData, 'Message signed is different', this.domain); + + expect(this.mock.isValidSignature(hash, signature)).to.eventually.not.equal(MAGIC_VALUE); + }); + }); + + describe('TypedDataSign', function () { + beforeEach(async function () { + // Dummy app domain, different from the ERC7739Signer's domain + // Note the difference of format (signer domain doesn't include a salt, but app domain does) + this.appDomain = { + name: 'SomeApp', + version: '1', + chainId: this.domain.chainId, + verifyingContract: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', + salt: '0x02cb3d8cb5e8928c9c6de41e935e16a4e28b2d54e7e7ba47e99f16071efab785', + }; + }); + + it('returns true for a valid typed data signature', async function () { + const contents = { + owner: '0x1ab5E417d9AF00f1ca9d159007e12c401337a4bb', + spender: '0xD68E96620804446c4B1faB3103A08C98d4A8F55f', + value: 1_000_000n, + nonce: 0n, + deadline: ethers.MaxUint256, + }; + const message = TypedDataSignHelper.prepare(contents, this.domain); + + const hash = ethers.TypedDataEncoder.hash(this.appDomain, { Permit }, message.contents); + const signature = await TypedDataSignHelper.sign(this.signTypedData, this.appDomain, { Permit }, message); + + expect(this.mock.isValidSignature(hash, signature)).to.eventually.equal(MAGIC_VALUE); + }); + + it('returns true for valid typed data signature (nested types)', async function () { + const contentsTypes = { + B: formatType({ z: 'Z' }), + Z: formatType({ a: 'A' }), + A: formatType({ v: 'uint256' }), + }; + + const contents = { z: { a: { v: 1n } } }; + const message = TypedDataSignHelper.prepare(contents, this.domain); + + const hash = TypedDataSignHelper.hash(this.appDomain, contentsTypes, message.contents); + const signature = await TypedDataSignHelper.sign(this.signTypedData, this.appDomain, contentsTypes, message); + + expect(this.mock.isValidSignature(hash, signature)).to.eventually.equal(MAGIC_VALUE); + }); + + it('returns false for an invalid typed data signature', async function () { + const appContents = { + owner: '0x1ab5E417d9AF00f1ca9d159007e12c401337a4bb', + spender: '0xD68E96620804446c4B1faB3103A08C98d4A8F55f', + value: 1_000_000n, + nonce: 0n, + deadline: ethers.MaxUint256, + }; + // message signed by the user is for a lower amount. + const message = TypedDataSignHelper.prepare({ ...appContents, value: 1_000n }, this.domain); + + const hash = ethers.TypedDataEncoder.hash(this.appDomain, { Permit }, appContents); + const signature = await TypedDataSignHelper.sign(this.signTypedData, this.appDomain, { Permit }, message); + + expect(this.mock.isValidSignature(hash, signature)).to.eventually.not.equal(MAGIC_VALUE); + }); + }); + + it('support detection', function () { + expect( + this.mock.isValidSignature('0x7739773977397739773977397739773977397739773977397739773977397739', ''), + ).to.eventually.equal('0x77390001'); + }); + }); +} + +module.exports = { + shouldBehaveLikeERC7739Signer, +}; diff --git a/test/utils/cryptography/draft-ERC7739Signer.test.js b/test/utils/cryptography/draft-ERC7739Signer.test.js index 6bac670..8fe0b71 100644 --- a/test/utils/cryptography/draft-ERC7739Signer.test.js +++ b/test/utils/cryptography/draft-ERC7739Signer.test.js @@ -1,117 +1,38 @@ const { ethers } = require('hardhat'); -const { expect } = require('chai'); -const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); - -const { Permit, formatType, getDomain } = require('@openzeppelin/contracts/test/helpers/eip712'); -const { PersonalSignHelper, TypedDataSignHelper } = require('../../helpers/erc7739'); - -// Constant -const MAGIC_VALUE = '0x1626ba7e'; - -// Fixture -async function fixture() { - const [signer] = await ethers.getSigners(); - const mock = await ethers.deployContract('$ERC7739SignerMock', [signer]); - const domain = await getDomain(mock); - - return { - mock, - domain, - signTypedData: signer.signTypedData.bind(signer), - }; -} +const { shouldBehaveLikeERC7739Signer } = require('./ERC7739Signer.behavior'); +const { NonNativeSigner, P256SigningKey, RSASigningKey } = require('../../helpers/signers'); describe('ERC7739Signer', function () { - beforeEach(async function () { - Object.assign(this, await loadFixture(fixture)); - }); - - describe('isValidSignature', function () { - describe('PersonalSign', function () { - it('returns true for a valid personal signature', async function () { - const text = 'Hello, world!'; - - const hash = PersonalSignHelper.hash(text); - const signature = await PersonalSignHelper.sign(this.signTypedData, text, this.domain); - - expect(this.mock.isValidSignature(hash, signature)).to.eventually.equal(MAGIC_VALUE); - }); - - it('returns false for an invalid personal signature', async function () { - const hash = PersonalSignHelper.hash('Message the app expects'); - const signature = await PersonalSignHelper.sign(this.signTypedData, 'Message signed is different', this.domain); - - expect(this.mock.isValidSignature(hash, signature)).to.eventually.not.equal(MAGIC_VALUE); - }); + describe('for an ECDSA signer', function () { + before(async function () { + this.signer = ethers.Wallet.createRandom(); + this.mock = await ethers.deployContract('ERC7739SignerECDSAMock', [this.signer.address]); }); - describe('TypedDataSign', function () { - beforeEach(async function () { - // Dummy app domain, different from the ERC7739Signer's domain - // Note the difference of format (signer domain doesn't include a salt, but app domain does) - this.appDomain = { - name: 'SomeApp', - version: '1', - chainId: this.domain.chainId, - verifyingContract: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', - salt: '0x02cb3d8cb5e8928c9c6de41e935e16a4e28b2d54e7e7ba47e99f16071efab785', - }; - }); - - it('returns true for a valid typed data signature', async function () { - const contents = { - owner: '0x1ab5E417d9AF00f1ca9d159007e12c401337a4bb', - spender: '0xD68E96620804446c4B1faB3103A08C98d4A8F55f', - value: 1_000_000n, - nonce: 0n, - deadline: ethers.MaxUint256, - }; - const message = TypedDataSignHelper.prepare(contents, this.domain); - - const hash = ethers.TypedDataEncoder.hash(this.appDomain, { Permit }, message.contents); - const signature = await TypedDataSignHelper.sign(this.signTypedData, this.appDomain, { Permit }, message); - - expect(this.mock.isValidSignature(hash, signature)).to.eventually.equal(MAGIC_VALUE); - }); - - it('returns true for valid typed data signature (nested types)', async function () { - const contentsTypes = { - B: formatType({ z: 'Z' }), - Z: formatType({ a: 'A' }), - A: formatType({ v: 'uint256' }), - }; - - const contents = { z: { a: { v: 1n } } }; - const message = TypedDataSignHelper.prepare(contents, this.domain); - - const hash = TypedDataSignHelper.hash(this.appDomain, contentsTypes, message.contents); - const signature = await TypedDataSignHelper.sign(this.signTypedData, this.appDomain, contentsTypes, message); - - expect(this.mock.isValidSignature(hash, signature)).to.eventually.equal(MAGIC_VALUE); - }); + shouldBehaveLikeERC7739Signer(); + }); - it('returns false for an invalid typed data signature', async function () { - const appContents = { - owner: '0x1ab5E417d9AF00f1ca9d159007e12c401337a4bb', - spender: '0xD68E96620804446c4B1faB3103A08C98d4A8F55f', - value: 1_000_000n, - nonce: 0n, - deadline: ethers.MaxUint256, - }; - // message signed by the user is for a lower amount. - const message = TypedDataSignHelper.prepare({ ...appContents, value: 1_000n }, this.domain); + describe('for a P256 signer', function () { + before(async function () { + this.signer = new NonNativeSigner(P256SigningKey.random()); + this.mock = await ethers.deployContract('ERC7739SignerP256Mock', [ + this.signer.signingKey.publicKey.qx, + this.signer.signingKey.publicKey.qy, + ]); + }); - const hash = ethers.TypedDataEncoder.hash(this.appDomain, { Permit }, appContents); - const signature = await TypedDataSignHelper.sign(this.signTypedData, this.appDomain, { Permit }, message); + shouldBehaveLikeERC7739Signer(); + }); - expect(this.mock.isValidSignature(hash, signature)).to.eventually.not.equal(MAGIC_VALUE); - }); + describe('for an RSA signer', function () { + before(async function () { + this.signer = new NonNativeSigner(RSASigningKey.random()); + this.mock = await ethers.deployContract('ERC7739SignerRSAMock', [ + this.signer.signingKey.publicKey.e, + this.signer.signingKey.publicKey.n, + ]); }); - it('support detection', function () { - expect( - this.mock.isValidSignature('0x7739773977397739773977397739773977397739773977397739773977397739', ''), - ).to.eventually.equal('0x77390001'); - }); + shouldBehaveLikeERC7739Signer(); }); });