diff --git a/.gitignore b/.gitignore index 22b05d9..1cfbed4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules log/ .vscode/ +.openzeppelin/ #Hardhat files cache artifacts diff --git a/README.md b/README.md index f74dc74..b150bcd 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,49 @@ # BloctoAccount & BloctoAccountFactory -## Test & Deploy +## Test test ``` -npx hardhat test test/entrypoint.test.ts +yarn test ``` -deploy BloctoAccountFactory +Schnorr Multi Sign Test ``` -yarn deploy-accountfactory --network mumbai +npx hardhat test test/schnorrMultiSign.test.ts ``` -verify BloctoAccountFactory +## Deploy & Verify + +deploy BloctoAccountCloneableWallet, BloctoAccountFactory, and addStake to BloctoAccountFactory + +``` +yarn deploy_verify --network goerli +``` + +create a test account and verify +``` +npx hardhat run deploy/2_createSchnorrAccount_verify.ts --network goerli +``` + + +## Tool + +check storage layout +``` +npx hardhat check +``` + +## Testnet chain info + +goerli, arbitrum goerli, op goerli, mumbai, bsc testnet, avax testnet ``` -yarn verify-accountfactory --network mumbai +BloctoAccountCloneableWallet +0x490B5ED8A17224a553c34fAA642161c8472118dd +BloctoAccountFactory +0x285cc5232236D227FCb23E6640f87934C948a028 +VerifyingPaymaster +0x9C58dF1BB61a3f68C66Ef5fC7D8Ab4bd1DaEC9Ac ``` diff --git a/contracts/BloctoAccount.sol b/contracts/BloctoAccount.sol new file mode 100644 index 0000000..6c8ce92 --- /dev/null +++ b/contracts/BloctoAccount.sol @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +/* solhint-disable avoid-low-level-calls */ +/* solhint-disable no-inline-assembly */ +/* solhint-disable reason-string */ + +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import "@account-abstraction/contracts/core/BaseAccount.sol"; + +import "./TokenCallbackHandler.sol"; +import "./CoreWallet/CoreWallet.sol"; + +/** + * Blocto account. + * compatibility for EIP-4337 and smart contract wallet with cosigner functionality (CoreWallet) + */ +contract BloctoAccount is UUPSUpgradeable, TokenCallbackHandler, CoreWallet, BaseAccount { + /** + * This is the version of this contract. + */ + string public constant VERSION = "1.4.0"; + + /// @notice etnrypoint from 4337 official + IEntryPoint private immutable _entryPoint; + + /// @notice initialized _IMPLEMENTATION_SLOT + bool public initializedImplementation = false; + + /** + * constructor for BloctoAccount + * @param anEntryPoint entrypoint address + */ + constructor(IEntryPoint anEntryPoint) { + _entryPoint = anEntryPoint; + } + + /** + * override from UUPSUpgradeable + * @param newImplementation implementation address + */ + function _authorizeUpgrade(address newImplementation) internal view override onlyInvoked { + (newImplementation); + } + + /** + * return entrypoint + */ + function entryPoint() public view virtual override returns (IEntryPoint) { + return _entryPoint; + } + + /** + * execute a transaction (called directly by entryPoint) + * @param dest dest call address + * @param value value to send + * @param func the func containing the transaction to be called + */ + function execute(address dest, uint256 value, bytes calldata func) external { + _requireFromEntryPoint(); + _call(dest, value, func); + } + + /** + * execute a sequence of transactions (called directly by entryPoint) + * @param dest sequence of dest call address + * @param value sequence of value to send + * @param func sequence of the func containing transactions to be called + */ + function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata func) external { + _requireFromEntryPoint(); + require(dest.length == func.length, "wrong array lengths"); + for (uint256 i = 0; i < dest.length; i++) { + _call(dest[i], value[i], func[i]); + } + } + + /** + * internal call for execute and executeBatch + * @param target target call address + * @param value value to send + * @param data the data containing the transaction to be called + */ + function _call(address target, uint256 value, bytes memory data) internal { + (bool success, bytes memory result) = target.call{value: value}(data); + if (!success) { + assembly { + revert(add(result, 32), mload(result)) + } + } + } + + /** + * implement validate signature method of BaseAccount from etnrypoint + * @param userOp user operation including signature for validating + * @param userOpHash user operation hash + */ + function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) + internal + virtual + override + returns (uint256 validationData) + { + bytes4 result = this.isValidSignature(userOpHash, userOp.signature); + if (result != IERC1271.isValidSignature.selector) { + return SIG_VALIDATION_FAILED; + } + + return 0; + } + + /** + * check current account deposit in the entryPoint StakeManager + */ + function getDeposit() public view returns (uint256) { + return entryPoint().balanceOf(address(this)); + } + + /** + * deposit more funds for this account in the entryPoint StakeManager + */ + function addDeposit() public payable { + entryPoint().depositTo{value: msg.value}(address(this)); + } + + /** + * withdraw deposit to withdrawAddress from entryPoint StakeManager + * @param withdrawAddress target to send to + * @param amount to withdraw + */ + function withdrawDepositTo(address payable withdrawAddress, uint256 amount) external onlyInvoked { + entryPoint().withdrawTo(withdrawAddress, amount); + } + + /// @notice Used to decorate the `init` function so this can only be called one time. Necessary + /// since this contract will often be used as a "clone". (See above.) + modifier onlyOnceInitImplementation() { + require(!initializedImplementation, "must not already be initialized"); + initializedImplementation = true; + _; + } + + /// @notice initialize BloctoAccountProxy for adding the implementation address + /// @param implementation implementation address + function initImplementation(address implementation) public onlyOnceInitImplementation { + require(Address.isContract(implementation), "ERC1967: new implementation is not a contract"); + StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = implementation; + } + + function disableInitImplementation() public { + initializedImplementation = true; + } +} diff --git a/contracts/BloctoAccountCloneableWallet.sol b/contracts/BloctoAccountCloneableWallet.sol new file mode 100644 index 0000000..5eb1881 --- /dev/null +++ b/contracts/BloctoAccountCloneableWallet.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "./BloctoAccount.sol"; + +/// @title BloctoAccountCloneableWallet Wallet +/// @notice This contract represents a complete but non working wallet. +contract BloctoAccountCloneableWallet is BloctoAccount { + /// @notice constructor that deploys a NON-FUNCTIONAL version of `BloctoAccount` + /// @param anEntryPoint entrypoint address + constructor(IEntryPoint anEntryPoint) BloctoAccount(anEntryPoint) { + initialized = true; + initializedImplementation = true; + } +} diff --git a/contracts/BloctoAccountFactory.sol b/contracts/BloctoAccountFactory.sol new file mode 100644 index 0000000..b0d68e9 --- /dev/null +++ b/contracts/BloctoAccountFactory.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "@openzeppelin/contracts/utils/Create2.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@account-abstraction/contracts/interfaces/IEntryPoint.sol"; +import "./BloctoAccountProxy.sol"; +import "./BloctoAccount.sol"; + +// BloctoAccountFactory for creating BloctoAccountProxy +contract BloctoAccountFactory is Initializable, OwnableUpgradeable { + /// @notice this is the version of this contract. + string public constant VERSION = "1.4.0"; + /// @notice the init implementation address of BloctoAccountCloneableWallet, never change for cosistent address + address public initImplementation; + /// @notice the implementation address of BloctoAccountCloneableWallet + address public bloctoAccountImplementation; + /// @notice the address from EIP-4337 official implementation + IEntryPoint public entryPoint; + + event WalletCreated(address wallet, address authorizedAddress, bool full); + + /// @notice initialize + /// @param _bloctoAccountImplementation the implementation address for BloctoAccountCloneableWallet + /// @param _entryPoint the entrypoint address from EIP-4337 official implementation + function initialize(address _bloctoAccountImplementation, IEntryPoint _entryPoint) public initializer { + __Ownable_init_unchained(); + initImplementation = _bloctoAccountImplementation; + bloctoAccountImplementation = _bloctoAccountImplementation; + entryPoint = _entryPoint; + } + + /// @notice create an account, and return its BloctoAccount. + /// returns the address even if the account is already deployed. + /// Note that during UserOperation execution, this method is called only if the account is not deployed. + /// This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation + /// @param _authorizedAddress the initial authorized address, must not be zero! + /// @param _cosigner the initial cosigning address for `_authorizedAddress`, can be equal to `_authorizedAddress` + /// @param _recoveryAddress the initial recovery address for the wallet, can be address(0) + /// @param _salt salt for create account (used for address calculation in create2) + /// @param _mergedKeyIndexWithParity the corresponding index of mergedKeys = authVersion + _mergedIndex + /// @param _mergedKey the corresponding mergedKey (using Schnorr merged key) + function createAccount( + address _authorizedAddress, + address _cosigner, + address _recoveryAddress, + uint256 _salt, + uint8 _mergedKeyIndexWithParity, + bytes32 _mergedKey + ) public onlyOwner returns (BloctoAccount ret) { + bytes32 salt = keccak256(abi.encodePacked(_salt, _cosigner, _recoveryAddress)); + // to be consistent address + BloctoAccountProxy newProxy = new BloctoAccountProxy{salt: salt}(initImplementation); + ret = BloctoAccount(payable(address(newProxy))); + // to save gas, first deploy using disableInitImplementation() + // to be consistent address, (after) first upgrade need to call initImplementation + ret.disableInitImplementation(); + ret.init( + _authorizedAddress, uint256(uint160(_cosigner)), _recoveryAddress, _mergedKeyIndexWithParity, _mergedKey + ); + emit WalletCreated(address(ret), _authorizedAddress, false); + } + + /// @notice create an account with multiple authorized addresses, and return its BloctoAccount. + /// returns the address even if the account is already deployed. + /// @param _authorizedAddresses the initial authorized addresses, must not be zero! + /// @param _cosigner the initial cosigning address for `_authorizedAddress`, can be equal to `_authorizedAddress` + /// @param _recoveryAddress the initial recovery address for the wallet, can be address(0) + /// @param _salt salt for create account (used for address calculation in create2) + /// @param _mergedKeyIndexWithParitys the corresponding index of mergedKeys = authVersion + _mergedIndex + /// @param _mergedKeys the corresponding mergedKey + function createAccount2( + address[] calldata _authorizedAddresses, + address _cosigner, + address _recoveryAddress, + uint256 _salt, + uint8[] calldata _mergedKeyIndexWithParitys, + bytes32[] calldata _mergedKeys + ) public onlyOwner returns (BloctoAccount ret) { + bytes32 salt = keccak256(abi.encodePacked(_salt, _cosigner, _recoveryAddress)); + // to be consistent address + BloctoAccountProxy newProxy = new BloctoAccountProxy{salt: salt}(initImplementation); + + ret = BloctoAccount(payable(address(newProxy))); + // to save gas, first deploy use disableInitImplementation() + // to be consistent address, (after) first upgrade need to call initImplementation() + // ret.initImplementation(bloctoAccountImplementation); + ret.disableInitImplementation(); + ret.init2( + _authorizedAddresses, uint256(uint160(_cosigner)), _recoveryAddress, _mergedKeyIndexWithParitys, _mergedKeys + ); + // emit event only with _authorizedAddresses[0] + emit WalletCreated(address(ret), _authorizedAddresses[0], true); + } + + /// @notice calculate the counterfactual address of this account as it would be returned by createAccount() + /// @param _cosigner the initial cosigning address + /// @param _recoveryAddress the initial recovery address for the wallet + /// @param _salt salt for create account (used for address calculation in create2) + function getAddress(address _cosigner, address _recoveryAddress, uint256 _salt) public view returns (address) { + bytes32 salt = keccak256(abi.encodePacked(_salt, _cosigner, _recoveryAddress)); + return Create2.computeAddress( + bytes32(salt), + keccak256(abi.encodePacked(type(BloctoAccountProxy).creationCode, abi.encode(address(initImplementation)))) + ); + } + + /// @notice set the implementation + /// @param _bloctoAccountImplementation update the implementation address of BloctoAccountCloneableWallet for createAccount and createAccount2 + function setImplementation(address _bloctoAccountImplementation) public onlyOwner { + bloctoAccountImplementation = _bloctoAccountImplementation; + } + + /// @notice set the entrypoint + /// @param _entrypoint target entrypoint + function setEntrypoint(IEntryPoint _entrypoint) public onlyOwner { + entryPoint = _entrypoint; + } + + /// @notice withdraw value from the deposit + /// @param withdrawAddress target to send to + /// @param amount to withdraw + function withdrawTo(address payable withdrawAddress, uint256 amount) public onlyOwner { + entryPoint.withdrawTo(withdrawAddress, amount); + } + + /// @notice add stake in etnrypoint for this factory to avoid bundler reject + /// @param unstakeDelaySec - the unstake delay for this factory. Can only be increased. + function addStake(uint32 unstakeDelaySec) external payable onlyOwner { + entryPoint.addStake{value: msg.value}(unstakeDelaySec); + } +} diff --git a/contracts/BloctoAccountProxy.sol b/contracts/BloctoAccountProxy.sol new file mode 100644 index 0000000..841e507 --- /dev/null +++ b/contracts/BloctoAccountProxy.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +contract BloctoAccountProxy { + /// @notice constructor for setting the implementation address + /// @param implementation the initial implementation(logic) addresses, must not be zero! + constructor(address implementation) { + assembly { + sstore(0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, implementation) + } + } + + /// @notice Fallback function that delegates calls to the address + /// @dev update from "@openzeppelin/contracts/proxy/Proxy.sol" + fallback() external payable virtual { + assembly { + let implementation := sload(0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) + // if eq(implementation, 0) { implementation := 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 } + // Copy msg.data. We take full control of memory in this inline assembly + // block because it will not return to Solidity code. We overwrite the + // Solidity scratch pad at memory position 0. + calldatacopy(0, 0, calldatasize()) + + // Call the implementation. + // out and outsize are 0 because we don't know the size yet. + let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) + + // Copy the returned data. + returndatacopy(0, 0, returndatasize()) + + switch result + // delegatecall returns 0 on error. + case 0 { revert(0, returndatasize()) } + default { return(0, returndatasize()) } + } + } +} diff --git a/contracts/CoreWallet/BytesExtractSignature.sol b/contracts/CoreWallet/BytesExtractSignature.sol new file mode 100644 index 0000000..6bcb2ef --- /dev/null +++ b/contracts/CoreWallet/BytesExtractSignature.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +/// @title ECDSA is a library that contains useful methods for working with ECDSA signatures +library BytesExtractSignature { + /// @notice Extracts the r, s, and v components from the `sigData` field starting from the `offset` + /// @dev Note: does not do any bounds checking on the arguments! + /// @param sigData the signature data; could be 1 or more packed signatures. + /// @param offset the offset in sigData from which to start unpacking the signature components. + function extractSignature( + bytes memory sigData, + uint256 offset + ) internal pure returns (bytes32 r, bytes32 s, uint8 v) { + // Divide the signature in r, s and v variables + // ecrecover takes the signature parameters, and the only way to get them + // currently is to use assembly. + // solium-disable-next-line security/no-inline-assembly + assembly { + let dataPointer := add(sigData, offset) + r := mload(add(dataPointer, 0x20)) + s := mload(add(dataPointer, 0x40)) + v := byte(0, mload(add(dataPointer, 0x60))) + } + + return (r, s, v); + } +} diff --git a/contracts/CoreWallet/CoreWallet.sol b/contracts/CoreWallet/CoreWallet.sol new file mode 100644 index 0000000..8d5ad34 --- /dev/null +++ b/contracts/CoreWallet/CoreWallet.sol @@ -0,0 +1,775 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "./BytesExtractSignature.sol"; +import "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @title Core Wallet +/// @notice A basic smart contract wallet with cosigner functionality. The notion of "cosigner" is +/// the simplest possible multisig solution, a two-of-two signature scheme. It devolves nicely +/// to "one-of-one" (i.e. singlesig) by simply having the cosigner set to the same value as +/// the main signer. +/// +/// Most "advanced" functionality (deadman's switch, multiday recovery flows, blacklisting, etc) +/// can be implemented externally to this smart contract, either as an additional smart contract +/// (which can be tracked as a signer without cosigner, or as a cosigner) or as an off-chain flow +/// using a public/private key pair as cosigner. Of course, the basic cosigning functionality could +/// also be implemented in this way, but (A) the complexity and gas cost of two-of-two multisig (as +/// implemented here) is negligable even if you don't need the cosigner functionality, and +/// (B) two-of-two multisig (as implemented here) handles a lot of really common use cases, most +/// notably third-party gas payment and off-chain blacklisting and fraud detection. +contract CoreWallet is IERC1271 { + using BytesExtractSignature for bytes; + using ECDSA for bytes; + + /// @notice We require that presigned transactions use the EIP-191 signing format. + /// See that EIP for more info: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-191.md + bytes1 public constant EIP191_VERSION_DATA = bytes1(0); + bytes1 public constant EIP191_PREFIX = bytes1(0x19); + + /// @notice This is a sentinel value used to determine when a delegate is set to expose + /// support for an interface containing more than a single function. See `delegates` and + /// `setDelegate` for more information. + address public constant COMPOSITE_PLACEHOLDER = address(1); + + /// @notice A pre-shifted "1", used to increment the authVersion, so we can "prepend" + /// the authVersion to an address (for lookups in the authorizations mapping) + /// by using the '+' operator (which is cheaper than a shift and a mask). See the + /// comment on the `authorizations` variable for how this is used. + uint256 public constant AUTH_VERSION_INCREMENTOR = (1 << 160); + + /// @notice Q constant for schnorr signature verify + uint256 internal constant Q = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + + /// @notice The pre-shifted authVersion (to get the current authVersion as an integer, + /// shift this value right by 160 bits). Starts as `1 << 160` (`AUTH_VERSION_INCREMENTOR`) + /// See the comment on the `authorizations` variable for how this is used. + uint256 public authVersion; + + /// @notice A mapping containing all of the addresses that are currently authorized to manage + /// the assets owned by this wallet. + /// + /// The keys in this mapping are authorized addresses with a version number prepended, + /// like so: (authVersion,96)(address,160). The current authVersion MUST BE included + /// for each look-up; this allows us to effectively clear the entire mapping of its + /// contents merely by incrementing the authVersion variable. (This is important for + /// the emergencyRecovery() method.) Inspired by https://ethereum.stackexchange.com/a/42540 + /// + /// The values in this mapping are 256bit words, whose lower 20 bytes constitute "cosigners" + /// for each address. If an address maps to itself, then that address is said to have no cosigner. + /// + /// The upper 12 bytes are reserved for future meta-data purposes. The meta-data could refer + /// to the key (authorized address) or the value (cosigner) of the mapping. + /// + /// Addresses that map to a non-zero cosigner in the current authVersion are called + /// "authorized addresses". + mapping(uint256 => uint256) public authorizations; + + // (authVersion,96)(padding_0,152)(isSchnorr,1) (authKeyIdx,6)(parity,1) -> merged_ec_pubkey_x (256) + // isSchnorr: 1 -> schnorr, 0 -> not schnorr + mapping(uint256 => bytes32) public mergedKeys; + + /// @notice A per-key nonce value, incremented each time a transaction is processed with that key. + /// Used for replay prevention. The nonce value in the transaction must exactly equal the current + /// nonce value in the wallet for that key. (This mirrors the way Ethereum's transaction nonce works.) + mapping(address => uint256) public nonces; + + /// @notice A mapping tracking dynamically supported interfaces and their corresponding + /// implementation contracts. Keys are interface IDs and values are addresses of + /// contracts that are responsible for implementing the function corresponding to the + /// interface. + /// + /// Delegates are added (or removed) via the `setDelegate` method after the contract is + /// deployed, allowing support for new interfaces to be dynamically added after deployment. + /// When a delegate is added, its interface ID is considered "supported" under EIP165. + /// + /// For cases where an interface composed of more than a single function must be + /// supported, it is necessary to manually add the composite interface ID with + /// `setDelegate(interfaceId, COMPOSITE_PLACEHOLDER)`. Interface IDs added with the + /// COMPOSITE_PLACEHOLDER address are ignored when called and are only used to specify + /// supported interfaces. + mapping(bytes4 => address) public delegates; + + /// @notice A special address that is authorized to call `emergencyRecovery()`. That function + /// resets ALL authorization for this wallet, and must therefore be treated with utmost security. + /// Reasonable choices for recoveryAddress include: + /// - the address of a private key in cold storage + /// - a physically secured hardware wallet + /// - a multisig smart contract, possibly with a time-delayed challenge period + /// - the zero address, if you like performing without a safety net ;-) + address public recoveryAddress; + + /// @notice Used to track whether or not this contract instance has been initialized. This + /// is necessary since it is common for this wallet smart contract to be used as the "library + /// code" for an clone contract. See https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1167.md + /// for more information about clone contracts. + bool public initialized; + + /// @notice Used to decorate methods that can only be called directly by the recovery address. + modifier onlyRecoveryAddress() { + require(msg.sender == recoveryAddress, "sender must be recovery address"); + _; + } + + /// @notice Used to decorate the `init` function so this can only be called one time. Necessary + /// since this contract will often be used as a "clone". (See above.) + modifier onlyOnce() { + require(!initialized, "must not already be initialized"); + initialized = true; + _; + } + + /// @notice Used to decorate methods that can only be called indirectly via an `invoke()` method. + /// In practice, it means that those methods can only be called by a signer/cosigner + /// pair that is currently authorized. Theoretically, we could factor out the + /// signer/cosigner verification code and use it explicitly in this modifier, but that + /// would either result in duplicated code, or additional overhead in the invoke() + /// calls (due to the stack manipulation for calling into the shared verification function). + /// Doing it this way makes calling the administration functions more expensive (since they + /// go through a explicit call() instead of just branching within the contract), but it + /// makes invoke() more efficient. We assume that invoke() will be used much, much more often + /// than any of the administration functions. + modifier onlyInvoked() { + require(msg.sender == address(this), "must be called from `invoke()`"); + _; + } + + /// @notice Emitted when an authorized address is added, removed, or modified. When an + /// authorized address is removed ("deauthorized"), cosigner will be address(0) in + /// this event. + /// + /// NOTE: When emergencyRecovery() is called, all existing addresses are deauthorized + /// WITHOUT Authorized(addr, 0) being emitted. If you are keeping an off-chain mirror of + /// authorized addresses, you must also watch for EmergencyRecovery events. + /// @dev hash is 0xf5a7f4fb8a92356e8c8c4ae7ac3589908381450500a7e2fd08c95600021ee889 + /// @param authorizedAddress the address to authorize or unauthorize + /// @param cosigner the 2-of-2 signatory (optional). + event Authorized(address authorizedAddress, uint256 cosigner); + + event AuthorizedMeregedKey(uint256 authorizedAddress, bytes32 mergedKey); + + /// @notice Emitted when an emergency recovery has been performed. If this event is fired, + /// ALL previously authorized addresses have been deauthorized and the only authorized + /// address is the authorizedAddress indicated in this event. + /// @dev hash is 0xe12d0bbeb1d06d7a728031056557140afac35616f594ef4be227b5b172a604b5 + /// @param authorizedAddress the new authorized address + /// @param cosigner the cosigning address for `authorizedAddress` + event EmergencyRecovery(address authorizedAddress, uint256 cosigner); + + /// @notice Emitted when the recovery address changes. Either (but not both) of the + /// parameters may be zero. + /// @dev hash is 0x568ab3dedd6121f0385e007e641e74e1f49d0fa69cab2957b0b07c4c7de5abb6 + /// @param previousRecoveryAddress the previous recovery address + /// @param newRecoveryAddress the new recovery address + event RecoveryAddressChanged(address previousRecoveryAddress, address newRecoveryAddress); + + /// @dev Emitted when this contract receives a non-zero amount ether via the fallback function + /// (i.e. This event is not fired if the contract receives ether as part of a method invocation) + /// @param from the address which sent you ether + /// @param value the amount of ether sent + event Received(address from, uint256 value); + + /// @notice Emitted whenever a transaction is processed successfully from this wallet. Includes + /// both simple send ether transactions, as well as other smart contract invocations. + /// @dev hash is 0x101214446435ebbb29893f3348e3aae5ea070b63037a3df346d09d3396a34aee + /// @param hash The hash of the entire operation set. 0 is returned when emitted from `invoke0()`. + /// @param result A bitfield of the results of the operations. A bit of 0 means success, and 1 means failure. + /// @param numOperations A count of the number of operations processed + event InvocationSuccess(bytes32 hash, uint256 result, uint256 numOperations); + + /// @notice Emitted when a delegate is added or removed. + /// @param interfaceId The interface ID as specified by EIP165 + /// @param delegate The address of the contract implementing the given function. If this is + /// COMPOSITE_PLACEHOLDER, we are indicating support for a composite interface. + event DelegateUpdated(bytes4 interfaceId, address delegate); + + /// @notice The shared initialization code used to setup the contract state regardless of whether or + /// not the clone pattern is being used. + /// @param _authorizedAddress the initial authorized address, must not be zero! + /// @param _cosigner the initial cosigning address for `_authorizedAddress`, can be equal to `_authorizedAddress` + /// @param _recoveryAddress the initial recovery address for the wallet, can be address(0) + /// @param _mergedKeyIndexWithParity the corresponding index of mergedKeys = authVersion + _mergedIndex + /// @param _mergedKey the corresponding mergedKey (using Schnorr merged key) + function init( + address _authorizedAddress, + uint256 _cosigner, + address _recoveryAddress, + uint8 _mergedKeyIndexWithParity, + bytes32 _mergedKey + ) public onlyOnce { + require(_authorizedAddress != address(0), "Authorized addresses must not be zero."); + require(_authorizedAddress != _recoveryAddress, "Do not use the recovery address as an authorized address."); + require(address(uint160(_cosigner)) != _recoveryAddress, "Do not use the recovery address as a cosigner."); + + recoveryAddress = _recoveryAddress; + // set initial authorization value + authVersion = AUTH_VERSION_INCREMENTOR; + // add initial authorized address + authorizations[AUTH_VERSION_INCREMENTOR + uint256(uint160(_authorizedAddress))] = _cosigner; + mergedKeys[AUTH_VERSION_INCREMENTOR + _mergedKeyIndexWithParity] = _mergedKey; + emit Authorized(_authorizedAddress, _cosigner); + } + + /// @notice The shared initialization code used to setup the contract state regardless of whether or + /// not the clone pattern is being used. + /// @param _authorizedAddresses the initial authorized addresses, must not be zero! + /// @param _cosigner the initial cosigning address for `_authorizedAddress`, can be equal to `_authorizedAddress` + /// @param _recoveryAddress the initial recovery address for the wallet, can be address(0) + /// @param _mergedKeyIndexWithParitys the corresponding index of mergedKeys = authVersion + _mergedIndex + /// @param _mergedKeys the corresponding mergedKey + function init2( + address[] calldata _authorizedAddresses, + uint256 _cosigner, + address _recoveryAddress, + uint8[] calldata _mergedKeyIndexWithParitys, + bytes32[] calldata _mergedKeys + ) public onlyOnce { + require(_authorizedAddresses.length > 0, "invalid _authorizedAddresses array"); + require(_authorizedAddresses.length == _mergedKeyIndexWithParitys.length, "Array length not match with."); + require(_authorizedAddresses.length == _mergedKeys.length, "Array length not match."); + recoveryAddress = _recoveryAddress; + // set initial authorization value + authVersion = AUTH_VERSION_INCREMENTOR; + for (uint256 i = 0; i < _authorizedAddresses.length; i++) { + address _authorizedAddress = _authorizedAddresses[i]; + require(_authorizedAddress != address(0), "Authorized addresses must not be zero."); + authorizations[AUTH_VERSION_INCREMENTOR + uint256(uint160(_authorizedAddress))] = _cosigner; + mergedKeys[AUTH_VERSION_INCREMENTOR + _mergedKeyIndexWithParitys[i]] = _mergedKeys[i]; + + emit Authorized(_authorizedAddress, _cosigner); + } + } + + /// @notice The fallback function, invoked whenever we receive a transaction that doesn't call any of our + /// named functions. In particular, this method is called when we are the target of a simple send + /// transaction, when someone calls a method we have dynamically added a delegate for, or when someone + /// tries to call a function we don't implement, either statically or dynamically. + /// + /// A correct invocation of this method occurs in following case: + /// - someone calls a delegated function (`msg.data.length` is greater than 0 and + /// `delegates[msg.sig]` is set) + /// In all other cases, this function will revert. + /// + /// NOTE: Some smart contracts send 0 eth as part of a more complex operation + /// (-cough- CryptoKitties -cough-); ideally, we'd `require(msg.value > 0)` here when + /// `msg.data.length == 0`, but to work with those kinds of smart contracts, we accept zero sends + /// and just skip logging in that case. + fallback() external payable { + if (msg.value > 0) { + emit Received(msg.sender, msg.value); + } + if (msg.data.length > 0) { + address delegate = delegates[msg.sig]; + require(delegate > COMPOSITE_PLACEHOLDER, "Invalid transaction"); + + // We have found a delegate contract that is responsible for the method signature of + // this call. Now, pass along the calldata of this CALL to the delegate contract. + assembly { + calldatacopy(0, 0, calldatasize()) + let result := staticcall(gas(), delegate, 0, calldatasize(), 0, 0) + returndatacopy(0, 0, returndatasize()) + + // If the delegate reverts, we revert. If the delegate does not revert, we return the data + // returned by the delegate to the original caller. + switch result + case 0 { revert(0, returndatasize()) } + default { return(0, returndatasize()) } + } + } + } + + // solhint-disable-next-line no-empty-blocks + receive() external payable { + if (msg.value > 0) { + emit Received(msg.sender, msg.value); + } + } + + /// @notice Adds or removes dynamic support for an interface. Can be used in 3 ways: + /// - Add a contract "delegate" that implements a single function + /// - Remove delegate for a function + /// - Specify that an interface ID is "supported", without adding a delegate. This is + /// used for composite interfaces when the interface ID is not a single method ID. + /// @dev Must be called through `invoke` + /// @param _interfaceId The ID of the interface we are adding support for + /// @param _delegate Either: + /// - the address of a contract that implements the function specified by `_interfaceId` + /// for adding an implementation for a single function + /// - 0 for removing an existing delegate + /// - COMPOSITE_PLACEHOLDER for specifying support for a composite interface + function setDelegate(bytes4 _interfaceId, address _delegate) external onlyInvoked { + delegates[_interfaceId] = _delegate; + emit DelegateUpdated(_interfaceId, _delegate); + } + + /// @notice Configures an authorizable address. Can be used in four ways: + /// - Add a new signer/cosigner pair (cosigner must be non-zero) + /// - Set or change the cosigner for an existing signer (if authorizedAddress != cosigner) + /// - Remove the cosigning requirement for a signer (if authorizedAddress == cosigner) + /// - Remove a signer (if cosigner == address(0)) + /// @dev Must be called through `invoke()` + /// @param _authorizedAddress the address to configure authorization + /// @param _cosigner the corresponding cosigning address + /// @param _mergedIndexWithParity the corresponding index of mergedKeys = authVersion + _mergedIndex + /// @param _mergedKey the corresponding mergedKey + function setAuthorized( + address _authorizedAddress, + uint256 _cosigner, + uint8 _mergedIndexWithParity, + bytes32 _mergedKey + ) external onlyInvoked { + require(_authorizedAddress != address(0), "Authorized addresses must not be zero."); + require(_authorizedAddress != recoveryAddress, "Do not use the recovery address as an authorized address."); + require( + (address(uint160(_cosigner)) == address(0) && _mergedKey == 0) + || address(uint160(_cosigner)) != recoveryAddress, + "Do not use the recovery address as a cosigner." + ); + + authorizations[authVersion + uint256(uint160(_authorizedAddress))] = _cosigner; + mergedKeys[authVersion + _mergedIndexWithParity] = _mergedKey; + + emit Authorized(_authorizedAddress, _cosigner); + } + + /// @notice Configures an authorizable address to use a merged key. + /// @dev Must be called through `invoke()` + /// @param _meregedKeyIndex the merged key index + /// @param _meregedKey the corresponding merged authorized key & cosigner key by Schnorr + function setMergedKey(uint256 _meregedKeyIndex, bytes32 _meregedKey) external onlyInvoked { + mergedKeys[authVersion + _meregedKeyIndex] = _meregedKey; + emit AuthorizedMeregedKey(_meregedKeyIndex, _meregedKey); + } + + /// @notice Performs an emergency recovery operation, removing all existing authorizations and setting + /// a sole new authorized address with optional cosigner. THIS IS A SCORCHED EARTH SOLUTION, and great + /// care should be taken to ensure that this method is never called unless it is a last resort. See the + /// comments above about the proper kinds of addresses to use as the recoveryAddress to ensure this method + /// is not trivially abused. + /// @param _authorizedAddress the new and sole authorized address + /// @param _cosigner the corresponding cosigner address, can be equal to _authorizedAddress + function emergencyRecovery(address _authorizedAddress, uint256 _cosigner) external onlyRecoveryAddress { + require(_authorizedAddress != address(0), "Authorized addresses must not be zero."); + require(_authorizedAddress != recoveryAddress, "Do not use the recovery address as an authorized address."); + require(address(uint160(_cosigner)) != address(0), "The cosigner must not be zero."); + + // Incrementing the authVersion number effectively erases the authorizations mapping. See the comments + // on the authorizations variable (above) for more information. + authVersion += AUTH_VERSION_INCREMENTOR; + + // Store the new signer/cosigner pair as the only remaining authorized address + authorizations[authVersion + uint256(uint160(_authorizedAddress))] = _cosigner; + emit EmergencyRecovery(_authorizedAddress, _cosigner); + } + + function emergencyRecovery2(address _authorizedAddress, uint256 _cosigner, address _recoveryAddress) + external + onlyRecoveryAddress + { + require(_authorizedAddress != address(0), "Authorized addresses must not be zero."); + require(_authorizedAddress != _recoveryAddress, "Do not use the recovery address as an authorized address."); + require(address(uint160(_cosigner)) != address(0), "The cosigner must not be zero."); + + // Incrementing the authVersion number effectively erases the authorizations mapping. See the comments + // on the authorizations variable (above) for more information. + authVersion += AUTH_VERSION_INCREMENTOR; + + // Store the new signer/cosigner pair as the only remaining authorized address + authorizations[authVersion + uint256(uint160(_authorizedAddress))] = _cosigner; + + // set new recovery address + address previous = recoveryAddress; + recoveryAddress = _recoveryAddress; + + emit RecoveryAddressChanged(previous, recoveryAddress); + emit EmergencyRecovery(_authorizedAddress, _cosigner); + } + + /// @notice Sets the recovery address, which can be zero (indicating that no recovery is possible) + /// Can be updated by any authorized address. This address should be set with GREAT CARE. See the + /// comments above about the proper kinds of addresses to use as the recoveryAddress to ensure this + /// mechanism is not trivially abused. + /// @dev Must be called through `invoke()` + /// @param _recoveryAddress the new recovery address + function setRecoveryAddress(address _recoveryAddress) external onlyInvoked { + require( + address(uint160(authorizations[authVersion + uint256(uint160(_recoveryAddress))])) == address(0), + "Do not use an authorized address as the recovery address." + ); + + address previous = recoveryAddress; + recoveryAddress = _recoveryAddress; + + emit RecoveryAddressChanged(previous, recoveryAddress); + } + + /// @notice Allows ANY caller to recover gas by way of deleting old authorization keys after + /// a recovery operation. Anyone can call this method to delete the old unused storage and + /// get themselves a bit of gas refund in the bargin. + /// @dev keys must be known to caller or else nothing is refunded + /// @param _version the version of the mapping which you want to delete (unshifted) + /// @param _keys the authorization keys to delete + function recoverGas(uint256 _version, address[] calldata _keys) external { + // TODO: should this be 0xffffffffffffffffffffffff ? + require(_version > 0 && _version < 0xffffffff, "Invalid version number."); + + uint256 shiftedVersion = _version << 160; + + require(shiftedVersion < authVersion, "You can only recover gas from expired authVersions."); + + for (uint256 i = 0; i < _keys.length; ++i) { + delete(authorizations[shiftedVersion + uint256(uint160(_keys[i]))]); + } + } + + function calculateParity(uint256 boolsUintCopy) internal pure returns (uint8) { + // uint256 boolsUintCopy = boolsUint; + uint8 _count = 0; + for (uint8 i = 0; i < 255; i++) { + if (boolsUintCopy & 1 == 1) { + _count++; + } + boolsUintCopy >>= 1; + } + // console.log("count: %d", _count); + return (_count & 1) + 27; + } + + function verifySchnorr(bytes32 hash, bytes memory sig) internal view returns (bool) { + // px := public key x-coord + // e := schnorr signature challenge + // s := schnorr signature + // parity := public key y-coord parity (27 or 28) + (bytes32 e, bytes32 s, uint8 keyIndexWithParity) = sig.extractSignature(0); + bytes32 px = mergedKeys[authVersion + uint256(keyIndexWithParity)]; + uint8 parity = (keyIndexWithParity & 0x1) + 27; + + bytes32 sp = bytes32(Q - mulmod(uint256(s), uint256(px), Q)); + bytes32 ep = bytes32(Q - mulmod(uint256(e), uint256(px), Q)); + + require(sp != 0); + // the ecrecover precompile implementation checks that the `r` and `s` + // inputs are non-zero (in this case, `px` and `ep`), thus we don't need to + // check if they're zero. + address R = ecrecover(sp, parity, px, ep); + require(R != address(0), "ecrecover failed"); + // return e == keccak256(abi.encodePacked(R, uint8(parity), px, hash)) ? address(uint160(uint256(px))) : address(0); + return e == keccak256(abi.encodePacked(R, parity, px, hash)); + } + + /// @notice Should return whether the signature provided is valid for the provided data + /// See https://github.com/ethereum/EIPs/issues/1271 + /// @dev This function meets the following conditions as per the EIP: + /// MUST return the bytes4 magic value `0x1626ba7e` when function passes. + /// MUST NOT modify state (using `STATICCALL` for solc < 0.5, `view` modifier for solc > 0.5) + /// MUST allow external calls + /// @param _hash A 32 byte hash of the signed data. The actual hash that is hashed however is the + /// the following tightly packed arguments: `0x19,0x0,wallet_address,hash` + /// @param _signature Signature byte array associated with `_data` + /// @return Magic value `0x1626ba7e` upon success, 0 otherwise. + function isValidSignature(bytes32 _hash, bytes calldata _signature) external view returns (bytes4) { + // return verifySchnorr(hash, _signature) ? IERC1271.isValidSignature.selector : bytes4(0); + // We 'hash the hash' for the following reasons: + // 1. `hash` is not the hash of an Ethereum transaction + // 2. signature must target this wallet to avoid replaying the signature for another wallet + // with the same key + // 3. Gnosis does something similar: + // https://github.com/gnosis/safe-contracts/blob/102e632d051650b7c4b0a822123f449beaf95aed/contracts/GnosisSafe.sol + bytes32 operationHash = keccak256(abi.encodePacked(EIP191_PREFIX, EIP191_VERSION_DATA, this, _hash)); + + if (_signature.length == 65 && (_signature[64] & 0x80) > 0) { + return verifySchnorr(operationHash, _signature) ? IERC1271.isValidSignature.selector : bytes4(0); + } + + bytes32[2] memory r; + bytes32[2] memory s; + uint8[2] memory v; + address signer; + address cosigner; + + // extract 1 or 2 signatures depending on length + if (_signature.length == 65) { + (r[0], s[0], v[0]) = _signature.extractSignature(0); + signer = ecrecover(operationHash, v[0], r[0], s[0]); + cosigner = signer; + } else if (_signature.length == 130) { + (r[0], s[0], v[0]) = _signature.extractSignature(0); + (r[1], s[1], v[1]) = _signature.extractSignature(65); + signer = ecrecover(operationHash, v[0], r[0], s[0]); + cosigner = ecrecover(operationHash, v[1], r[1], s[1]); + } else { + return 0; + } + + // check for valid signature + if (signer == address(0)) { + return 0; + } + + // check for valid signature + if (cosigner == address(0)) { + return 0; + } + + // check to see if this is an authorized key + if (address(uint160(authorizations[authVersion + uint256(uint160(signer))])) != cosigner) { + return 0; + } + + return IERC1271.isValidSignature.selector; + } + + /// @notice A version of `invoke()` that has no explicit signatures, and uses msg.sender + /// as both the signer and cosigner. Will only succeed if `msg.sender` is an authorized + /// signer for this wallet, with no cosigner, saving transaction size and gas in that case. + /// @param data The data containing the transactions to be invoked; see internalInvoke for details. + function invoke0(bytes calldata data) external { + // The nonce doesn't need to be incremented for transactions that don't include explicit signatures; + // the built-in nonce of the native ethereum transaction will protect against replay attacks, and we + // can save the gas that would be spent updating the nonce variable + + // The operation should be approved if the signer address has no cosigner (i.e. signer == cosigner) + require( + address(uint160(authorizations[authVersion + uint256(uint160(msg.sender))])) == msg.sender, + "Invalid authorization." + ); + + internalInvoke(0, data); + } + + /// @notice A version of `invoke()` that has one explicit signature which is used to derive the authorized + /// address. Uses `msg.sender` as the cosigner. + /// @param v the v value for the signature; see https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md + /// @param r the r value for the signature + /// @param s the s value for the signature + /// @param nonce the nonce value for the signature + /// @param authorizedAddress the address of the authorization key; this is used here so that cosigner signatures are interchangeable + /// between this function and `invoke2()` + /// @param data The data containing the transactions to be invoked; see internalInvoke for details. + function invoke1CosignerSends( + uint8 v, + bytes32 r, + bytes32 s, + uint256 nonce, + address authorizedAddress, + bytes calldata data + ) external { + // check signature version + require(v == 27 || v == 28, "Invalid signature version."); + + // calculate hash + bytes32 operationHash = + keccak256(abi.encodePacked(EIP191_PREFIX, EIP191_VERSION_DATA, this, nonce, authorizedAddress, data)); + + // recover signer + address signer = ecrecover(operationHash, v, r, s); + + // check for valid signature + require(signer != address(0), "Invalid signature."); + + // check nonce + require(nonce > nonces[signer], "must use valid nonce for signer"); + + // check signer + require(signer == authorizedAddress, "authorized addresses must be equal"); + + // Get cosigner + address requiredCosigner = address(uint160(authorizations[authVersion + uint256(uint160(signer))])); + + // The operation should be approved if the signer address has no cosigner (i.e. signer == cosigner) or + // if the actual cosigner matches the required cosigner. + require(requiredCosigner == signer || requiredCosigner == msg.sender, "Invalid authorization."); + + // increment nonce to prevent replay attacks + nonces[signer] = nonce; + + // call internal function + internalInvoke(operationHash, data); + } + + /// @notice A version of `invoke()` that has one explicit signature which is used to derive the cosigning + /// address. Uses `msg.sender` as the authorized address. + /// @param v the v value for the signature; see https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md + /// @param r the r value for the signature + /// @param s the s value for the signature + /// @param data The data containing the transactions to be invoked; see internalInvoke for details. + function invoke1SignerSends(uint8 v, bytes32 r, bytes32 s, bytes calldata data) external { + // check signature version + // `ecrecover` will in fact return 0 if given invalid + // so perhaps this check is redundant + require(v == 27 || v == 28, "Invalid signature version."); + + uint256 nonce = nonces[msg.sender]; + + // calculate hash + bytes32 operationHash = + keccak256(abi.encodePacked(EIP191_PREFIX, EIP191_VERSION_DATA, this, nonce, msg.sender, data)); + + // recover cosigner + address cosigner = ecrecover(operationHash, v, r, s); + + // check for valid signature + require(cosigner != address(0), "Invalid signature."); + + // Get required cosigner + address requiredCosigner = address(uint160(authorizations[authVersion + uint256(uint160(msg.sender))])); + + // The operation should be approved if the signer address has no cosigner (i.e. signer == cosigner) or + // if the actual cosigner matches the required cosigner. + require(requiredCosigner == cosigner || requiredCosigner == msg.sender, "Invalid authorization."); + + // increment nonce to prevent replay attacks + nonces[msg.sender] = nonce + 1; + + internalInvoke(operationHash, data); + } + + /// @notice A version of `invoke()` that has two explicit signatures, the first is used to derive the authorized + /// address, the second to derive the cosigner. The value of `msg.sender` is ignored. + /// @param v the v values for the signatures + /// @param r the r values for the signatures + /// @param s the s values for the signatures + /// @param nonce the nonce value for the signature + /// @param authorizedAddress the address of the signer; forces the signature to be unique and tied to the signers nonce + /// @param data The data containing the transactions to be invoked; see internalInvoke for details. + function invoke2( + uint8[2] calldata v, + bytes32[2] calldata r, + bytes32[2] calldata s, + uint256 nonce, + address authorizedAddress, + bytes calldata data + ) external { + // check signature versions + // `ecrecover` will infact return 0 if given invalid + // so perhaps these checks are redundant + require(v[0] == 27 || v[0] == 28, "invalid signature version v[0]"); + require(v[1] == 27 || v[1] == 28, "invalid signature version v[1]"); + + bytes32 operationHash = + keccak256(abi.encodePacked(EIP191_PREFIX, EIP191_VERSION_DATA, this, nonce, authorizedAddress, data)); + + // recover signer and cosigner + address signer = ecrecover(operationHash, v[0], r[0], s[0]); + address cosigner = ecrecover(operationHash, v[1], r[1], s[1]); + + // check for valid signatures + require(signer != address(0), "Invalid signature for signer."); + require(cosigner != address(0), "Invalid signature for cosigner."); + + // check signer address + require(signer == authorizedAddress, "authorized addresses must be equal"); + + // check nonces + require(nonce > nonces[signer], "must use valid nonce for signer"); + + // Get Mapping + address requiredCosigner = address(uint160(authorizations[authVersion + uint256(uint160(signer))])); + + // The operation should be approved if the signer address has no cosigner (i.e. signer == cosigner) or + // if the actual cosigner matches the required cosigner. + require(requiredCosigner == signer || requiredCosigner == cosigner, "Invalid authorization."); + + // increment nonce to prevent replay attacks + nonces[signer] = nonce; + + internalInvoke(operationHash, data); + } + + /// @dev Internal invoke call, + /// @param operationHash The hash of the operation + /// @param data The data to send to the `call()` operation + /// The data is prefixed with a global 1 byte revert flag + /// If revert is 1, then any revert from a `call()` operation is rethrown. + /// Otherwise, the error is recorded in the `result` field of the `InvocationSuccess` event. + /// Immediately following the revert byte (no padding), the data format is then is a series + /// of 1 or more tightly packed tuples: + /// `` + /// If `datalength == 0`, the data field must be omitted + function internalInvoke(bytes32 operationHash, bytes memory data) internal { + // keep track of the number of operations processed + uint256 numOps; + // keep track of the result of each operation as a bit + uint256 result; + + // We need to store a reference to this string as a variable so we can use it as an argument to + // the revert call from assembly. + string memory invalidLengthMessage = "Data field too short"; + string memory callFailed = "Call failed"; + + // At an absolute minimum, the data field must be at least 85 bytes + // + require(data.length >= 85, invalidLengthMessage); + + // Forward the call onto its actual target. Note that the target address can be `self` here, which is + // actually the required flow for modifying the configuration of the authorized keys and recovery address. + // + // The assembly code below loads data directly from memory, so the enclosing function must be marked `internal` + assembly { + // A cursor pointing to the revert flag, starts after the length field of the data object + let memPtr := add(data, 32) + + // The revert flag is the leftmost byte from memPtr + let revertFlag := byte(0, mload(memPtr)) + + // A pointer to the end of the data object + let endPtr := add(memPtr, mload(data)) + + // Now, memPtr is a cursor pointing to the beginning of the current sub-operation + memPtr := add(memPtr, 1) + + // Loop through data, parsing out the various sub-operations + for {} lt(memPtr, endPtr) {} { + // Load the length of the call data of the current operation + // 52 = to(20) + value(32) + let len := mload(add(memPtr, 52)) + + // Compute a pointer to the end of the current operation + // 84 = to(20) + value(32) + size(32) + let opEnd := add(len, add(memPtr, 84)) + + // Bail if the current operation's data overruns the end of the enclosing data buffer + // NOTE: Comment out this bit of code and uncomment the next section if you want + // the solidity-coverage tool to work. + // See https://github.com/sc-forks/solidity-coverage/issues/287 + if gt(opEnd, endPtr) { + // The computed end of this operation goes past the end of the data buffer. Not good! + revert(add(invalidLengthMessage, 32), mload(invalidLengthMessage)) + } + // NOTE: Code that is compatible with solidity-coverage + // switch gt(opEnd, endPtr) + // case 1 { + // revert(add(invalidLengthMessage, 32), mload(invalidLengthMessage)) + // } + + // This line of code packs in a lot of functionality! + // - load the target address from memPtr, the address is only 20-bytes but mload always grabs 32-bytes, + // so we have to shr by 12 bytes. + // - load the value field, stored at memPtr+20 + // - pass a pointer to the call data, stored at memPtr+84 + // - use the previously loaded len field as the size of the call data + // - make the call (passing all remaining gas to the child call) + // - check the result (0 == reverted) + if eq(0, call(gas(), shr(96, mload(memPtr)), mload(add(memPtr, 20)), add(memPtr, 84), len, 0, 0)) { + switch revertFlag + case 1 { revert(add(callFailed, 32), mload(callFailed)) } + default { + // mark this operation as failed + // create the appropriate bit, 'or' with previous + result := or(result, exp(2, numOps)) + } + } + + // increment our counter + numOps := add(numOps, 1) + + // Update mem pointer to point to the next sub-operation + memPtr := opEnd + } + } + + // emit single event upon success + emit InvocationSuccess(operationHash, result, numOps); + } +} diff --git a/contracts/CoreWallet/README.md b/contracts/CoreWallet/README.md new file mode 100644 index 0000000..345f1ea --- /dev/null +++ b/contracts/CoreWallet/README.md @@ -0,0 +1,3 @@ +## Overview + +Fork CoreWallet from https://github.com/dapperlabs/dapper-contracts & https://github.com/portto/evm-contract-wallet diff --git a/contracts/Paymaster/VerifyingPaymaster.sol b/contracts/Paymaster/VerifyingPaymaster.sol new file mode 100644 index 0000000..e24dcdb --- /dev/null +++ b/contracts/Paymaster/VerifyingPaymaster.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +/* solhint-disable reason-string */ +/* solhint-disable no-inline-assembly */ + +import "@account-abstraction/contracts/core/BasePaymaster.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +/** + * A sample paymaster that uses external service to decide whether to pay for the UserOp. + * The paymaster trusts an external signer to sign the transaction. + * The calling user must pass the UserOp to that external signer first, which performs + * whatever off-chain verification before signing the UserOp. + * Note that this signature is NOT a replacement for the account-specific signature: + * - the paymaster checks a signature to agree to PAY for GAS. + * - the account checks a signature to prove identity and account ownership. + */ + +contract VerifyingPaymaster is BasePaymaster { + using ECDSA for bytes32; + using UserOperationLib for UserOperation; + + address public verifyingSigner; + + uint256 private constant VALID_TIMESTAMP_OFFSET = 20; + + uint256 private constant SIGNATURE_OFFSET = 84; + + constructor(IEntryPoint _entryPoint, address _verifyingSigner) BasePaymaster(_entryPoint) { + verifyingSigner = _verifyingSigner; + } + + function setVerifyingSigner(address _verifyingSigner) public onlyOwner { + verifyingSigner = _verifyingSigner; + } + + mapping(address => uint256) public senderNonce; + + function pack(UserOperation calldata userOp) internal pure returns (bytes memory ret) { + // lighter signature scheme. must match UserOp.ts#packUserOp + bytes calldata pnd = userOp.paymasterAndData; + // copy directly the userOp from calldata up to (but not including) the paymasterAndData. + // 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(pnd.offset, ofs), 32) + ret := mload(0x40) + mstore(0x40, add(ret, add(len, 32))) + mstore(ret, len) + calldatacopy(add(ret, 32), ofs, len) + } + } + + /** + * return the hash we're going to sign off-chain (and validate on-chain) + * this method is called by the off-chain service, to sign the request. + * it is called on-chain from the validatePaymasterUserOp, to validate the signature. + * note that this signature covers all fields of the UserOperation, except the "paymasterAndData", + * which will carry the signature itself. + */ + function getHash(UserOperation calldata userOp, uint48 validUntil, uint48 validAfter) + public + view + returns (bytes32) + { + //can't use userOp.hash(), since it contains also the paymasterAndData itself. + + return keccak256( + abi.encode( + pack(userOp), block.chainid, address(this), senderNonce[userOp.getSender()], validUntil, validAfter + ) + ); + } + + /** + * verify our external signer signed this request. + * the "paymasterAndData" is expected to be the paymaster and a signature over the entire request params + * paymasterAndData[:20] : address(this) + * paymasterAndData[20:84] : abi.encode(validUntil, validAfter) + * paymasterAndData[84:] : signature + */ + function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32, /*userOpHash*/ uint256 requiredPreFund) + internal + override + returns (bytes memory context, uint256 validationData) + { + (requiredPreFund); + + (uint48 validUntil, uint48 validAfter, bytes calldata signature) = + parsePaymasterAndData(userOp.paymasterAndData); + //ECDSA library supports both 64 and 65-byte long signatures. + // we only "require" it here so that the revert reason on invalid signature will be of "VerifyingPaymaster", and not "ECDSA" + require( + signature.length == 64 || signature.length == 65, + "VerifyingPaymaster: invalid signature length in paymasterAndData" + ); + bytes32 hash = ECDSA.toEthSignedMessageHash(getHash(userOp, validUntil, validAfter)); + senderNonce[userOp.getSender()]++; + + //don't revert on signature failure: return SIG_VALIDATION_FAILED + if (verifyingSigner != ECDSA.recover(hash, signature)) { + return ("", _packValidationData(true, validUntil, validAfter)); + } + + //no need for other on-chain validation: entire UserOp should have been checked + // by the external service prior to signing it. + return ("", _packValidationData(false, validUntil, validAfter)); + } + + function parsePaymasterAndData(bytes calldata paymasterAndData) + public + pure + returns (uint48 validUntil, uint48 validAfter, bytes calldata signature) + { + (validUntil, validAfter) = + abi.decode(paymasterAndData[VALID_TIMESTAMP_OFFSET:SIGNATURE_OFFSET], (uint48, uint48)); + signature = paymasterAndData[SIGNATURE_OFFSET:]; + } +} diff --git a/contracts/TokenCallbackHandler.sol b/contracts/TokenCallbackHandler.sol new file mode 100644 index 0000000..60e0210 --- /dev/null +++ b/contracts/TokenCallbackHandler.sol @@ -0,0 +1,48 @@ +// 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/contracts/test/TestBloctoAccountCloneableWalletV200.sol b/contracts/test/TestBloctoAccountCloneableWalletV200.sol new file mode 100644 index 0000000..e8ff7be --- /dev/null +++ b/contracts/test/TestBloctoAccountCloneableWalletV200.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +import "./TestBloctoAccountV200.sol"; + +/// @title BloctoAccountCloneableWallet Wallet +/// @notice This contract represents a complete but non working wallet. +contract TestBloctoAccountCloneableWalletV200 is TestBloctoAccountV200 { + /// @dev Cconstructor that deploys a NON-FUNCTIONAL version of `TestBloctoAccountV140` + constructor(IEntryPoint anEntryPoint) TestBloctoAccountV200(anEntryPoint) { + initialized = true; + } +} diff --git a/contracts/test/TestBloctoAccountFactoryV200.sol b/contracts/test/TestBloctoAccountFactoryV200.sol new file mode 100644 index 0000000..9eaddb5 --- /dev/null +++ b/contracts/test/TestBloctoAccountFactoryV200.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +/* solhint-disable avoid-low-level-calls */ +/* solhint-disable no-inline-assembly */ +/* solhint-disable reason-string */ + +import "../BloctoAccountFactory.sol"; + +/// Test Blocto account Factory +contract TestBloctoAccountFactoryV200 is Initializable, OwnableUpgradeable { + /// @notice this is the version of this contract. + string public constant VERSION = "2.0.0"; + /// @notice the init implementation address of BloctoAccountCloneableWallet, never change for cosistent address + address public initImplementation; + /// @notice the implementation address of BloctoAccountCloneableWallet + address public bloctoAccountImplementation; + /// @notice the address from EIP-4337 official implementation + IEntryPoint public entryPoint; + + event WalletCreated(address wallet, address authorizedAddress, bool full); + + /// @notice initialize + /// @param _bloctoAccountImplementation the implementation address for BloctoAccountCloneableWallet + /// @param _entryPoint the entrypoint address from EIP-4337 official implementation + function initialize(address _bloctoAccountImplementation, IEntryPoint _entryPoint) public initializer { + __Ownable_init_unchained(); + initImplementation = _bloctoAccountImplementation; + bloctoAccountImplementation = _bloctoAccountImplementation; + entryPoint = _entryPoint; + } + + /// @notice create an account, and return its BloctoAccount. + /// returns the address even if the account is already deployed. + /// Note that during UserOperation execution, this method is called only if the account is not deployed. + /// This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation + /// @param _authorizedAddress the initial authorized address, must not be zero! + /// @param _cosigner the initial cosigning address for `_authorizedAddress`, can be equal to `_authorizedAddress` + /// @param _recoveryAddress the initial recovery address for the wallet, can be address(0) + /// @param _salt salt for create account (used for address calculation in create2) + /// @param _mergedKeyIndexWithParity the corresponding index of mergedKeys = authVersion + _mergedIndex + /// @param _mergedKey the corresponding mergedKey (using Schnorr merged key) + function createAccount( + address _authorizedAddress, + address _cosigner, + address _recoveryAddress, + uint256 _salt, + uint8 _mergedKeyIndexWithParity, + bytes32 _mergedKey + ) public onlyOwner returns (BloctoAccount ret) { + bytes32 salt = keccak256(abi.encodePacked(_salt, _cosigner, _recoveryAddress)); + // to be consistent address + BloctoAccountProxy newProxy = new BloctoAccountProxy{salt: salt}(initImplementation); + ret = BloctoAccount(payable(address(newProxy))); + // to save gas, first deploy don't need to call initImplementation + // to be consistent address, (after) first upgrade need to call initImplementation + ret.initImplementation(bloctoAccountImplementation); + ret.init( + _authorizedAddress, uint256(uint160(_cosigner)), _recoveryAddress, _mergedKeyIndexWithParity, _mergedKey + ); + emit WalletCreated(address(ret), _authorizedAddress, false); + } + + /// @notice create an account with multiple authorized addresses, and return its BloctoAccount. + /// returns the address even if the account is already deployed. + /// @param _authorizedAddresses the initial authorized addresses, must not be zero! + /// @param _cosigner the initial cosigning address for `_authorizedAddress`, can be equal to `_authorizedAddress` + /// @param _recoveryAddress the initial recovery address for the wallet, can be address(0) + /// @param _salt salt for create account (used for address calculation in create2) + /// @param _mergedKeyIndexWithParitys the corresponding index of mergedKeys = authVersion + _mergedIndex + /// @param _mergedKeys the corresponding mergedKey + function createAccount2( + address[] calldata _authorizedAddresses, + address _cosigner, + address _recoveryAddress, + uint256 _salt, + uint8[] calldata _mergedKeyIndexWithParitys, + bytes32[] calldata _mergedKeys + ) public onlyOwner returns (BloctoAccount ret) { + bytes32 salt = keccak256(abi.encodePacked(_salt, _cosigner, _recoveryAddress)); + // to be consistent address + BloctoAccountProxy newProxy = new BloctoAccountProxy{salt: salt}(initImplementation); + + ret = BloctoAccount(payable(address(newProxy))); + // to save gas, first deploy don't need to call initImplementation + // to be consistent address, (after) first upgrade need to call initImplementation + ret.initImplementation(bloctoAccountImplementation); + ret.init2( + _authorizedAddresses, uint256(uint160(_cosigner)), _recoveryAddress, _mergedKeyIndexWithParitys, _mergedKeys + ); + // emit event only with _authorizedAddresses[0] + emit WalletCreated(address(ret), _authorizedAddresses[0], true); + } + + /// @notice calculate the counterfactual address of this account as it would be returned by createAccount() + /// @param _cosigner the initial cosigning address + /// @param _recoveryAddress the initial recovery address for the wallet + /// @param _salt salt for create account (used for address calculation in create2) + function getAddress(address _cosigner, address _recoveryAddress, uint256 _salt) public view returns (address) { + bytes32 salt = keccak256(abi.encodePacked(_salt, _cosigner, _recoveryAddress)); + return Create2.computeAddress( + bytes32(salt), + keccak256(abi.encodePacked(type(BloctoAccountProxy).creationCode, abi.encode(address(initImplementation)))) + ); + } + + /// @notice set the implementation + /// @param _bloctoAccountImplementation update the implementation address of BloctoAccountCloneableWallet for createAccount and createAccount2 + function setImplementation(address _bloctoAccountImplementation) public onlyOwner { + bloctoAccountImplementation = _bloctoAccountImplementation; + } + + /// @notice set the entrypoint + /// @param _entrypoint target entrypoint + function setEntrypoint(IEntryPoint _entrypoint) public onlyOwner { + entryPoint = _entrypoint; + } + + /// @notice withdraw value from the deposit + /// @param withdrawAddress target to send to + /// @param amount to withdraw + function withdrawTo(address payable withdrawAddress, uint256 amount) public onlyOwner { + entryPoint.withdrawTo(withdrawAddress, amount); + } + + /// @notice add stake in etnrypoint for this factory to avoid bundler reject + /// @param unstakeDelaySec - the unstake delay for this factory. Can only be increased. + function addStake(uint32 unstakeDelaySec) external payable onlyOwner { + entryPoint.addStake{value: msg.value}(unstakeDelaySec); + } +} diff --git a/contracts/test/TestBloctoAccountV200.sol b/contracts/test/TestBloctoAccountV200.sol new file mode 100644 index 0000000..83b6077 --- /dev/null +++ b/contracts/test/TestBloctoAccountV200.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.12; + +/* solhint-disable avoid-low-level-calls */ +/* solhint-disable no-inline-assembly */ +/* solhint-disable reason-string */ + +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import "@account-abstraction/contracts/core/BaseAccount.sol"; + +import "../TokenCallbackHandler.sol"; +import "../CoreWallet/CoreWallet.sol"; + +/** + * Blocto account. + * compatibility for EIP-4337 and smart contract wallet with cosigner functionality (CoreWallet) + */ +contract TestBloctoAccountV200 is UUPSUpgradeable, TokenCallbackHandler, CoreWallet, BaseAccount { + /** + * This is the version of this contract. + */ + string public constant VERSION = "2.0.0"; + + IEntryPoint private immutable _entryPoint; + + /** + * constructor for BloctoAccount + * @param anEntryPoint entrypoint address + */ + constructor(IEntryPoint anEntryPoint) { + _entryPoint = anEntryPoint; + } + + /** + * override from UUPSUpgradeable + * @param newImplementation implementation address + */ + function _authorizeUpgrade(address newImplementation) internal view override onlyInvoked { + (newImplementation); + } + + /** + * return entrypoint + */ + function entryPoint() public view virtual override returns (IEntryPoint) { + return _entryPoint; + } + + /** + * execute a transaction (called directly by entryPoint) + * @param dest dest call address + * @param value value to send + * @param func the func containing the transaction to be called + */ + function execute(address dest, uint256 value, bytes calldata func) external { + _requireFromEntryPoint(); + _call(dest, value, func); + } + + /** + * execute a sequence of transactions (called directly by entryPoint) + * @param dest sequence of dest call address + * @param value sequence of value to send + * @param func sequence of the func containing transactions to be called + */ + function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata func) external { + _requireFromEntryPoint(); + require(dest.length == func.length, "wrong array lengths"); + for (uint256 i = 0; i < dest.length; i++) { + _call(dest[i], value[i], func[i]); + } + } + + /** + * internal call for execute and executeBatch + * @param target target call address + * @param value value to send + * @param data the data containing the transaction to be called + */ + function _call(address target, uint256 value, bytes memory data) internal { + (bool success, bytes memory result) = target.call{value: value}(data); + if (!success) { + assembly { + revert(add(result, 32), mload(result)) + } + } + } + + /** + * implement validate signature method of BaseAccount from etnrypoint + * @param userOp user operation including signature for validating + * @param userOpHash user operation hash + */ + function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) + internal + virtual + override + returns (uint256 validationData) + { + bytes4 result = this.isValidSignature(userOpHash, userOp.signature); + if (result != IERC1271.isValidSignature.selector) { + return SIG_VALIDATION_FAILED; + } + + return 0; + } + + /** + * check current account deposit in the entryPoint StakeManager + */ + function getDeposit() public view returns (uint256) { + return entryPoint().balanceOf(address(this)); + } + + /** + * deposit more funds for this account in the entryPoint StakeManager + */ + function addDeposit() public payable { + entryPoint().depositTo{value: msg.value}(address(this)); + } + + /** + * withdraw deposit to withdrawAddress from entryPoint StakeManager + * @param withdrawAddress target to send to + * @param amount to withdraw + */ + function withdrawDepositTo(address payable withdrawAddress, uint256 amount) external onlyInvoked { + entryPoint().withdrawTo(withdrawAddress, amount); + } + + /// @notice initialized _IMPLEMENTATION_SLOT + /// @dev for first depoloy, set it to true for prevent call initImplementation + /// @dev (after) first upgrade, set it to false and immediately setting initImplementation + bool public initializedImplementation = false; + + /// @notice Used to decorate the `init` function so this can only be called one time. Necessary + /// since this contract will often be used as a "clone". (See above.) + modifier onlyOnceInitImplementation() { + require(!initializedImplementation, "must not already be initialized"); + initializedImplementation = true; + _; + } + + /// @notice initialize BloctoAccountProxy for adding the implementation address + /// @param implementation implementation address + function initImplementation(address implementation) public onlyOnceInitImplementation { + require(Address.isContract(implementation), "ERC1967: new implementation is not a contract"); + StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = implementation; + } +} diff --git a/contracts/test/TestERC20.sol b/contracts/test/TestERC20.sol new file mode 100644 index 0000000..a783f1e --- /dev/null +++ b/contracts/test/TestERC20.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.12; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; + +contract TestERC20 is ERC20, ERC20Burnable, AccessControl { + uint8 private immutable _decimals; + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + constructor(string memory name, string memory symbol, uint8 decimals_) ERC20(name, symbol) { + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + _setupRole(MINTER_ROLE, msg.sender); + + _decimals = decimals_; + } + + function mint(address to, uint256 amount) public { + require(hasRole(MINTER_ROLE, msg.sender)); + _mint(to, amount); + } + + function decimals() public view virtual override returns (uint8) { + return _decimals; + } +} diff --git a/contracts/test/TestUtil.sol b/contracts/test/TestUtil.sol new file mode 100644 index 0000000..be6d4f9 --- /dev/null +++ b/contracts/test/TestUtil.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0 +// from: https://github.com/eth-infinitism/account-abstraction/tree/develop/contracts/test +pragma solidity ^0.8.12; + +import "@account-abstraction/contracts/interfaces/UserOperation.sol"; + +contract TestUtil { + using UserOperationLib for UserOperation; + + function packUserOp(UserOperation calldata op) external pure returns (bytes memory){ + return op.pack(); + } + +} diff --git a/deploy/0_deploy_Account_Factory-and-addStake.ts b/deploy/0_deploy_Account_Factory-and-addStake.ts new file mode 100644 index 0000000..6966cb5 --- /dev/null +++ b/deploy/0_deploy_Account_Factory-and-addStake.ts @@ -0,0 +1,68 @@ +import { EntryPoint__factory } from '@account-abstraction/contracts' +import { BigNumber } from 'ethers' +import hre, { ethers } from 'hardhat' +import { getImplementationAddress } from '@openzeppelin/upgrades-core' + +const BloctoAccountCloneableWallet = 'BloctoAccountCloneableWallet' +const BloctoAccountFactory = 'BloctoAccountFactory' +const EntryPoint = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789' +const GasLimit = 6000000 + +async function main (): Promise { + // const lockedAmount = ethers.utils.parseEther("1"); + const [owner] = await ethers.getSigners() + console.log('deploy with account: ', owner.address) + + const BloctoAccountCloneableWalletContract = await ethers.getContractFactory(BloctoAccountCloneableWallet) + const walletCloneable = await BloctoAccountCloneableWalletContract.deploy(EntryPoint, { + gasLimit: GasLimit + }) + + await walletCloneable.deployed() + + console.log(`${BloctoAccountCloneableWallet} deployed to: ${walletCloneable.address}`) + + // account factory + const BloctoAccountFactoryContract = await ethers.getContractFactory(BloctoAccountFactory) + const accountFactory = await upgrades.deployProxy(BloctoAccountFactoryContract, [walletCloneable.address, EntryPoint], + { initializer: 'initialize', gasLimit: GasLimit }) + + await accountFactory.deployed() + + console.log(`BloctoAccountFactory deployed to: ${accountFactory.address}`) + + // add stake + const tx = await accountFactory.addStake(BigNumber.from(86400 * 3650), { value: ethers.utils.parseEther('0.001') }) + await tx.wait() + + const entrypoint = EntryPoint__factory.connect(EntryPoint, ethers.provider) + const depositInfo = await entrypoint.getDepositInfo(accountFactory.address) + console.log('stake: ', ethers.utils.formatUnits(depositInfo.stake), ', unstakeDelaySec: ', depositInfo.unstakeDelaySec) + + // sleep 10 seconds + console.log('sleep 10 seconds for chain sync...') + await new Promise(f => setTimeout(f, 10000)) + + // verify BloctoAccountCloneableWallet + await hre.run('verify:verify', { + address: walletCloneable.address, + contract: 'contracts/BloctoAccountCloneableWallet.sol:BloctoAccountCloneableWallet', + constructorArguments: [ + EntryPoint + ] + }) + + // verify BloctoAccountFactory (if proxy) + const accountFactoryImplAddress = await getImplementationAddress(ethers.provider, accountFactory.address) + await hre.run('verify:verify', { + address: accountFactoryImplAddress, + contract: 'contracts/BloctoAccountFactory.sol:BloctoAccountFactory' + }) +} + +// We recommend this pattern to be able to use async/await everywhere +// and properly handle errors. +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/deploy/0_verify.ts b/deploy/0_verify.ts new file mode 100644 index 0000000..29c7fa6 --- /dev/null +++ b/deploy/0_verify.ts @@ -0,0 +1,47 @@ +import { getImplementationAddress } from '@openzeppelin/upgrades-core' +import hre, { ethers } from 'hardhat' + +const BloctoAccountCloneableWalletAddr = '0x490B5ED8A17224a553c34fAA642161c8472118dd' +const BloctoAccountFactoryAddr = '0x285cc5232236D227FCb23E6640f87934C948a028' +// const BloctoAccountProxyCloneAddr = '0x6672e24A9D809A1b03317e83949572e71afae5be' +const EntryPoint = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789' + +async function main (): Promise { + // verify BloctoAccountCloneableWallet + await hre.run('verify:verify', { + address: BloctoAccountCloneableWalletAddr, + contract: 'contracts/BloctoAccountCloneableWallet.sol:BloctoAccountCloneableWallet', + constructorArguments: [ + EntryPoint + ] + }) + + // verify BloctoAccountFactory (if proxy) + // const accountFactoryImplAddress = await getImplementationAddress(ethers.provider, BloctoAccountFactoryAddr) + // await hre.run('verify:verify', { + // address: accountFactoryImplAddress, + // contract: 'contracts/BloctoAccountFactory.sol:BloctoAccountFactory' + // }) + + // verify BloctoAccountFactory (if not proxy) + await hre.run('verify:verify', { + address: '0x7db696a9130b0e2aea92b39bfe520861baa5fb83', + contract: 'contracts/BloctoAccountFactory.sol:BloctoAccountFactory' + }) + + // verify BloctoAccountProxy + // await hre.run('verify:verify', { + // address: BloctoAccountProxyCloneAddr, + // contract: 'contracts/BloctoAccountProxy.sol:BloctoAccountProxy', + // constructorArguments: [ + // BloctoAccountCloneableWalletAddr + // ] + // }) +} + +// We recommend this pattern to be able to use async/await everywhere +// and properly handle errors. +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/deploy/0_verify_proxy.ts b/deploy/0_verify_proxy.ts new file mode 100644 index 0000000..8704954 --- /dev/null +++ b/deploy/0_verify_proxy.ts @@ -0,0 +1,21 @@ +// update from https://github.com/borislav-itskov/schnorrkel.js +import hre from 'hardhat' + +const BloctoAccountCloableWallet = '0x490B5ED8A17224a553c34fAA642161c8472118dd' + +async function main (): Promise { + // ---------------Verify BloctoAccountProxy Contract---------------- // + await hre.run('verify:verify', { + address: '0xd448D0835731f5dDE3942993B2bE80DFC232Cc0f', + contract: 'contracts/BloctoAccountProxy.sol:BloctoAccountProxy', + constructorArguments: [ + BloctoAccountCloableWallet + ] + }) +} +// We recommend this pattern to be able to use async/await everywhere +// and properly handle errors. +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/deploy/1_deploy_VerifyingPaymaster.ts b/deploy/1_deploy_VerifyingPaymaster.ts new file mode 100644 index 0000000..fb9eacf --- /dev/null +++ b/deploy/1_deploy_VerifyingPaymaster.ts @@ -0,0 +1,68 @@ +import hre, { ethers } from 'hardhat' + +// import { SignerWithAddress } from 'hardhat-deploy-ethers/signers' + +const ContractName = 'VerifyingPaymaster' +// version 0.6.0 from https://mirror.xyz/erc4337official.eth/cSdZl9X-Hce71l_FzjVKQ5eN398ial7QmkDExmIIOQk +const EntryPointAddress = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789' +const VerifyingSigner = '0x31E2FD21F2ad34bBf56B08baD57438869aED12eF' +const AligementNonce = 15 +const GasLimit = 6000000 + +async function alignNonce (signer: any, targetNonce: number): Promise { + let nowNonce = await signer.getTransactionCount() + + while (nowNonce < targetNonce) { + console.log('nonce not aligned, now: ', nowNonce, ', target: ', targetNonce, '-> send a tx to align nonce') + await signer.sendTransaction({ + to: '0x914171a48aa2c306DD2D68c6810D6E2B4F4ACdc7', + value: 0// 0 ether + }) + + console.log('sleep 10 seconds for chain sync...') + await new Promise(f => setTimeout(f, 20000)) + nowNonce = await signer.getTransactionCount() + } +} + +async function main (): Promise { + const [owner] = await ethers.getSigners() + const nowNonce = await owner.getTransactionCount() + console.log('deploy with account: ', owner.address, ', nonce: ', nowNonce) + + if (nowNonce == AligementNonce) { + console.log('nonce aligned') + } else if (nowNonce < AligementNonce) { + await alignNonce(owner, AligementNonce) + } else { + throw new Error('nonce is larger than target nonce') + } + console.log(`deploying ${ContractName}...`) + const factory = await ethers.getContractFactory(ContractName) + const contract = await factory.deploy(EntryPointAddress, VerifyingSigner, { + gasLimit: GasLimit // set the gas limit to 6 million + }) + + await contract.deployed() + + console.log(`${ContractName} deployed to: ${contract.address}`) + + // sleep 10 seconds + console.log('sleep 10 seconds for chain sync...') + await new Promise(f => setTimeout(f, 10000)) + // ---------------Verify BloctoAccountProxy Contract---------------- // + await hre.run('verify:verify', { + address: contract.address, + contract: 'contracts/Paymaster/VerifyingPaymaster.sol:VerifyingPaymaster', + constructorArguments: [ + EntryPointAddress, VerifyingSigner + ] + }) +} + +// We recommend this pattern to be able to use async/await everywhere +// and properly handle errors. +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/deploy/2_2_createMultipleSchnorrAccount.ts b/deploy/2_2_createMultipleSchnorrAccount.ts new file mode 100644 index 0000000..f12efcd --- /dev/null +++ b/deploy/2_2_createMultipleSchnorrAccount.ts @@ -0,0 +1,56 @@ +// update from https://github.com/borislav-itskov/schnorrkel.js +import { ethers } from 'hardhat' +import { + createAuthorizedCosignerRecoverWallet, + getMergedKey +} from '../test/testutils' + +const FactoryAddress = '0x285cc5232236D227FCb23E6640f87934C948a028' + +const RecoverAddress = '0x0c558b2735286533b834bd1172bcA43DBD2970f7' + +const ethersSigner = ethers.provider.getSigner() + +const SALT = 45215234123 + +async function main (): Promise { + // const lockedAmount = ethers.utils.parseEther("1"); + const AccountFactory = await ethers.getContractFactory('BloctoAccountFactory') + const factory = await AccountFactory.attach(FactoryAddress) + + const [authorizedWallet, cosignerWallet] = createAuthorizedCosignerRecoverWallet() + const [authorizedWallet2, cosignerWallet2] = createAuthorizedCosignerRecoverWallet() + // const signerOne = new DefaultSigner(authorizedWallet) + // const signerTwo = new DefaultSigner(cosignerWallet) + // const publicKeys = [signerOne.getPublicKey(), signerTwo.getPublicKey()] + // const publicNonces = [signerOne.getPublicNonces(), signerTwo.getPublicNonces()] + // const combinedPublicKey = Schnorrkel.getCombinedPublicKey(publicKeys) + // const px = ethers.utils.hexlify(combinedPublicKey.buffer.slice(1, 33)) + // because of the parity byte is 2, 3 so sub 2 + // const pxIndexWithParity = combinedPublicKey.buffer.slice(0, 1).readInt8() - 2 + mergedKeyIndex + + const [px, pxIndexWithParity] = getMergedKey(authorizedWallet, cosignerWallet, 0) + const [px2, pxIndexWithParity2] = getMergedKey(authorizedWallet2, cosignerWallet2, 1) + const [px3, pxIndexWithParity3] = getMergedKey(authorizedWallet2, cosignerWallet, 2) + + console.log('authorizedWallet.getAddress(): ', await authorizedWallet.getAddress(), ', cosignerWallet.getAddress()', await cosignerWallet.getAddress()) + + console.log('ethersSigner address: ', await ethersSigner.getAddress()) + console.log('factory.address', factory.address) + + const tx = await factory.createAccount2([authorizedWallet.address, authorizedWallet2.address, cosignerWallet.address], + cosignerWallet.address, RecoverAddress, + SALT, // random salt + [pxIndexWithParity, pxIndexWithParity2, pxIndexWithParity3], + [px, px2, px3]) + + console.log('after createAccount2') + const receipt = await tx.wait() + console.log(receipt.gasUsed) +} +// We recommend this pattern to be able to use async/await everywhere +// and properly handle errors. +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/deploy/2_createSchnorrAccount_verify.ts b/deploy/2_createSchnorrAccount_verify.ts new file mode 100644 index 0000000..1e3e4f4 --- /dev/null +++ b/deploy/2_createSchnorrAccount_verify.ts @@ -0,0 +1,96 @@ +// update from https://github.com/borislav-itskov/schnorrkel.js +import hre, { ethers } from 'hardhat' +import { BigNumber } from 'ethers' +import { expect } from 'chai' +import { + createAccount, + createAuthorizedCosignerRecoverWallet, + hashMessageEIP191V0 +} from '../test/testutils' + +import Schnorrkel from '../src/schnorrkel.js/index' +import { DefaultSigner } from '../test/schnorrUtils' + +const ERC1271_MAGICVALUE_BYTES32 = '0x1626ba7e' + +const BloctoAccountCloableWallet = '0x490B5ED8A17224a553c34fAA642161c8472118dd' +const FactoryAddress = '0x285cc5232236D227FCb23E6640f87934C948a028' + +const RecoverAddress = '0x0c558b2735286533b834bd1172bcA43DBD2970f7' + +const ethersSigner = ethers.provider.getSigner() + +// multisig +const msg = 'just a test message' + +const SALT = 515233151 + +async function main (): Promise { + // ---------------Create Account---------------- // + const AccountFactory = await ethers.getContractFactory('BloctoAccountFactory') + const factory = await AccountFactory.attach(FactoryAddress) + + const mergedKeyIndex = 128 + (0 << 1) + const [authorizedWallet, cosignerWallet] = createAuthorizedCosignerRecoverWallet() + const signerOne = new DefaultSigner(authorizedWallet) + const signerTwo = new DefaultSigner(cosignerWallet) + const publicKeys = [signerOne.getPublicKey(), signerTwo.getPublicKey()] + const publicNonces = [signerOne.getPublicNonces(), signerTwo.getPublicNonces()] + const combinedPublicKey = Schnorrkel.getCombinedPublicKey(publicKeys) + const px = ethers.utils.hexlify(combinedPublicKey.buffer.slice(1, 33)) + // because of the parity byte is 2, 3 so sub 2 + const pxIndexWithParity = combinedPublicKey.buffer.slice(0, 1).readInt8() - 2 + mergedKeyIndex + + console.log('authorizedWallet.getAddress(): ', await authorizedWallet.getAddress(), ', cosignerWallet.getAddress()', await cosignerWallet.getAddress()) + + console.log('ethersSigner address: ', await ethersSigner.getAddress()) + console.log('factory.address', factory.address) + console.log('creating account...') + const account = await createAccount( + ethersSigner, + await authorizedWallet.getAddress(), + await cosignerWallet.getAddress(), + RecoverAddress, + BigNumber.from(SALT), + pxIndexWithParity, + px, + factory + ) + + console.log('account create success! SCW Address: ', account.address) + + // ---------------Verify BloctoAccountProxy Contract---------------- // + await hre.run('verify:verify', { + address: account.address, + contract: 'contracts/BloctoAccountProxy.sol:BloctoAccountProxy', + constructorArguments: [ + BloctoAccountCloableWallet + ] + }) + + // ---------------Verify Signature---------------- // + const msgKeccak256 = ethers.utils.solidityKeccak256(['string'], [msg]) + const msgEIP191V0 = hashMessageEIP191V0(account.address, msgKeccak256) + // note: following line multiSignMessage ignore hash message + const { signature: sigOne, challenge: e } = signerOne.multiSignMessage(msgEIP191V0, publicKeys, publicNonces) + const { signature: sigTwo } = signerTwo.multiSignMessage(msgEIP191V0, publicKeys, publicNonces) + const sSummed = Schnorrkel.sumSigs([sigOne, sigTwo]) + + // wrap the result + // e (bytes32), s (bytes32), pxIndexWithParity (uint8) + // pxIndexWithParity (7 bit for pxIndex, 1 bit for parity) + const hexPxIndexWithParity = ethers.utils.hexlify(pxIndexWithParity).slice(-2) + const abiCoder = new ethers.utils.AbiCoder() + const sigData = abiCoder.encode(['bytes32', 'bytes32'], [ + e.buffer, + sSummed.buffer + ]) + hexPxIndexWithParity + const result = await account.isValidSignature(msgKeccak256, sigData) + expect(result).to.equal(ERC1271_MAGICVALUE_BYTES32) +} +// We recommend this pattern to be able to use async/await everywhere +// and properly handle errors. +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/hardhat.config.ts b/hardhat.config.ts index 259b823..22a796a 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -3,65 +3,66 @@ import '@typechain/hardhat' import { HardhatUserConfig } from 'hardhat/config' import '@nomiclabs/hardhat-etherscan' import '@openzeppelin/hardhat-upgrades' - +import 'hardhat-storage-layout' import 'solidity-coverage' -import * as fs from 'fs' - const { ETHERSCAN_API_KEY, // etherscan API KEY POLYGONSCAN_API_KEY, // polygonscan API KEY BSCSCAN_API_KEY, // bscscan API KEY SNOWTRACE_API_KEY, // avalanche scan (snowtrace) API KEY - OPSCAN_API_KEY, // optimistic scan API KEY - ARBSCAN_API_KEY // arbitrum scan API KEY + ARBSCAN_API_KEY, // arbitrum scan API KEY + OP_API_KEY } = process.env -const mnemonicFileName = process.env.MNEMONIC_FILE ?? `${process.env.HOME}/.secret/testnet-mnemonic.txt` -let mnemonic = 'test '.repeat(11) + 'junk' -if (fs.existsSync(mnemonicFileName)) { mnemonic = fs.readFileSync(mnemonicFileName, 'ascii') } - -function getNetwork1 (url: string): { url: string, accounts: { mnemonic: string } } { - return { - url, - accounts: { mnemonic } - } -} - -function getNetwork (name: string): { url: string, accounts: { mnemonic: string } } { - return getNetwork1(`https://${name}.infura.io/v3/${process.env.INFURA_ID}`) - // return getNetwork1(`wss://${name}.infura.io/ws/v3/${process.env.INFURA_ID}`) +function getDeployAccount (): string[] { + return (process.env.ETH_PRIVATE_KEY !== undefined) ? [process.env.ETH_PRIVATE_KEY] : [] } // You need to export an object to set up your config // Go to https://hardhat.org/config/ to learn more - const config: HardhatUserConfig = { solidity: { compilers: [{ - version: '0.8.15', + version: '0.8.17', settings: { optimizer: { enabled: true, runs: 1000000 } } }] }, networks: { - dev: { url: 'http://localhost:8545' }, - // github action starts localgeth service, for gas calculations - localgeth: { url: 'http://localgeth:8545' }, - goerli: getNetwork('goerli'), - sepolia: getNetwork('sepolia'), - proxy: getNetwork1('http://localhost:8545'), - // mumbai: getNetwork1('https://polygon-testnet.public.blastapi.io'), + hardhat: { + allowUnlimitedContractSize: true + }, + goerli: { + url: 'https://ethereum-goerli.publicnode.com', + accounts: getDeployAccount(), + chainId: 5 + }, + optimism_testnet: { + url: 'https://goerli.optimism.io', + accounts: getDeployAccount(), + chainId: 420 + }, + arbitrum_testnet: { + url: 'https://arbitrum-goerli.publicnode.com', + accounts: getDeployAccount(), + chainId: 421613 + }, mumbai: { - url: 'https://polygon-testnet.public.blastapi.io', - accounts: - process.env.ETH_PRIVATE_KEY !== undefined - ? [process.env.ETH_PRIVATE_KEY] - : [], - chainId: 80001, - gas: 8000000, // 8M - gasPrice: 10000000000 // 10 gwei + url: 'https://polygon-mumbai.gateway.tenderly.co', + accounts: getDeployAccount(), + chainId: 80001 + }, + bsc_testnet: { + url: 'https://data-seed-prebsc-2-s1.binance.org:8545', + accounts: getDeployAccount(), + chainId: 97 + }, + avalanche_testnet: { + url: 'https://api.avax-test.network/ext/bc/C/rpc', + accounts: getDeployAccount(), + chainId: 43113 } }, mocha: { @@ -76,10 +77,12 @@ const config: HardhatUserConfig = { bsc: BSCSCAN_API_KEY, bscTestnet: BSCSCAN_API_KEY, avalanche: SNOWTRACE_API_KEY, + avalancheFujiTestnet: SNOWTRACE_API_KEY, goerli: ETHERSCAN_API_KEY, arbitrumOne: ARBSCAN_API_KEY, arbitrumGoerli: ARBSCAN_API_KEY, - optimism: OPSCAN_API_KEY + optimisticEthereum: OP_API_KEY, + optimisticGoerli: OP_API_KEY } } diff --git a/package.json b/package.json index a344a7a..1513a17 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bloctoaccount", "version": "0.6.0", - "description": "BloctoAccount & BloctoAccountFactory smart contract", + "description": "BloctoAccount & BloctoAccountFactory smart contracts", "scripts": { "clean": "rm -rf cache artifacts typechain typechain-types", "tsc": "tsc", @@ -9,16 +9,24 @@ "lint:js": "eslint -f unix .", "lint-fix": "eslint -f unix . --fix", "lint:sol": "solhint -f unix \"contracts/**/*.sol\" --max-warnings 0", - "test": "hardhat test ", - "deploy-accountfactory": "hardhat run deploy/deploy-BloctoAccountFactory.ts", - "verify-accountfactory": "npx hardhat verify 0xf8915e6896fb575a95246793b150c56e2666da11 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789 ", - "verify-verifyingpaymaster": "npx hardhat verify 0x214cC14b7312855c7F5FF156757B2eeeB938048D 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789 0xec53Efb202a4427bae100776BA920A5938E9d509" + "test": "hardhat test", + "deploy": "hardhat run deploy/0_deploy_Account_Factory-and-addStake.ts", + "deploy-bloctoaccountcloneable": "hardhat run deploy/1_deploy_BloctoAccount4337CloneableWallet.ts", + "deploy-bloctoaccountfactory": "hardhat run deploy/2_deploy_BloctoAccountFactory.ts", + "deploy-verifyingpaymaster": "hardhat run deploy/3_deploy_VerifyingPaymaster.ts", + "verify": "hardhat run deploy/4_verify.ts", + "verify-bloctoaccountcloneable": "npx hardhat verify --contract contracts/BloctoAccountCloneableWallet.sol:BloctoAccountCloneableWallet 0x66d4d44e4957F70dF0127b3de2dBaD9fF9058B5B 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789", + "verify-bloctoaccountproxy": "npx hardhat verify --contract contracts/BloctoAccountProxy.sol:BloctoAccountProxy 0xa38dB698ec2Ec25ac7b2919fD69F49a7A975a724 0xee8EED6701EfD1d4de6B3930859919C966c6B00C ", + "verify-bloctoaccountfactory": "npx hardhat verify 0xC261555D0e4623BF709d3f22Bf58A6275CE4d17D 0x66d4d44e4957F70dF0127b3de2dBaD9fF9058B5B 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789", + "verify-verifyingpaymaster": "npx hardhat verify 0xE671dEee9c758e642d50c32e13FD5fC6D42C98F1 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789 0x086443C6bA8165a684F3e316Da42D3A2F0a2330a", + "deploy_verify": "hardhat run deploy/0_deploy_Account_Factory-and-addStake.ts", + "deploy_verify_paymaster": "hardhat run deploy/1_deploy_VerifyingPaymaster.ts" }, "devDependencies": { "@account-abstraction/contracts": "^0.6.0", "@nomiclabs/hardhat-ethers": "^2.0.2", "@nomiclabs/hardhat-waffle": "^2.0.1", - "@openzeppelin/hardhat-upgrades": "^1.23.0", + "@openzeppelin/hardhat-upgrades": "^1.28.0", "@typechain/ethers-v5": "^10.1.0", "@types/chai": "^4.2.21", "@types/node": "^16.4.12", @@ -35,6 +43,7 @@ "ethereum-waffle": "^3.4.0", "ethers": "^5.4.2", "hardhat": "^2.6.6", + "hardhat-storage-layout": "^0.1.7", "solhint": "^3.3.7", "ts-generator": "^0.1.1", "ts-mocha": "^10.0.0", @@ -42,6 +51,7 @@ "typechain": "^8.1.0" }, "dependencies": { + "@borislav.itskov/schnorrkel.js": "https://github.com/borislav-itskov/schnorrkel.js", "@gnosis.pm/safe-contracts": "^1.3.0", "@nomiclabs/hardhat-etherscan": "^3.1.7", "@openzeppelin/contracts": "^4.2.0", @@ -58,4 +68,4 @@ "table": "^6.8.0", "typescript": "^4.3.5" } -} +} \ No newline at end of file diff --git a/src/AASigner.ts b/src/AASigner.ts new file mode 100644 index 0000000..8cee2fc --- /dev/null +++ b/src/AASigner.ts @@ -0,0 +1,411 @@ +import { BigNumber, Bytes, ethers, Event, Signer } from 'ethers' +import { zeroAddress } from 'ethereumjs-util' +import { BaseProvider, Provider, TransactionRequest } from '@ethersproject/providers' +import { Deferrable, resolveProperties } from '@ethersproject/properties' +import { + EntryPoint, + EntryPoint__factory, + ERC1967Proxy__factory, + SimpleAccount, + SimpleAccount__factory +} from '../typechain' +import { BytesLike, hexValue } from '@ethersproject/bytes' +import { TransactionResponse } from '@ethersproject/abstract-provider' +import { fillAndSign, getUserOpHash } from '../test/UserOp' +import { UserOperation } from '../test/UserOperation' +import { TransactionReceipt } from '@ethersproject/abstract-provider/src.ts/index' +import { clearInterval } from 'timers' +import { Create2Factory } from './Create2Factory' +import { getCreate2Address, hexConcat, Interface, keccak256 } from 'ethers/lib/utils' +import { HashZero } from '../test/testutils' + +export type SendUserOp = (userOp: UserOperation) => Promise + +export const debug = process.env.DEBUG != null + +/** + * send a request using rpc. + * + * @param provider - rpc provider that supports "eth_sendUserOperation" + */ +export function rpcUserOpSender (provider: ethers.providers.JsonRpcProvider, entryPointAddress: string): SendUserOp { + let chainId: number + + return async function (userOp) { + if (debug) { + console.log('sending eth_sendUserOperation', { + ...userOp, + initCode: (userOp.initCode ?? '').length, + callData: (userOp.callData ?? '').length + }, entryPointAddress) + } + if (chainId === undefined) { + chainId = await provider.getNetwork().then(net => net.chainId) + } + + const cleanUserOp = Object.keys(userOp).map(key => { + let val = (userOp as any)[key] + if (typeof val !== 'string' || !val.startsWith('0x')) { + val = hexValue(val) + } + return [key, val] + }) + .reduce((set, [k, v]) => ({ ...set, [k]: v }), {}) + await provider.send('eth_sendUserOperation', [cleanUserOp, entryPointAddress]).catch(e => { + throw e.error ?? e + }) + return undefined + } +} + +interface QueueSendUserOp extends SendUserOp { + lastQueueUpdate: number + queueSize: number + queue: { [sender: string]: UserOperation[] } + push: () => Promise + setInterval: (intervalMs: number) => void + cancelInterval: () => void + + _cancelInterval: any +} + +/** + * a SendUserOp that queue requests. need to call sendQueuedUserOps to create a bundle and send them. + * the returned object handles the queue of userops and also interval control. + */ +export function queueUserOpSender (entryPointAddress: string, signer: Signer, intervalMs = 3000): QueueSendUserOp { + const entryPoint = EntryPoint__factory.connect(entryPointAddress, signer) + + const ret = async function (userOp: UserOperation) { + if (ret.queue[userOp.sender] == null) { + ret.queue[userOp.sender] = [] + } + ret.queue[userOp.sender].push(userOp) + ret.lastQueueUpdate = Date.now() + ret.queueSize++ + } as QueueSendUserOp + + ret.queue = {} + ret.push = async function () { + await sendQueuedUserOps(ret, entryPoint) + } + ret.setInterval = function (intervalMs: number) { + ret.cancelInterval() + // eslint-disable-next-line @typescript-eslint/no-misused-promises + ret._cancelInterval = setInterval(ret.push, intervalMs) + } + ret.cancelInterval = function () { + if (ret._cancelInterval != null) { + clearInterval(ret._cancelInterval) + ret._cancelInterval = null + } + } + + if (intervalMs != null) { + ret.setInterval(intervalMs) + } + + return ret +} + +/** + * create a bundle from the queue and send it to the entrypoint. + * NOTE: only a single request from a given sender can be put into a bundle. + * @param queue + * @param entryPoint + */ + +let sending = false + +// after that much time with no new TX, send whatever you can. +const IDLE_TIME = 5000 + +// when reaching this theshold, don't wait anymore and send a bundle +const BUNDLE_SIZE_IMMEDIATE = 3 + +async function sendQueuedUserOps (queueSender: QueueSendUserOp, entryPoint: EntryPoint): Promise { + if (sending) { + console.log('sending in progress. waiting') + return + } + sending = true + try { + if (queueSender.queueSize < BUNDLE_SIZE_IMMEDIATE || queueSender.lastQueueUpdate + IDLE_TIME > Date.now()) { + console.log('queue too small/too young. waiting') + return + } + const ops: UserOperation[] = [] + const queue = queueSender.queue + Object.keys(queue).forEach(sender => { + const op = queue[sender].shift() + if (op != null) { + ops.push(op) + queueSender.queueSize-- + } + }) + if (ops.length === 0) { + console.log('no ops to send') + return + } + const signer = await (entryPoint.provider as any).getSigner().getAddress() + console.log('==== sending batch of ', ops.length) + const ret = await entryPoint.handleOps(ops, signer, { maxPriorityFeePerGas: 2e9 }) + console.log('handleop tx=', ret.hash) + const rcpt = await ret.wait() + console.log('events=', rcpt.events!.map(e => ({ name: e.event, args: e.args }))) + } finally { + sending = false + } +} + +/** + * send UserOp using handleOps, but locally. + * for testing: instead of connecting through RPC to a remote host, directly send the transaction + * @param entryPointAddress the entryPoint address to use. + * @param signer ethers provider to send the request (must have eth balance to send) + * @param beneficiary the account to receive the payment (from account/paymaster). defaults to the signer's address + */ +export function localUserOpSender (entryPointAddress: string, signer: Signer, beneficiary?: string): SendUserOp { + const entryPoint = EntryPoint__factory.connect(entryPointAddress, signer) + return async function (userOp) { + if (debug) { + console.log('sending', { + ...userOp, + initCode: userOp.initCode.length <= 2 ? userOp.initCode : `` + }) + } + const gasLimit = BigNumber.from(userOp.preVerificationGas).add(userOp.verificationGasLimit).add(userOp.callGasLimit) + console.log('calc gaslimit=', gasLimit.toString()) + const ret = await entryPoint.handleOps([userOp], beneficiary ?? await signer.getAddress(), { + maxPriorityFeePerGas: userOp.maxPriorityFeePerGas, + maxFeePerGas: userOp.maxFeePerGas + }) + await ret.wait() + return undefined + } +} + +export class AAProvider extends BaseProvider { + private readonly entryPoint: EntryPoint + + constructor (entryPointAddress: string, provider: Provider) { + super(provider.getNetwork()) + this.entryPoint = EntryPoint__factory.connect(entryPointAddress, provider) + } +} + +/** + * a signer that wraps account-abstraction. + */ +export class AASigner extends Signer { + _account?: SimpleAccount + + private _isPhantom = true + public entryPoint: EntryPoint + + private _chainId: Promise | undefined + + /** + * create account abstraction signer + * @param signer - the underlying signer. has no funds (=can't send TXs) + * @param entryPoint the entryPoint contract. used for read-only operations + * @param sendUserOp function to actually send the UserOp to the entryPoint. + * @param index - index of this account for this signer. + */ + constructor (readonly signer: Signer, readonly entryPointAddress: string, readonly sendUserOp: SendUserOp, readonly index = 0, readonly provider = signer.provider) { + super() + this.entryPoint = EntryPoint__factory.connect(entryPointAddress, signer) + } + + // connect to a specific pre-deployed address + // (note: in order to send transactions, the underlying signer address must be valid signer for this account (its owner) + async connectAccountAddress (address: string): Promise { + if (this._account != null) { + throw Error('already connected to account') + } + if (await this.provider!.getCode(address).then(code => code.length) <= 2) { + throw new Error('cannot connect to non-existing contract') + } + this._account = SimpleAccount__factory.connect(address, this.signer) + this._isPhantom = false + } + + connect (provider: Provider): Signer { + throw new Error('connect not implemented') + } + + async _deploymentAddress (): Promise { + return getCreate2Address(Create2Factory.contractAddress, HashZero, keccak256(await this._deploymentTransaction())) + } + + // TODO TODO: THERE IS UTILS.getAccountInitCode - why not use that? + async _deploymentTransaction (): Promise { + const implementationAddress = zeroAddress() // TODO: pass implementation in here + const ownerAddress = await this.signer.getAddress() + const initializeCall = new Interface(SimpleAccount__factory.abi).encodeFunctionData('initialize', [ownerAddress]) + return new ERC1967Proxy__factory(this.signer).getDeployTransaction(implementationAddress, initializeCall).data! + } + + async getAddress (): Promise { + await this.syncAccount() + return this._account!.address + } + + async signMessage (message: Bytes | string): Promise { + throw new Error('signMessage: unsupported by AA') + } + + async signTransaction (transaction: Deferrable): Promise { + throw new Error('signMessage: unsupported by AA') + } + + async getAccount (): Promise { + await this.syncAccount() + return this._account! + } + + // fabricate a response in a format usable by ethers users... + async userEventResponse (userOp: UserOperation): Promise { + const entryPoint = this.entryPoint + const userOpHash = getUserOpHash(userOp, entryPoint.address, await this._chainId!) + const provider = entryPoint.provider + const currentBLock = provider.getBlockNumber() + + let resolved = false + const waitPromise = new Promise((resolve, reject) => { + let listener = async function (this: any, ...param: any): Promise { + if (resolved) return + const event = arguments[arguments.length - 1] as Event + if (event.blockNumber <= await currentBLock) { + // not sure why this callback is called first for previously-mined block.. + console.log('ignore previous block', event.blockNumber) + return + } + if (event.args == null) { + console.error('got event without args', event) + return + } + if (event.args.userOpHash !== userOpHash) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions,@typescript-eslint/no-base-to-string + console.log(`== event with wrong userOpHash: sender/nonce: event.${event.args.sender}@${event.args.nonce.toString()}!= userOp.${userOp.sender}@${parseInt(userOp.nonce.toString())}`) + return + } + + const rcpt = await event.getTransactionReceipt() + console.log('got event with status=', event.args.success, 'gasUsed=', rcpt.gasUsed) + + // TODO: should use "userOpHash" as "transactionId" (but this has to be done in a provider, not a signer) + + // before returning the receipt, update the status from the event. + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (!event.args.success) { + console.log('mark tx as failed') + rcpt.status = 0 + const revertReasonEvents = await entryPoint.queryFilter(entryPoint.filters.UserOperationRevertReason(userOp.sender), rcpt.blockHash) + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (revertReasonEvents[0]) { + console.log('rejecting with reason') + reject(new Error(`UserOp failed with reason: ${revertReasonEvents[0].args.revertReason}`) + ) + return + } + } + // eslint-disable-next-line @typescript-eslint/no-misused-promises + entryPoint.off('UserOperationEvent', listener) + resolve(rcpt) + resolved = true + } + listener = listener.bind(listener) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + entryPoint.on('UserOperationEvent', listener) + // for some reason, 'on' takes at least 2 seconds to be triggered on local network. so add a one-shot timer: + // eslint-disable-next-line @typescript-eslint/no-misused-promises + setTimeout(async () => await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(userOpHash)).then(query => { + if (query.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + listener(query[0]) + } + }), 500) + }) + const resp: TransactionResponse = { + hash: userOpHash, + confirmations: 0, + from: userOp.sender, + nonce: BigNumber.from(userOp.nonce).toNumber(), + gasLimit: BigNumber.from(userOp.callGasLimit), // ?? + value: BigNumber.from(0), + data: hexValue(userOp.callData), // should extract the actual called method from this "execFromSingleton()" call + chainId: await this._chainId!, + wait: async function (confirmations?: number): Promise { + return await waitPromise + } + } + return resp + } + + async sendTransaction (transaction: Deferrable): Promise { + const userOp = await this._createUserOperation(transaction) + // get response BEFORE sending request: the response waits for events, which might be triggered before the actual send returns. + const reponse = await this.userEventResponse(userOp) + await this.sendUserOp(userOp) + return reponse + } + + async syncAccount (): Promise { + if (this._account == null) { + const address = await this._deploymentAddress() + this._account = SimpleAccount__factory.connect(address, this.signer) + } + + this._chainId = this.provider?.getNetwork().then(net => net.chainId) + // once an account is deployed, it can no longer be a phantom. + // but until then, we need to re-check + if (this._isPhantom) { + const size = await this.signer.provider?.getCode(this._account.address).then(x => x.length) + // console.log(`== __isPhantom. addr=${this._account.address} re-checking code size. result = `, size) + this._isPhantom = size === 2 + // !await this.entryPoint.isContractDeployed(await this.getAddress()); + } + } + + // return true if account not yet created. + async isPhantom (): Promise { + await this.syncAccount() + return this._isPhantom + } + + async _createUserOperation (transaction: Deferrable): Promise { + const tx: TransactionRequest = await resolveProperties(transaction) + await this.syncAccount() + + let initCode: BytesLike | undefined + if (this._isPhantom) { + const initCallData = new Create2Factory(this.provider!).getDeployTransactionCallData(hexValue(await this._deploymentTransaction()), HashZero) + + initCode = hexConcat([ + Create2Factory.contractAddress, + initCallData + ]) + } + const execFromEntryPoint = await this._account!.populateTransaction.execute(tx.to!, tx.value ?? 0, tx.data!) + + let { gasPrice, maxPriorityFeePerGas, maxFeePerGas } = tx + // gasPrice is legacy, and overrides eip1559 values: + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (gasPrice) { + maxPriorityFeePerGas = gasPrice + maxFeePerGas = gasPrice + } + const userOp = await fillAndSign({ + sender: this._account!.address, + initCode, + nonce: initCode == null ? tx.nonce : this.index, + callData: execFromEntryPoint.data!, + callGasLimit: tx.gasLimit, + maxPriorityFeePerGas, + maxFeePerGas + }, this.signer, this.entryPoint) + + return userOp + } +} diff --git a/src/Create2Factory.ts b/src/Create2Factory.ts new file mode 100644 index 0000000..b0e78cf --- /dev/null +++ b/src/Create2Factory.ts @@ -0,0 +1,120 @@ +// from: https://github.com/Arachnid/deterministic-deployment-proxy +import { BigNumber, BigNumberish, ethers, Signer } from 'ethers' +import { arrayify, hexConcat, hexlify, hexZeroPad, keccak256 } from 'ethers/lib/utils' +import { Provider } from '@ethersproject/providers' +import { TransactionRequest } from '@ethersproject/abstract-provider' + +export class Create2Factory { + factoryDeployed = false + + // from: https://github.com/Arachnid/deterministic-deployment-proxy + static readonly contractAddress = '0x4e59b44847b379578588920ca78fbf26c0b4956c' + static readonly factoryTx = '0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222' + static readonly factoryDeployer = '0x3fab184622dc19b6109349b94811493bf2a45362' + static readonly deploymentGasPrice = 100e9 + static readonly deploymentGasLimit = 100000 + static readonly factoryDeploymentFee = (Create2Factory.deploymentGasPrice * Create2Factory.deploymentGasLimit).toString() + + constructor (readonly provider: Provider, + readonly signer = (provider as ethers.providers.JsonRpcProvider).getSigner()) { + } + + /** + * deploy a contract using our deterministic deployer. + * The deployer is deployed (unless it is already deployed) + * NOTE: this transaction will fail if already deployed. use getDeployedAddress to check it first. + * @param initCode delpoyment code. can be a hex string or factory.getDeploymentTransaction(..) + * @param salt specific salt for deployment + * @param gasLimit gas limit or 'estimate' to use estimateGas. by default, calculate gas based on data size. + */ + async deploy (initCode: string | TransactionRequest, salt: BigNumberish = 0, gasLimit?: BigNumberish | 'estimate'): Promise { + await this.deployFactory() + if (typeof initCode !== 'string') { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + initCode = (initCode as TransactionRequest).data!.toString() + } + + const addr = Create2Factory.getDeployedAddress(initCode, salt) + if (await this.provider.getCode(addr).then(code => code.length) > 2) { + return addr + } + + const deployTx = { + to: Create2Factory.contractAddress, + data: this.getDeployTransactionCallData(initCode, salt) + } + if (gasLimit === 'estimate') { + gasLimit = await this.signer.estimateGas(deployTx) + } + + // manual estimation (its bit larger: we don't know actual deployed code size) + if (gasLimit === undefined) { + gasLimit = arrayify(initCode) + .map(x => x === 0 ? 4 : 16) + .reduce((sum, x) => sum + x) + + 200 * initCode.length / 2 + // actual is usually somewhat smaller (only deposited code, not entire constructor) + 6 * Math.ceil(initCode.length / 64) + // hash price. very minor compared to deposit costs + 32000 + + 21000 + + // deployer requires some extra gas + gasLimit = Math.floor(gasLimit * 64 / 63) + } + + const ret = await this.signer.sendTransaction({ ...deployTx, gasLimit }) + await ret.wait() + if (await this.provider.getCode(addr).then(code => code.length) === 2) { + throw new Error('failed to deploy') + } + return addr + } + + getDeployTransactionCallData (initCode: string, salt: BigNumberish = 0): string { + const saltBytes32 = hexZeroPad(hexlify(salt), 32) + return hexConcat([ + saltBytes32, + initCode + ]) + } + + /** + * return the deployed address of this code. + * (the deployed address to be used by deploy() + * @param initCode + * @param salt + */ + static getDeployedAddress (initCode: string, salt: BigNumberish): string { + const saltBytes32 = hexZeroPad(hexlify(salt), 32) + return '0x' + keccak256(hexConcat([ + '0xff', + Create2Factory.contractAddress, + saltBytes32, + keccak256(initCode) + ])).slice(-40) + } + + // deploy the factory, if not already deployed. + async deployFactory (signer?: Signer): Promise { + if (await this._isFactoryDeployed()) { + return + } + await (signer ?? this.signer).sendTransaction({ + to: Create2Factory.factoryDeployer, + value: BigNumber.from(Create2Factory.factoryDeploymentFee) + }) + await this.provider.sendTransaction(Create2Factory.factoryTx) + if (!await this._isFactoryDeployed()) { + throw new Error('fatal: failed to deploy deterministic deployer') + } + } + + async _isFactoryDeployed (): Promise { + if (!this.factoryDeployed) { + const deployed = await this.provider.getCode(Create2Factory.contractAddress) + if (deployed.length > 2) { + this.factoryDeployed = true + } + } + return this.factoryDeployed + } +} diff --git a/src/runop.ts b/src/runop.ts new file mode 100644 index 0000000..d9fd0b2 --- /dev/null +++ b/src/runop.ts @@ -0,0 +1,111 @@ +// run a single op +// "yarn run runop [--network ...]" + +import hre, { ethers } from 'hardhat' +import { objdump } from '../test/testutils' +import { AASigner, localUserOpSender, rpcUserOpSender } from './AASigner' +import { TestCounter__factory, EntryPoint__factory } from '../typechain' +import '../test/aa.init' +import { parseEther } from 'ethers/lib/utils' +import { providers } from 'ethers' +import { TransactionReceipt } from '@ethersproject/abstract-provider/src.ts/index'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + console.log('net=', hre.network.name) + const aa_url = process.env.AA_URL + + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (aa_url == null && !process.env.FORCE_DEPLOY) { + await hre.run('deploy') + const chainId = await hre.getChainId() + if (chainId.match(/1337/) == null) { + console.log('chainid=', chainId) + await hre.run('etherscan-verify') + } + } + const [entryPointAddress, testCounterAddress] = await Promise.all([ + hre.deployments.get('EntryPoint').then(d => d.address), + hre.deployments.get('TestCounter').then(d => d.address) + ]) + + console.log('entryPointAddress:', entryPointAddress, 'testCounterAddress:', testCounterAddress) + const provider = ethers.provider + const ethersSigner = provider.getSigner(0) + const prefundAccountAddress = await ethersSigner.getAddress() + const prefundAccountBalance = await provider.getBalance(prefundAccountAddress) + console.log('using prefund account address', prefundAccountAddress, 'with balance', prefundAccountBalance.toString()) + + let sendUserOp + + if (aa_url != null) { + const newprovider = new providers.JsonRpcProvider(aa_url) + sendUserOp = rpcUserOpSender(newprovider, entryPointAddress) + const supportedEntryPoints: string[] = await newprovider.send('eth_supportedEntryPoints', []).then(ret => ret.map(ethers.utils.getAddress)) + console.log('node supported EntryPoints=', supportedEntryPoints) + if (!supportedEntryPoints.includes(entryPointAddress)) { + console.error('ERROR: node', aa_url, 'does not support our EntryPoint') + } + } else { sendUserOp = localUserOpSender(entryPointAddress, ethersSigner) } + + // index is unique for an account (so same owner can have multiple accounts, with different index + const index = parseInt(process.env.AA_INDEX ?? '0') + console.log('using account index (AA_INDEX)', index) + const aasigner = new AASigner(ethersSigner, entryPointAddress, sendUserOp, index) + // connect to pre-deployed account + // await aasigner.connectAccountAddress(accountAddress) + const myAddress = await aasigner.getAddress() + if (await provider.getBalance(myAddress) < parseEther('0.01')) { + console.log('prefund account') + await ethersSigner.sendTransaction({ to: myAddress, value: parseEther('0.01') }) + } + + // usually, an account will deposit for itself (that is, get created using eth, run "addDeposit" for itself + // and from there on will use deposit + // for testing, + const entryPoint = EntryPoint__factory.connect(entryPointAddress, ethersSigner) + console.log('account address=', myAddress) + let preDeposit = await entryPoint.balanceOf(myAddress) + console.log('current deposit=', preDeposit, 'current balance', await provider.getBalance(myAddress)) + + if (preDeposit.lte(parseEther('0.005'))) { + console.log('depositing for account') + await entryPoint.depositTo(myAddress, { value: parseEther('0.01') }) + preDeposit = await entryPoint.balanceOf(myAddress) + } + + const testCounter = TestCounter__factory.connect(testCounterAddress, aasigner) + + const prebalance = await provider.getBalance(myAddress) + console.log('balance=', prebalance.div(1e9).toString(), 'deposit=', preDeposit.div(1e9).toString()) + console.log('estimate direct call', { gasUsed: await testCounter.connect(ethersSigner).estimateGas.justemit().then(t => t.toNumber()) }) + const ret = await testCounter.justemit() + console.log('waiting for mine, hash (reqId)=', ret.hash) + const rcpt = await ret.wait() + const netname = await provider.getNetwork().then(net => net.name) + if (netname !== 'unknown') { + console.log('rcpt', rcpt.transactionHash, `https://dashboard.tenderly.co/tx/${netname}/${rcpt.transactionHash}/gas-usage`) + } + const gasPaid = prebalance.sub(await provider.getBalance(myAddress)) + const depositPaid = preDeposit.sub(await entryPoint.balanceOf(myAddress)) + console.log('paid (from balance)=', gasPaid.toNumber() / 1e9, 'paid (from deposit)', depositPaid.div(1e9).toString(), 'gasUsed=', rcpt.gasUsed) + const logs = await entryPoint.queryFilter('*' as any, rcpt.blockNumber) + console.log(logs.map((e: any) => ({ ev: e.event, ...objdump(e.args!) }))) + console.log('1st run gas used:', await evInfo(rcpt)) + + const ret1 = await testCounter.justemit() + const rcpt2 = await ret1.wait() + console.log('2nd run:', await evInfo(rcpt2)) + + async function evInfo (rcpt: TransactionReceipt): Promise { + // TODO: checking only latest block... + const block = rcpt.blockNumber + const ev = await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(), block) + // if (ev.length === 0) return {} + return ev.map(event => { + const { nonce, actualGasUsed } = event.args + const gasUsed = rcpt.gasUsed.toNumber() + return { nonce: nonce.toNumber(), gasPaid, gasUsed: gasUsed, diff: gasUsed - actualGasUsed.toNumber() } + }) + } +})() diff --git a/src/schnorrkel.js/README.md b/src/schnorrkel.js/README.md new file mode 100644 index 0000000..4ff1f2d --- /dev/null +++ b/src/schnorrkel.js/README.md @@ -0,0 +1 @@ +fork from: https://github.com/borislav-itskov/schnorrkel.js \ No newline at end of file diff --git a/src/schnorrkel.js/core/index.ts b/src/schnorrkel.js/core/index.ts new file mode 100644 index 0000000..b55248b --- /dev/null +++ b/src/schnorrkel.js/core/index.ts @@ -0,0 +1,220 @@ +import { randomBytes } from 'crypto' +import { ethers } from 'ethers' +import secp256k1 from 'secp256k1' +import ecurve from 'ecurve' +import elliptic from 'elliptic' +import bigi from 'bigi' +import { BN } from 'bn.js' + +import { InternalNoncePairs, InternalNonces, InternalPublicNonces, InternalSignature } from './types' +import { KeyPair } from '../types' + +const curve = ecurve.getCurveByName('secp256k1') +const n = curve?.n +const EC = elliptic.ec +const ec = new EC('secp256k1') +const generatorPoint = ec.g + +export const _generateL = (publicKeys: Uint8Array[]) => { + return ethers.utils.keccak256(_concatTypedArrays(publicKeys.sort())) +} + +export const _concatTypedArrays = (publicKeys: Uint8Array[]): Uint8Array => { + const c: Buffer = Buffer.alloc(publicKeys.reduce((partialSum, publicKey) => partialSum + publicKey.length, 0)) + publicKeys.map((publicKey, index) => c.set(publicKey, (index * publicKey.length))) + return new Uint8Array(c.buffer) +} + +export const _aCoefficient = (publicKey: Uint8Array, L: string): Uint8Array => { + return ethers.utils.arrayify(ethers.utils.solidityKeccak256( + ['bytes', 'bytes'], + [L, publicKey] + )) +} + +const _bCoefficient = (combinedPublicKey: Uint8Array, msgHash: string, publicNonces: InternalPublicNonces[]): Uint8Array => { + type KeyOf = keyof InternalPublicNonces + const arrayColumn = (arr: InternalPublicNonces[], n: KeyOf) => arr.map(x => x[n]) + const kPublicNonces = secp256k1.publicKeyCombine(arrayColumn(publicNonces, 'kPublic')) + const kTwoPublicNonces = secp256k1.publicKeyCombine(arrayColumn(publicNonces, 'kTwoPublic')) + + return ethers.utils.arrayify(ethers.utils.solidityKeccak256( + ['bytes', 'bytes32', 'bytes', 'bytes'], + [combinedPublicKey, msgHash, kPublicNonces, kTwoPublicNonces] + )) +} + +export const generateRandomKeys = () => { + let privKeyBytes: Buffer | undefined + do { + privKeyBytes = randomBytes(32) + } while (!secp256k1.privateKeyVerify(privKeyBytes)) + + const pubKey = Buffer.from(secp256k1.publicKeyCreate(privKeyBytes)) + + const data = { + publicKey: pubKey, + privateKey: privKeyBytes + } + + return new KeyPair(data) +} + +export const _hashPrivateKey = (privateKey: Uint8Array): string => { + return ethers.utils.keccak256(privateKey) +} + +export const _generatePublicNonces = (privateKey: Buffer): { + privateNonceData: Pick + publicNonceData: InternalPublicNonces + hash: string +} => { + const hash = _hashPrivateKey(privateKey) + const nonce = _generateNonce() + + return { + hash, + privateNonceData: { + k: nonce.k, + kTwo: nonce.kTwo + }, + publicNonceData: { + kPublic: nonce.kPublic, + kTwoPublic: nonce.kTwoPublic + } + } +} + +const _generateNonce = (): InternalNoncePairs => { + const k = ethers.utils.randomBytes(32) + const kTwo = ethers.utils.randomBytes(32) + const kPublic = secp256k1.publicKeyCreate(k) + const kTwoPublic = secp256k1.publicKeyCreate(kTwo) + + return { + k, + kTwo, + kPublic, + kTwoPublic + } +} + +export const _multiSigSign = (nonces: InternalNonces, combinedPublicKey: Uint8Array, privateKey: Uint8Array, msg: string, publicKeys: Uint8Array[], publicNonces: InternalPublicNonces[]): InternalSignature => { + if (publicKeys.length < 2) { + throw Error('At least 2 public keys should be provided') + } + + const xHashed = _hashPrivateKey(privateKey) + if (!(xHashed in nonces) || Object.keys(nonces[xHashed]).length === 0) { + throw Error('Nonces should be exchanged before signing') + } + + const publicKey = secp256k1.publicKeyCreate(privateKey) + const L = _generateL(publicKeys) + const msgHash = _hashMessage(msg) + const a = _aCoefficient(publicKey, L) + const b = _bCoefficient(combinedPublicKey, msgHash, publicNonces) + + const effectiveNonces = publicNonces.map((batch) => { + return secp256k1.publicKeyCombine([batch.kPublic, secp256k1.publicKeyTweakMul(batch.kTwoPublic, b)]) + }) + const signerEffectiveNonce = secp256k1.publicKeyCombine([ + nonces[xHashed].kPublic, + secp256k1.publicKeyTweakMul(nonces[xHashed].kTwoPublic, b) + ]) + const inArray = effectiveNonces.filter(nonce => areBuffersSame(nonce, signerEffectiveNonce)).length != 0 + if (!inArray) { + throw Error('Passed nonces are invalid') + } + + const R = secp256k1.publicKeyCombine(effectiveNonces) + const e = challenge(R, msgHash, combinedPublicKey) + + const { k, kTwo } = nonces[xHashed] + + // xe = x * e + const xe = secp256k1.privateKeyTweakMul(privateKey, e) + + // xea = a * xe + const xea = secp256k1.privateKeyTweakMul(xe, a) + + // k + xea + const kPlusxea = secp256k1.privateKeyTweakAdd(xea, k) + + // kTwo * b + const kTwoMulB = secp256k1.privateKeyTweakMul(kTwo, b) + + // k + kTwoMulB + xea + const final = secp256k1.privateKeyTweakAdd(kPlusxea, kTwoMulB) + + return { + // s = k + xea mod(n) + signature: bigi.fromBuffer(final).mod(n).toBuffer(32), + challenge: e, + finalPublicNonce: R + } +} + +const areBuffersSame = (buf1: Uint8Array, buf2: Uint8Array): boolean => { + if (buf1.byteLength != buf2.byteLength) return false + + const dv1 = new Int8Array(buf1) + const dv2 = new Int8Array(buf2) + for (let i = 0; i != buf1.byteLength; i++) { + if (dv1[i] != dv2[i]) return false + } + + return true +} + +const challenge = (R: Uint8Array, msgHash: string, publicKey: Uint8Array): Uint8Array => { + // convert R to address + const R_uncomp = secp256k1.publicKeyConvert(R, false) + const R_addr = ethers.utils.arrayify(ethers.utils.keccak256(R_uncomp.slice(1, 65))).slice(12, 32) + + // e = keccak256(address(R) || compressed publicKey || msgHash) + return ethers.utils.arrayify( + ethers.utils.solidityKeccak256( + ['address', 'uint8', 'bytes32', 'bytes32'], + [R_addr, publicKey[0] + 27 - 2, publicKey.slice(1, 33), msgHash] + ) + ) +} + +export const _sumSigs = (signatures: Uint8Array[]): Buffer => { + let combined = bigi.fromBuffer(signatures[0]) + signatures.shift() + signatures.forEach(sig => { + combined = combined.add(bigi.fromBuffer(sig)) + }) + return combined.mod(n).toBuffer(32) +} + +export const _hashMessage = (message: string): string => { + // return ethers.utils.solidityKeccak256(['string'], [message]) + return message +} + +export const _sign = (privateKey: Uint8Array, msg: string): InternalSignature => { + const hash = _hashMessage(msg) + const publicKey = secp256k1.publicKeyCreate((privateKey as any)) + + // R = G * k + const k = ethers.utils.randomBytes(32) + const R = secp256k1.publicKeyCreate(k) + + // e = h(address(R) || compressed pubkey || m) + const e = challenge(R, hash, publicKey) + + // xe = x * e + const xe = secp256k1.privateKeyTweakMul((privateKey as any), e) + + // s = k + xe + const s = secp256k1.privateKeyTweakAdd(k, xe) + + return { + finalPublicNonce: R, + challenge: e, + signature: s + } +} diff --git a/src/schnorrkel.js/core/types.ts b/src/schnorrkel.js/core/types.ts new file mode 100644 index 0000000..6b83872 --- /dev/null +++ b/src/schnorrkel.js/core/types.ts @@ -0,0 +1,28 @@ +export interface InternalNoncePairs { + readonly k: Uint8Array, + readonly kTwo: Uint8Array, + readonly kPublic: Uint8Array, + readonly kTwoPublic: Uint8Array, +} + +export interface InternalPublicNonces { + readonly kPublic: Uint8Array, + readonly kTwoPublic: Uint8Array, +} + +export interface InternalSignature { + finalPublicNonce: Uint8Array, // the final public nonce + challenge: Uint8Array, // the schnorr challenge + signature: Uint8Array, // the signature +} + +export interface InternalNoncePairs { + readonly k: Uint8Array, + readonly kTwo: Uint8Array, + readonly kPublic: Uint8Array, + readonly kTwoPublic: Uint8Array, +} + +export type InternalNonces = { + [privateKey: string]: InternalNoncePairs +} \ No newline at end of file diff --git a/src/schnorrkel.js/index.ts b/src/schnorrkel.js/index.ts new file mode 100644 index 0000000..3325240 --- /dev/null +++ b/src/schnorrkel.js/index.ts @@ -0,0 +1,6 @@ +import Schnorrkel from './schnorrkel' +export { default as UnsafeSchnorrkel } from './unsafe-schnorrkel' + +export { Key, KeyPair, Signature, PublicNonces, Challenge, SignatureOutput, FinalPublicNonce } from './types' + +export default Schnorrkel \ No newline at end of file diff --git a/src/schnorrkel.js/schnorrkel.ts b/src/schnorrkel.js/schnorrkel.ts new file mode 100644 index 0000000..f4e8993 --- /dev/null +++ b/src/schnorrkel.js/schnorrkel.ts @@ -0,0 +1,124 @@ +import secp256k1 from 'secp256k1' + +import { Key, Nonces, PublicNonces, Signature, NoncePairs } from './types' + +import { _generateL, _aCoefficient, _generatePublicNonces, _multiSigSign, _hashPrivateKey, _sumSigs, _verify, _generatePk, _sign } from './core' +import { InternalNonces, InternalPublicNonces } from './core/types' +import { Challenge, FinalPublicNonce, SignatureOutput } from './types/signature' + +class Schnorrkel { + protected nonces: Nonces = {} + + private _setNonce(privateKey: Buffer): string { + const { publicNonceData, privateNonceData, hash } = _generatePublicNonces(privateKey) + + const mappedPublicNonce: PublicNonces = { + kPublic: new Key(Buffer.from(publicNonceData.kPublic)), + kTwoPublic: new Key(Buffer.from(publicNonceData.kTwoPublic)), + } + + const mappedPrivateNonce: Pick = { + k: new Key(Buffer.from(privateNonceData.k)), + kTwo: new Key(Buffer.from(privateNonceData.kTwo)) + } + + this.nonces[hash] = { ...mappedPrivateNonce, ...mappedPublicNonce } + return hash + } + + static getCombinedPublicKey(publicKeys: Array): Key { + if (publicKeys.length < 2) { + throw Error('At least 2 public keys should be provided') + } + + const bufferPublicKeys = publicKeys.map(publicKey => publicKey.buffer) + const L = _generateL(bufferPublicKeys) + + const modifiedKeys = bufferPublicKeys.map(publicKey => { + return secp256k1.publicKeyTweakMul(publicKey, _aCoefficient(publicKey, L)) + }) + + return new Key(Buffer.from(secp256k1.publicKeyCombine(modifiedKeys))) + } + + static getCombinedAddress(publicKeys: Array): string { + if (publicKeys.length < 2) throw Error('At least 2 public keys should be provided') + + const combinedPublicKey = Schnorrkel.getCombinedPublicKey(publicKeys) + const px = _generatePk(combinedPublicKey.buffer) + return px + } + + generatePublicNonces(privateKey: Key): PublicNonces { + const hash = this._setNonce(privateKey.buffer) + const nonce = this.nonces[hash] + + return { + kPublic: nonce.kPublic, + kTwoPublic: nonce.kTwoPublic, + } + } + + private clearNonces(privateKey: Key): void { + const x = privateKey.buffer + const hash = _hashPrivateKey(x) + + delete this.nonces[hash] + } + + multiSigSign(privateKey: Key, msg: string, publicKeys: Key[], publicNonces: PublicNonces[]): SignatureOutput { + const combinedPublicKey = Schnorrkel.getCombinedPublicKey(publicKeys) + const mappedPublicNonce: InternalPublicNonces[] = publicNonces.map(publicNonce => { + return { + kPublic: publicNonce.kPublic.buffer, + kTwoPublic: publicNonce.kTwoPublic.buffer, + } + }) + + const mappedNonces: InternalNonces = Object.fromEntries(Object.entries(this.nonces).map(([hash, nonce]) => { + return [ + hash, + { + k: nonce.k.buffer, + kTwo: nonce.kTwo.buffer, + kPublic: nonce.kPublic.buffer, + kTwoPublic: nonce.kTwoPublic.buffer, + } + ] + })) + + const musigData = _multiSigSign(mappedNonces, combinedPublicKey.buffer, privateKey.buffer, msg, publicKeys.map(key => key.buffer), mappedPublicNonce) + + // absolutely crucial to delete the nonces once a signature has been crafted with them. + // nonce reusae will lead to private key leakage! + this.clearNonces(privateKey) + + return { + signature: new Signature(Buffer.from(musigData.signature)), + finalPublicNonce: new FinalPublicNonce(Buffer.from(musigData.finalPublicNonce)), + challenge: new Challenge(Buffer.from(musigData.challenge)), + } + } + + static sign(privateKey: Key, msg: string): SignatureOutput { + const output = _sign(privateKey.buffer, msg) + + return { + signature: new Signature(Buffer.from(output.signature)), + finalPublicNonce: new FinalPublicNonce(Buffer.from(output.finalPublicNonce)), + challenge: new Challenge(Buffer.from(output.challenge)), + } + } + + static sumSigs(signatures: Signature[]): Signature { + const mappedSignatures = signatures.map(signature => signature.buffer) + const sum = _sumSigs(mappedSignatures) + return new Signature(Buffer.from(sum)) + } + + static verify(signature: Signature, msg: string, finalPublicNonce: FinalPublicNonce, publicKey: Key): boolean { + return _verify(signature.buffer, msg, finalPublicNonce.buffer, publicKey.buffer) + } +} + +export default Schnorrkel \ No newline at end of file diff --git a/src/schnorrkel.js/types/index.ts b/src/schnorrkel.js/types/index.ts new file mode 100644 index 0000000..0b86beb --- /dev/null +++ b/src/schnorrkel.js/types/index.ts @@ -0,0 +1,4 @@ +export { Key } from './key' +export { KeyPair } from './key-pair' +export { PublicNonces, Nonces, NoncePairs } from './nonce' +export { Signature, Challenge, SignatureOutput, FinalPublicNonce } from './signature' diff --git a/src/schnorrkel.js/types/key-pair.ts b/src/schnorrkel.js/types/key-pair.ts new file mode 100644 index 0000000..d29a552 --- /dev/null +++ b/src/schnorrkel.js/types/key-pair.ts @@ -0,0 +1,30 @@ +import { Key } from './key' + +export class KeyPair { + privateKey: Key + publicKey: Key + + constructor({ publicKey, privateKey }: { publicKey: Buffer, privateKey: Buffer }) { + this.privateKey = new Key(privateKey) + this.publicKey = new Key(publicKey) + } + + static fromJson(params: string): KeyPair { + try { + const data = JSON.parse(params) + const publicKey = Key.fromHex(data.publicKey) + const privateKey = Key.fromHex(data.privateKey) + + return new KeyPair({ publicKey: publicKey.buffer, privateKey: privateKey.buffer }) + } catch (error) { + throw new Error('Invalid JSON') + } + } + + toJson(): string { + return JSON.stringify({ + publicKey: this.publicKey.toHex(), + privateKey: this.privateKey.toHex(), + }) + } +} diff --git a/src/schnorrkel.js/types/key.ts b/src/schnorrkel.js/types/key.ts new file mode 100644 index 0000000..04aa6b6 --- /dev/null +++ b/src/schnorrkel.js/types/key.ts @@ -0,0 +1,15 @@ +export class Key { + readonly buffer: Buffer + + constructor(buffer: Buffer) { + this.buffer = buffer + } + + toHex(): string { + return this.buffer.toString('hex') + } + + static fromHex(hex: string): Key { + return new Key(Buffer.from(hex, 'hex')) + } +} diff --git a/src/schnorrkel.js/types/nonce.ts b/src/schnorrkel.js/types/nonce.ts new file mode 100644 index 0000000..ecbbfe6 --- /dev/null +++ b/src/schnorrkel.js/types/nonce.ts @@ -0,0 +1,18 @@ +import { Key } from './key' + +export interface NoncePairs { + readonly k: Key, + readonly kTwo: Key, + readonly kPublic: Key, + readonly kTwoPublic: Key, +} + +export interface PublicNonces { + readonly kPublic: Key, + readonly kTwoPublic: Key, +} + + +export type Nonces = { + [privateKey: string]: NoncePairs +} \ No newline at end of file diff --git a/src/schnorrkel.js/types/signature.ts b/src/schnorrkel.js/types/signature.ts new file mode 100644 index 0000000..4d56c11 --- /dev/null +++ b/src/schnorrkel.js/types/signature.ts @@ -0,0 +1,55 @@ + +export interface SignatureOutput { + finalPublicNonce: FinalPublicNonce, // the final public nonce + challenge: Challenge, // the schnorr challenge + signature: Signature, // the signature +} + +export class FinalPublicNonce { + readonly buffer: Buffer + + constructor(buffer: Buffer) { + this.buffer = buffer + } + + toHex(): string { + return this.buffer.toString('hex') + } + + static fromHex(hex: string): FinalPublicNonce { + return new FinalPublicNonce(Buffer.from(hex, 'hex')) + } +} + +export class Challenge { + readonly buffer: Buffer + + constructor(buffer: Buffer) { + this.buffer = buffer + } + + toHex(): string { + return this.buffer.toString('hex') + } + + static fromHex(hex: string): FinalPublicNonce { + return new FinalPublicNonce(Buffer.from(hex, 'hex')) + } +} + +export class Signature { + readonly buffer: Buffer + + constructor(buffer: Buffer) { + this.buffer = buffer + } + + toHex(): string { + return this.buffer.toString('hex') + } + + static fromHex(hex: string): FinalPublicNonce { + return new FinalPublicNonce(Buffer.from(hex, 'hex')) + } +} + diff --git a/src/schnorrkel.js/unsafe-schnorrkel.ts b/src/schnorrkel.js/unsafe-schnorrkel.ts new file mode 100644 index 0000000..44fb884 --- /dev/null +++ b/src/schnorrkel.js/unsafe-schnorrkel.ts @@ -0,0 +1,59 @@ +import { Key } from './types' + +import { _generateL, _aCoefficient, _generatePublicNonces, _multiSigSign, _hashPrivateKey, _sumSigs, _verify, _generatePk, _sign } from './core' +import Schnorrkel from './schnorrkel' + +class UnsafeSchnorrkel extends Schnorrkel { + static fromJson(json: string): UnsafeSchnorrkel { + interface JsonData { + nonces: { + [hash: string]: { + k: string, + kTwo: string, + kPublic: string, + kTwoPublic: string, + } + } + } + try { + const jsonData = JSON.parse(json) as JsonData + const noncesEntries = Object.entries(jsonData.nonces).map(([hash, nonce]) => { + return [ + hash, + { + k: Key.fromHex(nonce.k), + kTwo: Key.fromHex(nonce.kTwo), + kPublic: Key.fromHex(nonce.kPublic), + kTwoPublic: Key.fromHex(nonce.kTwoPublic), + } + ] + }) + + const schnorrkel = new UnsafeSchnorrkel() + schnorrkel.nonces = Object.fromEntries(noncesEntries) + return schnorrkel + } catch (error) { + throw new Error('Invalid JSON') + } + } + + toJson() { + const nonces = Object.fromEntries(Object.entries(this.nonces).map(([hash, nonce]) => { + return [ + hash, + { + k: nonce.k.toHex(), + kTwo: nonce.kTwo.toHex(), + kPublic: nonce.kPublic.toHex(), + kTwoPublic: nonce.kTwoPublic.toHex(), + } + ] + })) + + return JSON.stringify({ + nonces, + }) + } +} + +export default UnsafeSchnorrkel \ No newline at end of file diff --git a/test/bloctoaccount.test.ts b/test/bloctoaccount.test.ts new file mode 100644 index 0000000..79a87ef --- /dev/null +++ b/test/bloctoaccount.test.ts @@ -0,0 +1,193 @@ +import { ethers } from 'hardhat' +import { Wallet, BigNumber } from 'ethers' +import { expect } from 'chai' +import { + BloctoAccount, + BloctoAccount__factory, + BloctoAccountCloneableWallet__factory, + TestBloctoAccountCloneableWalletV200, + TestBloctoAccountCloneableWalletV200__factory +} from '../typechain' +import { EntryPoint } from '@account-abstraction/contracts' +import { + fund, + createTmpAccount, + createAccount, + deployEntryPoint, + ONE_ETH, + createAuthorizedCosignerRecoverWallet, + txData, + signMessage, + getMergedKey +} from './testutils' +import '@openzeppelin/hardhat-upgrades' + +describe('BloctoAccount Upgrade Test', function () { + const ethersSigner = ethers.provider.getSigner() + + let authorizedWallet: Wallet + let cosignerWallet: Wallet + let recoverWallet: Wallet + + let implementation: string + let factory: BloctoAccountFactory + + let entryPoint: EntryPoint + + async function testCreateAccount (salt = 0, mergedKeyIndex = 0): Promise { + const [px, pxIndexWithParity] = getMergedKey(authorizedWallet, cosignerWallet, mergedKeyIndex) + + const account = await createAccount( + ethersSigner, + await authorizedWallet.getAddress(), + await cosignerWallet.getAddress(), + await recoverWallet.getAddress(), + BigNumber.from(salt), + pxIndexWithParity, + px, + factory + ) + await fund(account) + return account + } + + before(async function () { + // 3 wallet + [authorizedWallet, cosignerWallet, recoverWallet] = createAuthorizedCosignerRecoverWallet() + await fund(cosignerWallet.address) + // 4337 + entryPoint = await deployEntryPoint() + + // v1 implementation + implementation = (await new BloctoAccountCloneableWallet__factory(ethersSigner).deploy(entryPoint.address)).address + // account factory + const BloctoAccountFactory = await ethers.getContractFactory('BloctoAccountFactory') + factory = await upgrades.deployProxy(BloctoAccountFactory, [implementation, entryPoint.address], { initializer: 'initialize' }) + }) + + describe('wallet function', () => { + const AccountSalt = 123 + + it('should receive native token', async () => { + const account = await testCreateAccount(AccountSalt) + const beforeRecevive = await ethers.provider.getBalance(account.address) + const [owner] = await ethers.getSigners() + + const tx = await owner.sendTransaction({ + to: account.address, + value: ONE_ETH // Sends exactly 1.0 ether + }) + const receipt = await tx.wait() + const receivedSelector = ethers.utils.id('Received(address,uint256)') + expect(receipt.logs[0].topics[0]).to.equal(receivedSelector) + expect(await ethers.provider.getBalance(account.address)).to.equal(beforeRecevive.add(ONE_ETH)) + }) + + it('should create account with multiple authorized address', async () => { + const [authorizedWallet2, cosignerWallet2, recoverWallet2] = createAuthorizedCosignerRecoverWallet() + const authorizedWallet22 = createTmpAccount() + + const [px, pxIndexWithParity] = getMergedKey(authorizedWallet, cosignerWallet, 0) + const [px2, pxIndexWithParity2] = getMergedKey(authorizedWallet2, cosignerWallet2, 1) + + const tx = await factory.createAccount2([authorizedWallet2.address, authorizedWallet22.address], + cosignerWallet2.address, recoverWallet2.address, + 754264557, // random salt + [pxIndexWithParity, pxIndexWithParity2], + [px, px2]) + + const receipt = await tx.wait() + console.log('createAccount with multiple authorized address gasUsed: ', receipt.gasUsed) + let findWalletCreated = false + receipt.events?.forEach((event) => { + if (event.event === 'WalletCreated' && + event.args?.authorizedAddress === authorizedWallet2.address) { + findWalletCreated = true + } + }) + expect(findWalletCreated).true + }) + }) + + describe('should upgrade account to different version implementation', () => { + const AccountSalt = 12345 + const MockEntryPointV070 = '0x000000000000000000000000000000000000E070' + let account: BloctoAccount + let implementationV200: TestBloctoAccountCloneableWalletV200 + + async function upgradeAccountToV200 (): Promise { + const authorizeInAccountNonce = (await account.nonces(authorizedWallet.address)).add(1) + const accountLinkCosigner = BloctoAccount__factory.connect(account.address, cosignerWallet) + const upgradeToData = txData(1, account.address, BigNumber.from(0), + account.interface.encodeFunctionData('upgradeTo', [implementationV200.address])) + + const sign = await signMessage(authorizedWallet, account.address, authorizeInAccountNonce, upgradeToData) + await accountLinkCosigner.invoke1CosignerSends(sign.v, sign.r, sign.s, authorizeInAccountNonce, authorizedWallet.address, upgradeToData) + } + + before(async () => { + account = await testCreateAccount(AccountSalt) + // mock new entry point version 0.7.0 + implementationV200 = await new TestBloctoAccountCloneableWalletV200__factory(ethersSigner).deploy(MockEntryPointV070) + // await factory.setImplementation(implementationV200.address) + }) + + it('new factory get new version and same acccount address', async () => { + const beforeAccountAddr = await factory.getAddress(await cosignerWallet.getAddress(), await recoverWallet.getAddress(), AccountSalt) + const UpgradeContract = await ethers.getContractFactory('TestBloctoAccountFactoryV200') + const factoryV200 = await upgrades.upgradeProxy(factory.address, UpgradeContract) + + factory.setImplementation(implementationV200.address) + expect(await factory.VERSION()).to.eql('2.0.0') + + const afterAccountAddr = await factoryV200.getAddress(await cosignerWallet.getAddress(), await recoverWallet.getAddress(), AccountSalt) + expect(beforeAccountAddr).to.eql(afterAccountAddr) + }) + + it('upgrade fail if not by contract self', async () => { + // upgrade revert even though upgrade by cosigner + await expect(account.connect(cosignerWallet).upgradeTo(implementationV200.address)) + .to.revertedWith('must be called from `invoke()') + }) + + it('upgrade test', async () => { + await upgradeAccountToV200() + expect(await account.VERSION()).to.eql('2.0.0') + }) + + it('factory getAddress sould be same', async () => { + const addrFromFacotry = await factory.getAddress( + await cosignerWallet.getAddress(), + await recoverWallet.getAddress(), + AccountSalt) + expect(addrFromFacotry).to.eql(account.address) + }) + + it('new account get new version', async () => { + const randomSalt = 54326346 + const accountNew = await testCreateAccount(randomSalt) + + expect(await accountNew.VERSION()).to.eql('2.0.0') + }) + + it('should entrypoint be v070 address', async () => { + expect(await account.entryPoint()).to.eql(MockEntryPointV070) + }) + }) + + describe('should upgrade factory to different version implementation', () => { + const TestSalt = 135341 + + it('new factory get new version but same acccount address', async () => { + const beforeAccountAddr = await factory.getAddress(await cosignerWallet.getAddress(), await recoverWallet.getAddress(), TestSalt) + + const UpgradeContract = await ethers.getContractFactory('TestBloctoAccountFactoryV200') + const factoryV200 = await upgrades.upgradeProxy(factory.address, UpgradeContract) + + expect(await factoryV200.VERSION()).to.eql('2.0.0') + + const afterAccountAddr = await factoryV200.getAddress(await cosignerWallet.getAddress(), await recoverWallet.getAddress(), TestSalt) + expect(beforeAccountAddr).to.eql(afterAccountAddr) + }) + }) +}) diff --git a/test/entrypoint/UserOp.ts b/test/entrypoint/UserOp.ts new file mode 100644 index 0000000..0659fb9 --- /dev/null +++ b/test/entrypoint/UserOp.ts @@ -0,0 +1,222 @@ +import { + arrayify, + defaultAbiCoder, + hexDataSlice, + keccak256 +} from 'ethers/lib/utils' +import { BigNumber, Contract, Signer, Wallet } from 'ethers' +import { AddressZero, callDataCost, rethrow } from '../testutils' +import { ecsign, toRpcSig, keccak256 as keccak256_buffer } from 'ethereumjs-util' +import { + EntryPoint +} from '../../typechain' +import { UserOperation } from './UserOperation' +import { Create2Factory } from '../../src/Create2Factory' + +export function packUserOp (op: UserOperation, forSignature = true): string { + if (forSignature) { + 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]) + } +} + +export function packUserOp1 (op: UserOperation): string { + return defaultAbiCoder.encode([ + 'address', // sender + 'uint256', // nonce + 'bytes32', // initCode + 'bytes32', // callData + 'uint256', // callGasLimit + 'uint256', // verificationGasLimit + 'uint256', // preVerificationGas + 'uint256', // maxFeePerGas + 'uint256', // maxPriorityFeePerGas + 'bytes32' // paymasterAndData + ], [ + op.sender, + op.nonce, + keccak256(op.initCode), + keccak256(op.callData), + op.callGasLimit, + op.verificationGasLimit, + op.preVerificationGas, + op.maxFeePerGas, + op.maxPriorityFeePerGas, + keccak256(op.paymasterAndData) + ]) +} + +export function getUserOpHash (op: UserOperation, entryPoint: string, chainId: number): string { + const userOpHash = keccak256(packUserOp(op, true)) + const enc = defaultAbiCoder.encode( + ['bytes32', 'address', 'uint256'], + [userOpHash, entryPoint, chainId]) + return keccak256(enc) +} + +export const DefaultsForUserOp: UserOperation = { + sender: AddressZero, + nonce: 0, + initCode: '0x', + callData: '0x', + callGasLimit: 0, + 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, + paymasterAndData: '0x', + signature: '0x' +} + +export function signUserOp (op: UserOperation, signer: Wallet, entryPoint: string, chainId: number): UserOperation { + const message = getUserOpHash(op, entryPoint, chainId) + const msg1 = Buffer.concat([ + Buffer.from('\x19Ethereum Signed Message:\n32', 'ascii'), + Buffer.from(arrayify(message)) + ]) + + const sig = ecsign(keccak256_buffer(msg1), Buffer.from(arrayify(signer.privateKey))) + // that's equivalent of: await signer.signMessage(message); + // (but without "async" + const signedMessage1 = toRpcSig(sig.v, sig.r, sig.s) + return { + ...op, + signature: signedMessage1 + } +} + +export function fillUserOpDefaults (op: Partial, defaults = DefaultsForUserOp): UserOperation { + const partial: any = { ...op } + // we want "item:undefined" to be used from defaults, and not override defaults, so we must explicitly + // remove those so "merge" will succeed. + for (const key in partial) { + if (partial[key] == null) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete partial[key] + } + } + const filled = { ...defaults, ...partial } + return filled +} + +// helper to fill structure: +// - default callGasLimit to estimate call from entryPoint to account (TODO: add overhead) +// if there is initCode: +// - calculate sender by eth_call the deployment code +// - default verificationGasLimit estimateGas of deployment code plus default 100000 +// no initCode: +// - update nonce from account.getNonce() +// entryPoint param is only required to fill in "sender address when specifying "initCode" +// 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, getNonceFunction = 'getNonce'): Promise { + const op1 = { ...op } + const provider = entryPoint?.provider + if (op.initCode != null) { + const initAddr = hexDataSlice(op1.initCode!, 0, 20) + const initCallData = hexDataSlice(op1.initCode!, 20) + if (op1.nonce == null) op1.nonce = 0 + if (op1.sender == null) { + // hack: if the init contract is our known deployer, then we know what the address would be, without a view call + if (initAddr.toLowerCase() === Create2Factory.contractAddress.toLowerCase()) { + const ctr = hexDataSlice(initCallData, 32) + const salt = hexDataSlice(initCallData, 0, 32) + op1.sender = Create2Factory.getDeployedAddress(ctr, salt) + } else { + // console.log('\t== not our deployer. our=', Create2Factory.contractAddress, 'got', initAddr) + if (provider == null) throw new Error('no entrypoint/provider') + op1.sender = await entryPoint!.callStatic.getSenderAddress(op1.initCode!).catch(e => e.errorArgs.sender) + } + } + if (op1.verificationGasLimit == null) { + if (provider == null) throw new Error('no entrypoint/provider') + const initEstimate = await provider.estimateGas({ + from: entryPoint?.address, + to: initAddr, + data: initCallData, + gasLimit: 10e6 + }) + op1.verificationGasLimit = BigNumber.from(DefaultsForUserOp.verificationGasLimit).add(initEstimate) + } + } + if (op1.nonce == null) { + if (provider == null) throw new Error('must have entryPoint to autofill nonce') + 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') + const gasEtimated = await provider.estimateGas({ + from: entryPoint?.address, + to: op1.sender, + data: op1.callData + }) + + // console.log('estim', op1.sender,'len=', op1.callData!.length, 'res=', gasEtimated) + // estimateGas assumes direct call from entryPoint. add wrapper cost. + op1.callGasLimit = gasEtimated // .add(55000) + } + if (op1.maxFeePerGas == null) { + if (provider == null) throw new Error('must have entryPoint to autofill maxFeePerGas') + const block = await provider.getBlock('latest') + op1.maxFeePerGas = block.baseFeePerGas!.add(op1.maxPriorityFeePerGas ?? DefaultsForUserOp.maxPriorityFeePerGas) + } + // TODO: this is exactly what fillUserOp below should do - but it doesn't. + // adding this manually + if (op1.maxPriorityFeePerGas == null) { + op1.maxPriorityFeePerGas = DefaultsForUserOp.maxPriorityFeePerGas + } + const op2 = fillUserOpDefaults(op1) + // eslint-disable-next-line @typescript-eslint/no-base-to-string + if (op2.preVerificationGas.toString() === '0') { + // TODO: we don't add overhead, which is ~21000 for a single TX, but much lower in a batch. + op2.preVerificationGas = callDataCost(packUserOp(op2, false)) + } + return op2 +} + +export async function fillAndSign (op: Partial, signer: Wallet | Signer, entryPoint?: EntryPoint, getNonceFunction = 'getNonce'): Promise { + const provider = entryPoint?.provider + const op2 = await fillUserOp(op, entryPoint, getNonceFunction) + + const chainId = await provider!.getNetwork().then(net => net.chainId) + const message = arrayify(getUserOpHash(op2, entryPoint!.address, chainId)) + + return { + ...op2, + signature: await signer.signMessage(message) + } +} + +export async function fillAndSignWithCoSigner (op: Partial, signer: Wallet | Signer, cosigner: Wallet | Signer, entryPoint?: EntryPoint, getNonceFunction = 'getNonce'): Promise { + const provider = entryPoint?.provider + const op2 = await fillUserOp(op, entryPoint) + + const chainId = await provider!.getNetwork().then(net => net.chainId) + const message = arrayify(getUserOpHash(op2, entryPoint!.address, chainId)) + + const signerSignature = await signer.signMessage(message) + const cosignerSignature = await cosigner.signMessage(message) + const signature = signerSignature + cosignerSignature.slice(2) + + return { + ...op2, + signature: signature + } +} diff --git a/test/entrypoint/UserOperation.ts b/test/entrypoint/UserOperation.ts new file mode 100644 index 0000000..8bed5ab --- /dev/null +++ b/test/entrypoint/UserOperation.ts @@ -0,0 +1,16 @@ +import * as typ from './solidityTypes' + +export interface UserOperation { + + sender: typ.address + nonce: typ.uint256 + initCode: typ.bytes + callData: typ.bytes + callGasLimit: typ.uint256 + verificationGasLimit: typ.uint256 + preVerificationGas: typ.uint256 + maxFeePerGas: typ.uint256 + maxPriorityFeePerGas: typ.uint256 + paymasterAndData: typ.bytes + signature: typ.bytes +} diff --git a/test/entrypoint/aa.init.ts b/test/entrypoint/aa.init.ts new file mode 100644 index 0000000..f796cdc --- /dev/null +++ b/test/entrypoint/aa.init.ts @@ -0,0 +1,6 @@ +import './chaiHelper' + +const ethers = require('ethers') +export const inspect_custom_symbol = Symbol.for('nodejs.util.inspect.custom') +// @ts-ignore +ethers.BigNumber.prototype[inspect_custom_symbol] = function () { return `BigNumber ${parseInt(this._hex)}` } diff --git a/test/entrypoint/chaiHelper.ts b/test/entrypoint/chaiHelper.ts new file mode 100644 index 0000000..f272168 --- /dev/null +++ b/test/entrypoint/chaiHelper.ts @@ -0,0 +1,65 @@ +// remap "eql" function to work nicely with EVM values. + +// cleanup "Result" object (returned on web3/ethers calls) +// remove "array" members, convert values to strings. +// so Result obj like +// { '0': "a", '1': 20, first: "a", second: 20 } +// becomes: +// { first: "a", second: "20" } +// map values inside object using mapping func. +import chai from 'chai' + +export function objValues (obj: { [key: string]: any }, mapFunc: (val: any, key?: string) => any): any { + return Object.keys(obj) + .filter(key => key.match(/^[\d_]/) == null) + .reduce((set, key) => ({ + ...set, + [key]: mapFunc(obj[key], key) + }), {}) +} + +/** + * cleanup a value of an object, for easier testing. + * - Result: this is an array which also contains named members. + * - obj.length*2 == Object.keys().length + * - remove the array elements, use just the named ones. + * - recursively handle inner members of object, arrays. + * - attempt toString. but if no normal value, recurse into fields. + */ +export function cleanValue (val: any): any { + if (val == null) return val + if (Array.isArray(val)) { + if (val.length * 2 === Object.keys(val).length) { + // "looks" like a Result object. + return objValues(val, cleanValue) + } + // its a plain array. map each array element + return val.map(val1 => cleanValue(val1)) + } + + const str = val.toString() + if (str !== '[object Object]') { return str } + + return objValues(val, cleanValue) +} + +// use cleanValue for comparing. MUCH easier, since numbers compare well with bignumbers, etc + +chai.Assertion.overwriteMethod('eql', (original) => { + // @ts-ignore + return function (this: any, expected: any) { + const _actual = cleanValue(this._obj) + const _expected = cleanValue(expected) + // original.apply(this,arguments) + this._obj = _actual + original.apply(this, [_expected]) + // assert.deepEqual(_actual, _expected) + // ctx.assert( + // _actual == _expected, + // 'expected #{act} to equal #{exp}', + // 'expected #{act} to be different from #{exp}', + // _expected, + // _actual + // ); + } +}) diff --git a/test/entrypoint/debugTx.ts b/test/entrypoint/debugTx.ts new file mode 100644 index 0000000..dc22598 --- /dev/null +++ b/test/entrypoint/debugTx.ts @@ -0,0 +1,26 @@ +import { ethers } from 'hardhat' + +export interface DebugLog { + pc: number + op: string + gasCost: number + depth: number + stack: string[] + memory: string[] +} + +export interface DebugTransactionResult { + gas: number + failed: boolean + returnValue: string + structLogs: DebugLog[] +} + +export async function debugTransaction (txHash: string, disableMemory = true, disableStorage = true): Promise { + const debugTx = async (hash: string): Promise => await ethers.provider.send('debug_traceTransaction', [hash, { + disableMemory, + disableStorage + }]) + + return await debugTx(txHash) +} diff --git a/test/entrypoint/entrypoint.test.ts b/test/entrypoint/entrypoint.test.ts new file mode 100644 index 0000000..9f05a91 --- /dev/null +++ b/test/entrypoint/entrypoint.test.ts @@ -0,0 +1,240 @@ +import './aa.init' +import { BigNumber, Event, Wallet } from 'ethers' +import { expect } from 'chai' +import { + EntryPoint, + BloctoAccount, + BloctoAccountFactory, + BloctoAccountCloneableWallet, + BloctoAccountCloneableWallet__factory, + BloctoAccountFactory, + BloctoAccountFactory__factory +} from '../../typechain' +import { + AddressZero, + createAccountOwner, + fund, + checkForGeth, + rethrow, + tostr, + getAccountInitCode, + getAccountInitCode2, + calcGasUsage, + ONE_ETH, + TWO_ETH, + deployEntryPoint, + getBalance, + createAddress, + getAccountAddress, + HashZero, + simulationResultCatch, + createTmpAccount, + createAccount, + createAuthorizedCosignerRecoverWallet, + getMergedKey +} from '../testutils' +import { checkForBannedOps } from './entrypoint_utils' +import { DefaultsForUserOp, getUserOpHash, fillAndSignWithCoSigner } from './UserOp' +import { UserOperation } from './UserOperation' +import { PopulatedTransaction } from 'ethers/lib/ethers' +import { ethers } from 'hardhat' +import { arrayify, defaultAbiCoder, hexConcat, hexZeroPad, parseEther } from 'ethers/lib/utils' +import { BytesLike } from '@ethersproject/bytes' +import { toChecksumAddress } from 'ethereumjs-util' + +describe('EntryPoint', function () { + let entryPoint: EntryPoint + let BloctoAccountFactory: BloctoAccountFactory + + let authorizedWallet: Wallet + let cosignerWallet: Wallet + let recoverWallet: Wallet + const ethersSigner = ethers.provider.getSigner() + let account: BloctoAccount + + let implementation: string + let factory: BloctoAccountFactory + + const globalUnstakeDelaySec = 2 + const paymasterStake = ethers.utils.parseEther('2') + + before(async function () { + this.timeout(20000) + await checkForGeth() + + const chainId = await ethers.provider.getNetwork().then(net => net.chainId) + + entryPoint = await deployEntryPoint() + + // v1 implementation + implementation = (await new BloctoAccountCloneableWallet__factory(ethersSigner).deploy(entryPoint.address)).address + + // account factory + const BloctoAccountFactory = await ethers.getContractFactory('BloctoAccountFactory') + factory = await upgrades.deployProxy(BloctoAccountFactory, [implementation, entryPoint.address], { initializer: 'initialize' }); + + [authorizedWallet, cosignerWallet, recoverWallet] = createAuthorizedCosignerRecoverWallet() + const [px, pxIndexWithParity] = getMergedKey(authorizedWallet, cosignerWallet, 0) + account = await createAccount( + ethersSigner, + await authorizedWallet.getAddress(), + await cosignerWallet.getAddress(), + await recoverWallet.getAddress(), + 0, + pxIndexWithParity, + px, + factory + ) + await fund(account) + + // sanity: validate helper functions + const sampleOp = await fillAndSignWithCoSigner( + { sender: account.address }, + authorizedWallet, + cosignerWallet, + entryPoint + ) + expect(getUserOpHash(sampleOp, entryPoint.address, chainId)).to.eql(await entryPoint.getUserOpHash(sampleOp)) + }) + + describe('#simulateValidation', () => { + let account1: BloctoAccount + let authorizedWallet1: Wallet + let cosignerWallet1: Wallet + let recoverWallet1: Wallet + + before(async () => { + [authorizedWallet1, cosignerWallet1, recoverWallet1] = createAuthorizedCosignerRecoverWallet() + const [px, pxIndexWithParity] = getMergedKey(authorizedWallet, cosignerWallet, 0) + account1 = await createAccount( + ethersSigner, + await authorizedWallet1.getAddress(), + await cosignerWallet1.getAddress(), + await recoverWallet1.getAddress(), + 0, + pxIndexWithParity, + px, + factory) + }) + it('should fail if validateUserOp fails', async () => { + // using wrong nonce + const op = await fillAndSignWithCoSigner( + { sender: account.address, nonce: 1234 }, + authorizedWallet, + cosignerWallet, + entryPoint + ) + await expect(entryPoint.callStatic.simulateValidation(op)).to + .revertedWith('AA25 invalid account nonce') + }) + + it('should report signature failure without revert', async () => { + // (this is actually a feature of the wallet, not the entrypoint) + // using wrong owner for account1 + // (zero gas price so it doesn't fail on prefund) + const op = await fillAndSignWithCoSigner( + { sender: account1.address, maxFeePerGas: 0 }, + authorizedWallet, + cosignerWallet, + entryPoint + ) + + const { returnInfo } = await entryPoint.callStatic.simulateValidation(op).catch(simulationResultCatch) + expect(returnInfo.sigFailed).to.be.true + }) + + it('should revert if wallet not deployed (and no initcode)', async () => { + const op = await fillAndSignWithCoSigner( + { + sender: createAddress(), + nonce: 0, + verificationGasLimit: 1000 + }, + authorizedWallet, + cosignerWallet, + entryPoint + ) + + await expect(entryPoint.callStatic.simulateValidation(op)).to + .revertedWith('AA20 account not deployed') + }) + + it('should revert on oog if not enough verificationGas', async () => { + const op = await fillAndSignWithCoSigner( + { sender: account.address, verificationGasLimit: 1000 }, + authorizedWallet, + cosignerWallet, + entryPoint + ) + await expect(entryPoint.callStatic.simulateValidation(op)).to + .revertedWith('AA23 reverted (or OOG)') + }) + + it('should succeed if validateUserOp succeeds', async () => { + const op = await fillAndSignWithCoSigner( + { sender: account1.address }, + authorizedWallet1, + cosignerWallet1, + entryPoint + ) + await fund(account1) + await entryPoint.callStatic.simulateValidation(op).catch(simulationResultCatch) + }) + + it('should return empty context if no paymaster', async () => { + const op = await fillAndSignWithCoSigner( + { sender: account1.address, maxFeePerGas: 0 }, + authorizedWallet1, + cosignerWallet1, + entryPoint + ) + const { returnInfo } = await entryPoint.callStatic.simulateValidation(op).catch(simulationResultCatch) + expect(returnInfo.paymasterContext).to.eql('0x') + }) + + it('should prevent overflows: fail if any numeric value is more than 120 bits', async () => { + const op = await fillAndSignWithCoSigner( + { + preVerificationGas: BigNumber.from(2).pow(130), + sender: account1.address + }, + authorizedWallet1, + cosignerWallet1, + entryPoint + ) + + await expect( + entryPoint.callStatic.simulateValidation(op) + ).to.revertedWith('gas values overflow') + }) + + it('should not call initCode from entrypoint', async () => { + // a possible attack: call an account's execFromEntryPoint through initCode. This might lead to stolen funds. + const [authorizedWallet2, cosignerWallet2, recoverWallet2] = createAuthorizedCosignerRecoverWallet() + const [px, pxIndexWithParity] = getMergedKey(authorizedWallet2, cosignerWallet2, 0) + const account = await createAccount( + ethersSigner, + await authorizedWallet2.getAddress(), + await cosignerWallet2.getAddress(), + await recoverWallet2.getAddress(), + 0, + pxIndexWithParity, + px, + factory) + + const sender = createAddress() + const op1 = await fillAndSignWithCoSigner({ + initCode: hexConcat([ + account.address, + account.interface.encodeFunctionData('execute', [sender, 0, '0x']) + ]), + sender: sender, + verificationGasLimit: 1e5, + maxFeePerGas: 0 + }, authorizedWallet2, cosignerWallet2, entryPoint) + + const error = await entryPoint.callStatic.simulateValidation(op1).catch(e => e) + expect(error.message).to.match(/initCode failed or OOG/, error) + }) + }) +}) diff --git a/test/entrypoint/entrypoint_utils.ts b/test/entrypoint/entrypoint_utils.ts new file mode 100644 index 0000000..f6e9f8d --- /dev/null +++ b/test/entrypoint/entrypoint_utils.ts @@ -0,0 +1,29 @@ +import { debugTransaction } from './debugTx' +import { expect } from 'chai' + +export async function checkForBannedOps (txHash: string, checkPaymaster: boolean): Promise { + const tx = await debugTransaction(txHash) + const logs = tx.structLogs + const blockHash = logs.map((op, index) => ({ op: op.op, index })).filter(op => op.op === 'NUMBER') + expect(blockHash.length).to.equal(2, 'expected exactly 2 call to NUMBER (Just before and after validateUserOperation)') + const validateAccountOps = logs.slice(0, blockHash[0].index - 1) + const validatePaymasterOps = logs.slice(blockHash[0].index + 1) + const ops = validateAccountOps.filter(log => log.depth > 1).map(log => log.op) + const paymasterOps = validatePaymasterOps.filter(log => log.depth > 1).map(log => log.op) + + expect(ops).to.include('POP', 'not a valid ops list: ' + JSON.stringify(ops)) // sanity + const bannedOpCodes = new Set(['GAS', 'BASEFEE', 'GASPRICE', 'NUMBER']) + expect(ops.filter((op, index) => { + // don't ban "GAS" op followed by "*CALL" + if (op === 'GAS' && (ops[index + 1].match(/CALL/) != null)) { + return false + } + return bannedOpCodes.has(op) + })).to.eql([]) + if (checkPaymaster) { + expect(paymasterOps).to.include('POP', 'not a valid ops list: ' + JSON.stringify(paymasterOps)) // sanity + expect(paymasterOps).to.not.include('BASEFEE') + expect(paymasterOps).to.not.include('GASPRICE') + expect(paymasterOps).to.not.include('NUMBER') + } +} diff --git a/test/entrypoint/solidityTypes.ts b/test/entrypoint/solidityTypes.ts new file mode 100644 index 0000000..5026ef9 --- /dev/null +++ b/test/entrypoint/solidityTypes.ts @@ -0,0 +1,10 @@ +// define the same export types as used by export typechain/ethers +import { BigNumberish } from 'ethers' +import { BytesLike } from '@ethersproject/bytes' + +export type address = string +export type uint256 = BigNumberish +export type uint = BigNumberish +export type uint48 = BigNumberish +export type bytes = BytesLike +export type bytes32 = BytesLike diff --git a/test/schnorrMultiSign.test.ts b/test/schnorrMultiSign.test.ts new file mode 100644 index 0000000..b93c5ba --- /dev/null +++ b/test/schnorrMultiSign.test.ts @@ -0,0 +1,133 @@ +// update from https://github.com/borislav-itskov/schnorrkel.js +import { ethers } from 'hardhat' +import { BigNumber } from 'ethers' +import { expect } from 'chai' +import { + BloctoAccountCloneableWallet__factory, + BloctoAccountFactory +} from '../typechain' +import { + createAccount, + deployEntryPoint, + createAuthorizedCosignerRecoverWallet, + hashMessageEIP191V0 +} from './testutils' + +import Schnorrkel from '../src/schnorrkel.js/index' +import { DefaultSigner } from './schnorrUtils' + +const ERC1271_MAGICVALUE_BYTES32 = '0x1626ba7e' + +describe('Schnorr MultiSign Test', function () { + const ethersSigner = ethers.provider.getSigner() + + let implementation: string + let factory: BloctoAccountFactory + + before(async function () { + // deploy entry point (only for fill address) + const entryPoint = await deployEntryPoint() + + // v1 implementation + implementation = (await new BloctoAccountCloneableWallet__factory(ethersSigner).deploy(entryPoint.address)).address + + // account factory + const BloctoAccountFactory = await ethers.getContractFactory('BloctoAccountFactory') + factory = await upgrades.deployProxy(BloctoAccountFactory, [implementation, entryPoint.address], { initializer: 'initialize' }) + }) + + it('should generate a schnorr musig2 and validate it on the blockchain', async () => { + // create account + // for only 1 byte, (isSchnorr,1)(authKeyIdx,6)(parity,1) + const mergedKeyIndex = 128 + (0 << 1) + const [authorizedWallet, cosignerWallet, recoverWallet] = createAuthorizedCosignerRecoverWallet() + const signerOne = new DefaultSigner(authorizedWallet) + const signerTwo = new DefaultSigner(cosignerWallet) + const publicKeys = [signerOne.getPublicKey(), signerTwo.getPublicKey()] + const publicNonces = [signerOne.getPublicNonces(), signerTwo.getPublicNonces()] + const combinedPublicKey = Schnorrkel.getCombinedPublicKey(publicKeys) + const px = ethers.utils.hexlify(combinedPublicKey.buffer.slice(1, 33)) + // because of the parity byte is 2, 3 so sub 2 + const pxIndexWithParity = combinedPublicKey.buffer.slice(0, 1).readInt8() - 2 + mergedKeyIndex + const account = await createAccount( + ethersSigner, + await authorizedWallet.getAddress(), + await cosignerWallet.getAddress(), + await recoverWallet.getAddress(), + BigNumber.from(123), + pxIndexWithParity, + px, + factory + ) + + // multisig + const msg = 'just a test message' + + const msgKeccak256 = ethers.utils.solidityKeccak256(['string'], [msg]) + const msgEIP191V0 = hashMessageEIP191V0(account.address, msgKeccak256) + // note: following line multiSignMessage ignore hash message + const { signature: sigOne, challenge: e } = signerOne.multiSignMessage(msgEIP191V0, publicKeys, publicNonces) + const { signature: sigTwo } = signerTwo.multiSignMessage(msgEIP191V0, publicKeys, publicNonces) + const sSummed = Schnorrkel.sumSigs([sigOne, sigTwo]) + + // wrap the result + // e (bytes32), s (bytes32), pxIndexWithParity (uint8) + // pxIndexWithParity (7 bit for pxIndex, 1 bit for parity) + const hexPxIndexWithParity = ethers.utils.hexlify(pxIndexWithParity).slice(-2) + const abiCoder = new ethers.utils.AbiCoder() + const sigData = abiCoder.encode(['bytes32', 'bytes32'], [ + e.buffer, + sSummed.buffer + ]) + hexPxIndexWithParity + const result = await account.isValidSignature(msgKeccak256, sigData) + expect(result).to.equal(ERC1271_MAGICVALUE_BYTES32) + }) + + it('check none zero mergedKeyIndex', async () => { + // create account + // for only 1 byte, (isSchnorr,1)(authKeyIdx,6)(parity,1) + const mergedKeyIndex = 128 + (2 << 1) + // following same as test 'should generate a schnorr musig2 and validate it on the blockchain' + const [authorizedWallet, cosignerWallet, recoverWallet] = createAuthorizedCosignerRecoverWallet() + const signerOne = new DefaultSigner(authorizedWallet) + const signerTwo = new DefaultSigner(cosignerWallet) + const publicKeys = [signerOne.getPublicKey(), signerTwo.getPublicKey()] + const publicNonces = [signerOne.getPublicNonces(), signerTwo.getPublicNonces()] + const combinedPublicKey = Schnorrkel.getCombinedPublicKey(publicKeys) + const px = ethers.utils.hexlify(combinedPublicKey.buffer.slice(1, 33)) + // because of the parity byte is 2, 3 so sub 2 + const pxIndexWithParity = combinedPublicKey.buffer.slice(0, 1).readInt8() - 2 + mergedKeyIndex + const account = await createAccount( + ethersSigner, + await authorizedWallet.getAddress(), + await cosignerWallet.getAddress(), + await recoverWallet.getAddress(), + BigNumber.from(123), + pxIndexWithParity, + px, + factory + ) + + // multisig + const msg = 'just a test message' + + const msgKeccak256 = ethers.utils.solidityKeccak256(['string'], [msg]) + const msgEIP191V0 = hashMessageEIP191V0(account.address, msgKeccak256) + // note: following line multiSignMessage ignore hash message + const { signature: sigOne, challenge: e } = signerOne.multiSignMessage(msgEIP191V0, publicKeys, publicNonces) + const { signature: sigTwo } = signerTwo.multiSignMessage(msgEIP191V0, publicKeys, publicNonces) + const sSummed = Schnorrkel.sumSigs([sigOne, sigTwo]) + + // wrap the result + // e (bytes32), s (bytes32), pxIndexWithParity (uint8) + // pxIndexWithParity (7 bit for pxIndex, 1 bit for parity) + const hexPxIndexWithParity = ethers.utils.hexlify(pxIndexWithParity).slice(-2) + const abiCoder = new ethers.utils.AbiCoder() + const sigData = abiCoder.encode(['bytes32', 'bytes32'], [ + e.buffer, + sSummed.buffer + ]) + hexPxIndexWithParity + const result = await account.isValidSignature(msgKeccak256, sigData) + expect(result).to.equal(ERC1271_MAGICVALUE_BYTES32) + }) +}) diff --git a/test/schnorrUtils.ts b/test/schnorrUtils.ts new file mode 100644 index 0000000..d5c1a3c --- /dev/null +++ b/test/schnorrUtils.ts @@ -0,0 +1,68 @@ +// import { Key, PublicNonces } from '@borislav.itskov/schnorrkel.js/src/types' +// fork from https://github.com/borislav-itskov/schnorrkel.js +// import Schnorrkel, { Key, PublicNonces, SignatureOutput } from '@borislav.itskov/schnorrkel.js/src/index' +import Schnorrkel, { Key, PublicNonces, SignatureOutput } from '../src/schnorrkel.js/index' +import { Wallet } from 'ethers' +import { ethers } from 'hardhat' +import secp256k1 from 'secp256k1' + +const schnorrkel = new Schnorrkel() + +export class KeyPair { + privateKey: Key + publicKey: Key + + constructor ({ publicKey, privateKey }: { publicKey: Buffer, privateKey: Buffer }) { + this.privateKey = new Key(privateKey) + this.publicKey = new Key(publicKey) + } + + static fromJson (params: string): KeyPair { + try { + const data = JSON.parse(params) + const publicKey = Key.fromHex(data.publicKey) + const privateKey = Key.fromHex(data.privateKey) + + return new KeyPair({ publicKey: publicKey.buffer, privateKey: privateKey.buffer }) + } catch (error) { + throw new Error('Invalid JSON') + } + } + + toJson (): string { + return JSON.stringify({ + publicKey: this.publicKey.toHex(), + privateKey: this.privateKey.toHex() + }) + } +} + +export class DefaultSigner { + #privateKey: Key + #publicKey: Key + + constructor (w: Wallet) { + const pubKey = Buffer.from(secp256k1.publicKeyCreate(ethers.utils.arrayify(w.privateKey))) + + const data = { + publicKey: pubKey, + privateKey: Buffer.from(ethers.utils.arrayify(w.privateKey)) + } + const keyPair = new KeyPair(data) + + this.#privateKey = keyPair.privateKey + this.#publicKey = keyPair.publicKey + } + + getPublicKey (): Key { + return this.#publicKey + } + + getPublicNonces (): PublicNonces { + return schnorrkel.generatePublicNonces(this.#privateKey) + } + + multiSignMessage (msg: string, publicKeys: Key[], publicNonces: PublicNonces[]): SignatureOutput { + return schnorrkel.multiSigSign(this.#privateKey, msg, publicKeys, publicNonces) + } +} diff --git a/test/testutils.ts b/test/testutils.ts new file mode 100644 index 0000000..ae0f13b --- /dev/null +++ b/test/testutils.ts @@ -0,0 +1,362 @@ +import { ethers, config } from 'hardhat' +import { + arrayify, + hexConcat, + keccak256, + parseEther, + hexlify +} from 'ethers/lib/utils' +import { BigNumber, BigNumberish, Contract, ContractReceipt, Signer, Wallet } from 'ethers' +import { + IERC20, + BloctoAccount, + BloctoAccount__factory, + BloctoAccountFactory +} from '../typechain' + +import { EntryPoint, EntryPoint__factory } from '@account-abstraction/contracts' + +import { Bytes, BytesLike, hexZeroPad, concat } from '@ethersproject/bytes' +import { toUtf8Bytes } from '@ethersproject/strings' + +import { expect } from 'chai' +import { Create2Factory } from '../src/Create2Factory' +import Schnorrkel from '../src/schnorrkel.js/index' +import { DefaultSigner } from './schnorrUtils' + +export const AddressZero = ethers.constants.AddressZero +export const HashZero = ethers.constants.HashZero +export const ONE_ETH = parseEther('1') +export const TWO_ETH = parseEther('2') +export const FIVE_ETH = parseEther('5') + +export const tostr = (x: any): string => x != null ? x.toString() : 'null' + +export function tonumber (x: any): number { + try { + return parseFloat(x.toString()) + } catch (e: any) { + console.log('=== failed to parseFloat:', x, (e).message) + return NaN + } +} + +// just throw 1eth from account[0] to the given address (or contract instance) +export async function fund (contractOrAddress: string | Contract, amountEth = '1'): Promise { + let address: string + if (typeof contractOrAddress === 'string') { + address = contractOrAddress + } else { + address = contractOrAddress.address + } + await ethers.provider.getSigner().sendTransaction({ to: address, value: parseEther(amountEth) }) +} + +export async function getBalance (address: string): Promise { + const balance = await ethers.provider.getBalance(address) + return parseInt(balance.toString()) +} + +export async function getTokenBalance (token: IERC20, address: string): Promise { + const balance = await token.balanceOf(address) + return parseInt(balance.toString()) +} + +let counter = 0 // Math.floor(Math.random() * 5000) + +export function createTmpAccount (): Wallet { + const privateKey = keccak256(Buffer.from(arrayify(BigNumber.from(++counter)))) + return new ethers.Wallet(privateKey, ethers.provider) + // const accounts: any = config.networks.hardhat.accounts + // console.log('accounts.path: ', accounts.path) + // console.log('accounts.mnemonic: ', accounts.mnemonic) + // counter++ + // return ethers.Wallet.fromMnemonic(accounts.mnemonic, accounts.path + `/${counter}`) +} + +// create non-random account, so gas calculations are deterministic +export function createAuthorizedCosignerRecoverWallet (): [Wallet, Wallet, Wallet] { + return [createTmpAccount(), createTmpAccount(), createTmpAccount()] +} + +export function createAddress (): string { + return createTmpAccount().address +} + +export function callDataCost (data: string): number { + return ethers.utils.arrayify(data) + .map(x => x === 0 ? 4 : 16) + .reduce((sum, x) => sum + x) +} + +export async function calcGasUsage (rcpt: ContractReceipt, entryPoint: EntryPoint, beneficiaryAddress?: string): Promise<{ actualGasCost: BigNumberish }> { + const actualGas = await rcpt.gasUsed + const logs = await entryPoint.queryFilter(entryPoint.filters.UserOperationEvent(), rcpt.blockHash) + const { actualGasCost, actualGasUsed } = logs[0].args + console.log('\t== actual gasUsed (from tx receipt)=', actualGas.toString()) + console.log('\t== calculated gasUsed (paid to beneficiary)=', actualGasUsed) + const tx = await ethers.provider.getTransaction(rcpt.transactionHash) + console.log('\t== gasDiff', actualGas.toNumber() - actualGasUsed.toNumber() - callDataCost(tx.data)) + if (beneficiaryAddress != null) { + expect(await getBalance(beneficiaryAddress)).to.eq(actualGasCost.toNumber()) + } + return { actualGasCost } +} + +// helper function to create the initCode to deploy the account, using our account factory. +export function getAccountInitCode (factory: BloctoAccountFactory, authorizedAddress: string, cosignerAddress: string, recoveryAddress: string, salt, pxIndexWithParity, px): BytesLike { + return hexConcat([ + factory.address, + factory.interface.encodeFunctionData('createAccount', [authorizedAddress, cosignerAddress, recoveryAddress, BigNumber.from(salt)]) + ]) +} + +// helper function to create the initCode to deploy the account, using our account factory. +export function getAccountInitCode2 (factory: BloctoAccountFactory, authorizedAddresses: BytesLike, cosignerAddress: string, recoveryAddress: string, salt = 0): BytesLike { + return hexConcat([ + factory.address, + factory.interface.encodeFunctionData('createAccount2', [authorizedAddresses, cosignerAddress, recoveryAddress, BigNumber.from(salt)]) + ]) +} + +// given the parameters as AccountDeployer, return the resulting "counterfactual address" that it would create. +export async function getAccountAddress (factory: BloctoAccountFactory, cosignerAddress: string, recoveryAddress: string, salt = 0): Promise { + return await factory.getAddress(cosignerAddress, recoveryAddress, BigNumber.from(salt)) +} + +const panicCodes: { [key: number]: string } = { + // from https://docs.soliditylang.org/en/v0.8.0/control-structures.html + 0x01: 'assert(false)', + 0x11: 'arithmetic overflow/underflow', + 0x12: 'divide by zero', + 0x21: 'invalid enum value', + 0x22: 'storage byte array that is incorrectly encoded', + 0x31: '.pop() on an empty array.', + 0x32: 'array sout-of-bounds or negative index', + 0x41: 'memory overflow', + 0x51: 'zero-initialized variable of internal function type' +} + +// rethrow "cleaned up" exception. +// - stack trace goes back to method (or catch) line, not inner provider +// - attempt to parse revert data (needed for geth) +// use with ".catch(rethrow())", so that current source file/line is meaningful. +export function rethrow (): (e: Error) => void { + const callerStack = new Error().stack!.replace(/Error.*\n.*at.*\n/, '').replace(/.*at.* \(internal[\s\S]*/, '') + + if (arguments[0] != null) { + throw new Error('must use .catch(rethrow()), and NOT .catch(rethrow)') + } + return function (e: Error) { + const solstack = e.stack!.match(/((?:.* at .*\.sol.*\n)+)/) + const stack = (solstack != null ? solstack[1] : '') + callerStack + // const regex = new RegExp('error=.*"data":"(.*?)"').compile() + const found = /error=.*?"data":"(.*?)"/.exec(e.message) + let message: string + if (found != null) { + const data = found[1] + message = decodeRevertReason(data) ?? e.message + ' - ' + data.slice(0, 100) + } else { + message = e.message + } + const err = new Error(message) + err.stack = 'Error: ' + message + '\n' + stack + throw err + } +} + +export function decodeRevertReason (data: string, nullIfNoMatch = true): string | null { + const methodSig = data.slice(0, 10) + const dataParams = '0x' + data.slice(10) + + if (methodSig === '0x08c379a0') { + const [err] = ethers.utils.defaultAbiCoder.decode(['string'], dataParams) + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `Error(${err})` + } else if (methodSig === '0x00fa072b') { + const [opindex, paymaster, msg] = ethers.utils.defaultAbiCoder.decode(['uint256', 'address', 'string'], dataParams) + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `FailedOp(${opindex}, ${paymaster !== AddressZero ? paymaster : 'none'}, ${msg})` + } else if (methodSig === '0x4e487b71') { + const [code] = ethers.utils.defaultAbiCoder.decode(['uint256'], dataParams) + return `Panic(${panicCodes[code] ?? code} + ')` + } + if (!nullIfNoMatch) { + return data + } + return null +} + +let currentNode: string = '' + +// basic geth support +// - by default, has a single account. our code needs more. +export async function checkForGeth (): Promise { + // @ts-ignore + const provider = ethers.provider._hardhatProvider + + currentNode = await provider.request({ method: 'web3_clientVersion' }) + + console.log('node version:', currentNode) + // NOTE: must run geth with params: + // --http.api personal,eth,net,web3 + // --allow-insecure-unlock + if (currentNode.match(/geth/i) != null) { + for (let i = 0; i < 2; i++) { + const acc = await provider.request({ method: 'personal_newAccount', params: ['pass'] }).catch(rethrow) + await provider.request({ method: 'personal_unlockAccount', params: [acc, 'pass'] }).catch(rethrow) + await fund(acc, '10') + } + } +} + +// remove "array" members, convert values to strings. +// so Result obj like +// { '0': "a", '1': 20, first: "a", second: 20 } +// becomes: +// { first: "a", second: "20" } +export function objdump (obj: { [key: string]: any }): any { + return Object.keys(obj) + .filter(key => key.match(/^[\d_]/) == null) + .reduce((set, key) => ({ + ...set, + [key]: decodeRevertReason(obj[key].toString(), false) + }), {}) +} +/** + * process exception of ValidationResult + * usage: entryPoint.simulationResult(..).catch(simulationResultCatch) + */ +export function simulationResultCatch (e: any): any { + if (e.errorName !== 'ValidationResult') { + throw e + } + return e.errorArgs +} + +/** + * process exception of ValidationResultWithAggregation + * usage: entryPoint.simulationResult(..).catch(simulationResultWithAggregation) + */ +export function simulationResultWithAggregationCatch (e: any): any { + if (e.errorName !== 'ValidationResultWithAggregation') { + throw e + } + return e.errorArgs +} + +export async function deployEntryPoint (provider = ethers.provider): Promise { + const create2factory = new Create2Factory(provider) + const epf = new EntryPoint__factory(provider.getSigner()) + const addr = await create2factory.deploy(epf.bytecode, 0, process.env.COVERAGE != null ? 20e6 : 8e6) + return EntryPoint__factory.connect(addr, provider.getSigner()) +} + +export async function isDeployed (addr: string): Promise { + const code = await ethers.provider.getCode(addr) + return code.length > 2 +} + +// Deploys an implementation and a proxy pointing to this implementation +export async function createAccount ( + ethersSigner: Signer, + authorizedAddresses: string, + cosignerAddresses: string, + recoverAddresses: string, + salt: BigNumberish, + mergedKeyIndexWithParity: number, + mergedKey: string, + accountFactory: BloctoAccountFactory +): Promise { + const tx = await accountFactory.createAccount(authorizedAddresses, cosignerAddresses, recoverAddresses, salt, mergedKeyIndexWithParity, mergedKey) + // console.log('tx: ', tx) + const receipt = await tx.wait() + console.log('createAccount gasUsed: ', receipt.gasUsed) + const accountAddress = await accountFactory.getAddress(cosignerAddresses, recoverAddresses, salt) + const account = BloctoAccount__factory.connect(accountAddress, ethersSigner) + return account +} + +// helper function to create the setEntryPointCode to set the account entryPoint address +export function getSetEntryPointCode (account: BloctoAccount, entryPointAddress: string): BytesLike { + return hexConcat([ + account.address, + account.interface.encodeFunctionData('setEntryPoint', [entryPointAddress]) + ]) +} + +// txData from https://github.com/dapperlabs/dapper-contracts/blob/master/test/wallet-utils.js +export const txData = (revert: number, to: string, amount: BigNumber, dataBuff: string): Uint8Array => { + // revert_flag (1), to (20), value (32), data length (32), data + const dataArr = [] + const revertBuff = Buffer.alloc(1) + // don't revert for now + revertBuff.writeUInt8(revert) + dataArr.push(revertBuff) + // 'to' is not padded (20 bytes) + dataArr.push(Buffer.from(to.replace('0x', ''), 'hex')) // address as string + // value (32 bytes) + dataArr.push(hexZeroPad(amount.toHexString(), 32)) + // data length (0) + // dataArr.push(utils.numToBuffer(dataBuff.length)) + const hex = Buffer.from(dataBuff.replace('0x', ''), 'hex') + dataArr.push(hexZeroPad(hexlify(hex.length), 32)) + if (hex.length > 0) { + dataArr.push(hex) + } + + return concat(dataArr) +} + +export const EIP191V0MessagePrefix = '\x19\x00' +export function hashMessageEIP191V0 (address: string, message: Bytes | string): string { + address = address.replace('0x', '') + + return keccak256(concat([ + toUtf8Bytes(EIP191V0MessagePrefix), + Uint8Array.from(Buffer.from(address, 'hex')), + message + ])) +} + +export async function signUpgrade (signerWallet: Wallet, accountAddress: string, nonce: BigNumber, newImplementationAddress: string): Promise { + const nonceBytesLike = hexZeroPad(nonce.toHexString(), 32) + + const dataForHash = concat([ + nonceBytesLike, + signerWallet.address, + newImplementationAddress + ]) + // console.log('dataForHash: ', dataForHash) + const sign = signerWallet._signingKey().signDigest(hashMessageEIP191V0(accountAddress, dataForHash)) + return sign +} + +export async function signMessage (signerWallet: Wallet, accountAddress: string, nonce: BigNumber, data: Uint8Array): Promise { + const nonceBytesLike = hexZeroPad(nonce.toHexString(), 32) + + const dataForHash = concat([ + nonceBytesLike, + signerWallet.address, + data + ]) + // console.log('dataForHash: ', dataForHash) + const sign = signerWallet._signingKey().signDigest(hashMessageEIP191V0(accountAddress, dataForHash)) + return sign +} + +export function logBytes (uint8: Uint8Array): string { + return Buffer.from(uint8).toString('hex') + '(' + uint8.length.toString() + ')' +} + +export function getMergedKey (wallet1: Wallet, wallet2: Wallet, mergedKeyIndex: number): [px: string, pxIndexWithParity: number] { + mergedKeyIndex = 128 + (mergedKeyIndex << 1) + const signerOne = new DefaultSigner(wallet1) + const signerTwo = new DefaultSigner(wallet2) + const publicKeys = [signerOne.getPublicKey(), signerTwo.getPublicKey()] + const combinedPublicKey = Schnorrkel.getCombinedPublicKey(publicKeys) + const px = ethers.utils.hexlify(combinedPublicKey.buffer.slice(1, 33)) + const pxIndexWithParity = combinedPublicKey.buffer.slice(0, 1).readInt8() - 2 + mergedKeyIndex + + return [px, pxIndexWithParity] +} diff --git a/yarn.lock b/yarn.lock index 3cdcf6e..bb58194 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,38 @@ resolved "https://registry.yarnpkg.com/@account-abstraction/contracts/-/contracts-0.6.0.tgz#7188a01839999226e6b2796328af338329543b76" integrity sha512-8ooRJuR7XzohMDM4MV34I12Ci2bmxfE9+cixakRL7lA4BAwJKQ3ahvd8FbJa9kiwkUPCUNtj+/zxDQWYYalLMQ== +"@aws-crypto/sha256-js@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-js/-/sha256-js-1.2.2.tgz#02acd1a1fda92896fc5a28ec7c6e164644ea32fc" + integrity sha512-Nr1QJIbW/afYYGzYvrF70LtaHrIRtd4TNAglX8BvlfxJLZ45SAmueIKYl5tWoNBPzp65ymXGFK0Bb1vZUpuc9g== + dependencies: + "@aws-crypto/util" "^1.2.2" + "@aws-sdk/types" "^3.1.0" + tslib "^1.11.1" + +"@aws-crypto/util@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@aws-crypto/util/-/util-1.2.2.tgz#b28f7897730eb6538b21c18bd4de22d0ea09003c" + integrity sha512-H8PjG5WJ4wz0UXAFXeJjWCW1vkvIJ3qUUD+rGRwJ2/hj+xT58Qle2MTql/2MGzkU+1JLAFuR6aJpLAjHwhmwwg== + dependencies: + "@aws-sdk/types" "^3.1.0" + "@aws-sdk/util-utf8-browser" "^3.0.0" + tslib "^1.11.1" + +"@aws-sdk/types@^3.1.0": + version "3.347.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.347.0.tgz#4affe91de36ef227f6375d64a6efda8d4ececd5d" + integrity sha512-GkCMy79mdjU9OTIe5KT58fI/6uqdf8UmMdWqVHmFJ+UpEzOci7L/uw4sOXWo7xpPzLs6cJ7s5ouGZW4GRPmHFA== + dependencies: + tslib "^2.5.0" + +"@aws-sdk/util-utf8-browser@^3.0.0": + version "3.259.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz#3275a6f5eb334f96ca76635b961d3c50259fd9ff" + integrity sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw== + dependencies: + tslib "^2.3.1" + "@babel/code-frame@^7.0.0": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" @@ -28,6 +60,15 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@borislav.itskov/schnorrkel.js@https://github.com/borislav-itskov/schnorrkel.js": + version "2.0.0" + resolved "https://github.com/borislav-itskov/schnorrkel.js#357a1b56c0294d1e71b84d07d7ee898cbf5719d4" + dependencies: + bigi "^1.4.2" + ecurve "^1.0.6" + ethers "^5.7.2" + secp256k1 "^5.0.0" + "@cspotcode/source-map-support@^0.8.0": version "0.8.1" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" @@ -134,7 +175,7 @@ "@ethersproject/properties" ">=5.0.0-beta.131" "@ethersproject/strings" ">=5.0.0-beta.130" -"@ethersproject/abi@5.7.0", "@ethersproject/abi@^5.0.9", "@ethersproject/abi@^5.1.2", "@ethersproject/abi@^5.5.0", "@ethersproject/abi@^5.7.0": +"@ethersproject/abi@5.7.0", "@ethersproject/abi@^5.0.9", "@ethersproject/abi@^5.1.2", "@ethersproject/abi@^5.5.0", "@ethersproject/abi@^5.6.3", "@ethersproject/abi@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.7.0.tgz#b3f3e045bbbeed1af3947335c247ad625a44e449" integrity sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA== @@ -802,26 +843,51 @@ resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.8.0.tgz#6854c37df205dd2c056bdfa1b853f5d732109109" integrity sha512-AGuwhRRL+NaKx73WKRNzeCxOCOCxpaqF+kp8TJ89QzAipSwZy/NoflkWaL9bywXFRhIzXt8j38sfF7KBKCPWLw== -"@openzeppelin/hardhat-upgrades@^1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@openzeppelin/hardhat-upgrades/-/hardhat-upgrades-1.23.0.tgz#294c81cc101da697761b8fda257b8229a5fb582f" - integrity sha512-hn0PYJuQG+jkuseNpYX+Rn90c9wVm8QRFRWJua6Q2BODwLKxThDb4l2bQMtdLvscKt2CS8ZFIfdvgKG/ejrgwg== - dependencies: - "@openzeppelin/upgrades-core" "^1.25.0" +"@openzeppelin/defender-base-client@^1.46.0": + version "1.46.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/defender-base-client/-/defender-base-client-1.46.0.tgz#aa5177f8fbad23fd03d78f3dbe06664bbe9333ff" + integrity sha512-EMnVBcfE6ZN5yMxfaxrFF3eqyGp2RQp3oSRSRP+R3yuCRJf8VCc2ArdZf1QPmQQzbq70nl8EZa03mmAqPauNlQ== + dependencies: + amazon-cognito-identity-js "^6.0.1" + async-retry "^1.3.3" + axios "^0.21.2" + lodash "^4.17.19" + node-fetch "^2.6.0" + +"@openzeppelin/hardhat-upgrades@^1.28.0": + version "1.28.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/hardhat-upgrades/-/hardhat-upgrades-1.28.0.tgz#6361f313a8a879d8a08a5e395acf0933bc190950" + integrity sha512-7sb/Jf+X+uIufOBnmHR0FJVWuxEs2lpxjJnLNN6eCJCP8nD0v+Ot5lTOW2Qb/GFnh+fLvJtEkhkowz4ZQ57+zQ== + dependencies: + "@openzeppelin/defender-base-client" "^1.46.0" + "@openzeppelin/platform-deploy-client" "^0.8.0" + "@openzeppelin/upgrades-core" "^1.27.0" chalk "^4.1.0" debug "^4.1.1" proper-lockfile "^4.1.1" -"@openzeppelin/upgrades-core@^1.25.0": - version "1.25.0" - resolved "https://registry.yarnpkg.com/@openzeppelin/upgrades-core/-/upgrades-core-1.25.0.tgz#4f540e2043b98f8b59a4e8a988b9aeb5b5f23a35" - integrity sha512-vSxOSm1k+P156nNm15ydhOmSPGC37mnl092FMVOH+eGaoqLjr2Za6ULVjDMFzvMnG+sGE1UoDOqBNPfTm0ch8w== +"@openzeppelin/platform-deploy-client@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/platform-deploy-client/-/platform-deploy-client-0.8.0.tgz#af6596275a19c283d6145f0128cc1247d18223c1" + integrity sha512-POx3AsnKwKSV/ZLOU/gheksj0Lq7Is1q2F3pKmcFjGZiibf+4kjGxr4eSMrT+2qgKYZQH1ZLQZ+SkbguD8fTvA== + dependencies: + "@ethersproject/abi" "^5.6.3" + "@openzeppelin/defender-base-client" "^1.46.0" + axios "^0.21.2" + lodash "^4.17.19" + node-fetch "^2.6.0" + +"@openzeppelin/upgrades-core@^1.27.0": + version "1.27.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/upgrades-core/-/upgrades-core-1.27.0.tgz#43f05c3e45b21bdc583488aa42297fbb0d065d17" + integrity sha512-FBIuFPKiRNMhW09HS8jkmV5DueGfxO2wp/kmCa0m0SMDyX4ROumgy/4Ao0/yH8/JZZPDiH1q3EnTRn+B7TGYgg== dependencies: cbor "^8.0.0" chalk "^4.1.0" compare-versions "^5.0.0" debug "^4.1.1" ethereumjs-util "^7.0.3" + minimist "^1.2.7" proper-lockfile "^4.1.1" solidity-ast "^0.4.15" @@ -1491,6 +1557,17 @@ ajv@^8.0.1: require-from-string "^2.0.2" uri-js "^4.2.2" +amazon-cognito-identity-js@^6.0.1: + version "6.2.0" + resolved "https://registry.yarnpkg.com/amazon-cognito-identity-js/-/amazon-cognito-identity-js-6.2.0.tgz#99e96666944429cb8f67b62e4cf7ad77fbe71ad0" + integrity sha512-9Fxrp9+MtLdsJvqOwSaE3ll+pneICeuE3pwj2yDkiyGNWuHx97b8bVLR2bOgfDmDJnY0Hq8QoeXtwdM4aaXJjg== + dependencies: + "@aws-crypto/sha256-js" "1.2.2" + buffer "4.9.2" + fast-base64-decode "^1.0.0" + isomorphic-unfetch "^3.0.0" + js-cookie "^2.2.1" + amdefine@>=0.0.4: version "1.0.1" resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" @@ -1747,6 +1824,13 @@ async-limiter@~1.0.0: resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== +async-retry@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280" + integrity sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw== + dependencies: + retry "0.13.1" + async@1.x, async@^1.4.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" @@ -1791,7 +1875,7 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== -axios@^0.21.1: +axios@^0.21.1, axios@^0.21.2: version "0.21.4" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== @@ -2336,7 +2420,7 @@ base-x@^3.0.2, base-x@^3.0.8: dependencies: safe-buffer "^5.0.1" -base64-js@^1.3.1: +base64-js@^1.0.2, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -2366,6 +2450,11 @@ bech32@1.1.4: resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.4.tgz#e38c9f37bf179b8eb16ae3a772b40c356d4832e9" integrity sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ== +bigi@^1.1.0, bigi@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/bigi/-/bigi-1.4.2.tgz#9c665a95f88b8b08fc05cfd731f561859d725825" + integrity sha512-ddkU+dFIuEIW8lE7ZwdIAf2UPoM90eaprg5m3YXAVVTmKlqV/9BX4A2M8BOK2yOq6/VgZFVhK6QAxJebhlbhzw== + bigint-crypto-utils@^3.0.23: version "3.1.7" resolved "https://registry.yarnpkg.com/bigint-crypto-utils/-/bigint-crypto-utils-3.1.7.tgz#c4c1b537c7c1ab7aadfaecf3edfd45416bf2c651" @@ -2600,6 +2689,15 @@ buffer-xor@^2.0.1: dependencies: safe-buffer "^5.1.1" +buffer@4.9.2: + version "4.9.2" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8" + integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + isarray "^1.0.0" + buffer@^5.0.5, buffer@^5.2.1, buffer@^5.5.0, buffer@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" @@ -3085,6 +3183,13 @@ concat-stream@^1.5.1: readable-stream "^2.2.2" typedarray "^0.0.6" +console-table-printer@^2.9.0: + version "2.11.1" + resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.11.1.tgz#c2dfe56e6343ea5bcfa3701a4be29fe912dbd9c7" + integrity sha512-8LfFpbF/BczoxPwo2oltto5bph8bJkGOATXsg3E9ddMJOGnWJciKHldx2zDj5XIBflaKzPfVCjOTl6tMh7lErg== + dependencies: + simple-wcswidth "^1.0.1" + content-disposition@0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -3541,6 +3646,14 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +ecurve@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/ecurve/-/ecurve-1.0.6.tgz#dfdabbb7149f8d8b78816be5a7d5b83fcf6de797" + integrity sha512-/BzEjNfiSuB7jIWKcS/z8FK9jNjmEWvUV2YZ4RLSmcDtP7Lq0m6FvDuSnJpBlDpGRpfRQeTLGLBI8H+kEv0r+w== + dependencies: + bigi "^1.1.0" + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -4445,7 +4558,7 @@ ethereumjs-wallet@^1.0.1: utf8 "^3.0.0" uuid "^8.3.2" -ethers@^5.0.1, ethers@^5.0.2, ethers@^5.4.2, ethers@^5.5.2, ethers@^5.5.3: +ethers@^5.0.1, ethers@^5.0.2, ethers@^5.4.2, ethers@^5.5.2, ethers@^5.5.3, ethers@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg== @@ -4637,6 +4750,11 @@ fake-merkle-patricia-tree@^1.0.1: dependencies: checkpoint-store "^1.1.0" +fast-base64-decode@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz#b434a0dd7d92b12b43f26819300d2dafb83ee418" + integrity sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -5377,6 +5495,13 @@ hardhat-deploy@^0.11.23: qs "^6.9.4" zksync-web3 "^0.8.1" +hardhat-storage-layout@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/hardhat-storage-layout/-/hardhat-storage-layout-0.1.7.tgz#ad8a5afd8593ee51031eb1dd9476b4a2ed981785" + integrity sha512-q723g2iQnJpRdMC6Y8fbh/stG6MLHKNxa5jq/ohjtD5znOlOzQ6ojYuInY8V4o4WcPyG3ty4hzHYunLf66/1+A== + dependencies: + console-table-printer "^2.9.0" + hardhat@^2.6.6: version "2.12.4" resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.12.4.tgz#e539ba58bee9ba1a1ced823bfdcec0b3c5a3e70f" @@ -5638,7 +5763,7 @@ idna-uts46-hx@^2.3.1: dependencies: punycode "2.1.0" -ieee754@^1.1.13, ieee754@^1.2.1: +ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -6081,7 +6206,7 @@ isarray@0.0.1: resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== -isarray@1.0.0, isarray@~1.0.0: +isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== @@ -6103,11 +6228,24 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== +isomorphic-unfetch@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz#87341d5f4f7b63843d468438128cb087b7c3e98f" + integrity sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q== + dependencies: + node-fetch "^2.6.1" + unfetch "^4.2.0" + isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== +js-cookie@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" + integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== + js-sdsl@^4.1.4: version "4.2.0" resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.2.0.tgz#278e98b7bea589b8baaf048c20aeb19eb7ad09d0" @@ -6609,7 +6747,7 @@ lodash@4.17.20: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== -lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.17.4: +lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21, lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -6935,6 +7073,11 @@ minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@~1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minimist@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + minipass@^2.6.0, minipass@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" @@ -7199,6 +7342,11 @@ node-addon-api@^2.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32" integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA== +node-addon-api@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" + integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== + node-emoji@^1.10.0: version "1.11.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" @@ -7214,6 +7362,13 @@ node-environment-flags@1.0.6: object.getownpropertydescriptors "^2.0.3" semver "^5.7.0" +node-fetch@^2.6.0: + version "2.6.11" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.11.tgz#cde7fc71deef3131ef80a738919f999e6edfff25" + integrity sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w== + dependencies: + whatwg-url "^5.0.0" + node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -8281,6 +8436,11 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + retry@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" @@ -8437,6 +8597,15 @@ secp256k1@^4.0.1: node-addon-api "^2.0.0" node-gyp-build "^4.2.0" +secp256k1@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-5.0.0.tgz#be6f0c8c7722e2481e9773336d351de8cddd12f7" + integrity sha512-TKWX8xvoGHrxVdqbYeZM9w+izTF4b9z3NhSaDkdn81btvuh+ivbIMGT/zQvDtTFWhRlThpoz6LEYTr7n8A5GcA== + dependencies: + elliptic "^6.5.4" + node-addon-api "^5.0.0" + node-gyp-build "^4.2.0" + seedrandom@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.1.tgz#eb3dde015bcf55df05a233514e5df44ef9dce083" @@ -8615,6 +8784,11 @@ simple-get@^2.7.0: once "^1.3.1" simple-concat "^1.0.0" +simple-wcswidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.0.1.tgz#8ab18ac0ae342f9d9b629604e54d2aa1ecb018b2" + integrity sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg== + slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" @@ -9395,11 +9569,16 @@ tsconfig-paths@^3.14.1, tsconfig-paths@^3.5.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: +tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.3.1, tslib@^2.5.0: + version "2.5.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913" + integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w== + tsort@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/tsort/-/tsort-0.0.1.tgz#e2280f5e817f8bf4275657fd0f9aebd44f5a2786" @@ -9603,6 +9782,11 @@ undici@^5.4.0: dependencies: busboy "^1.6.0" +unfetch@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.2.0.tgz#7e21b0ef7d363d8d9af0fb929a5555f6ef97a3be" + integrity sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA== + union-value@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" @@ -10126,9 +10310,9 @@ window-size@^0.2.0: integrity sha512-UD7d8HFA2+PZsbKyaOCEy8gMh1oDtHgJh1LfgjQ4zVXmYjAT/kvz3PueITKuqDiIXQe7yzpPnxX3lNc+AhQMyw== word-wrap@^1.2.3, word-wrap@~1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + version "1.2.4" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" + integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== wordwrap@^1.0.0: version "1.0.0"