├── .env.sample ├── .gitattributes ├── .gitignore ├── .husky └── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── examples ├── add_owner.json ├── double_transfer.json ├── eth_transfer.json └── wrap_and_unwrap_eth.json ├── hardhat.config.ts ├── package.json ├── src ├── contracts.ts ├── creation.ts ├── execution │ ├── index.ts │ ├── proposing.ts │ ├── signing.ts │ ├── simple.ts │ ├── submitting.ts │ └── utils.ts ├── history │ ├── index.ts │ ├── loading.ts │ └── types.ts ├── information.ts └── tasks.ts ├── tsconfig.json ├── tx_input.sample.json └── yarn.lock /.env.sample: -------------------------------------------------------------------------------- 1 | MNEMONIC="" 2 | INFURA_KEY="" 3 | # Used for custom network 4 | NODE_URL="" 5 | NETWORK="" -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sol linguist-language=Solidity 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | contracts/ 4 | .DS_Store 5 | .zos.session 6 | .openzeppelin/.session 7 | env/ 8 | .env 9 | bin/ 10 | dist/ 11 | deployments/ 12 | cli_cache 13 | solc 14 | coverage/ 15 | coverage.json 16 | yarn-error.log 17 | templates/ -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v15.6.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gnosis Safe Tasks 2 | ================= 3 | 4 | Install 5 | ------- 6 | Set correct node version (see `.nvmrc`) with [nvm](https://github.com/nvm-sh/nvm) 7 | ```bash 8 | nvm use 9 | ``` 10 | 11 | Install requirements with yarn: 12 | ```bash 13 | yarn 14 | ``` 15 | 16 | Quick Start 17 | ----------- 18 | ### Setup 19 | 20 | Create `.env` file to use the commands (see `.env.sample` for more info): 21 | 22 | - `NETWORK` - Network that should be used (e.g. `rinkeby`, `mainnet` or `custom`) 23 | - `PK` or `MNEMONIC`- Credentials for the account that should be used 24 | - `INFURA`- For network that use Infura based RPC 25 | - `NODE`- RPC node for `custom` network (optional) 26 | 27 | ### Help 28 | 29 | Use `yarn safe help ` to get more information about parameters of a command. 30 | 31 | Example: 32 | ```bash 33 | yarn safe help create 34 | ``` 35 | 36 | ### Create Safe 37 | Creates and setups a Safe proxy via the proxy factory. All parameters of the Safe `setup` method can be configured. 38 | 39 | #### Example 40 | This will deploy a Safe that uses the first imported account as an owner and set the threshold to 1. 41 | ```bash 42 | yarn safe create 43 | ``` 44 | 45 | ### Safe Info 46 | Displays information about a Safe 47 | 48 | #### Usage 49 | ```bash 50 | yarn safe info
51 | ``` 52 | 53 | ### Propose Safe Transaction 54 | Creates a proposal json file for a Safe transaction that can be shared. The name of the json file will be `.proposal.json` and it will be stored in the `cli_cache` folder. 55 | 56 | #### Examples 57 | This will create a transaction from the Safe to the target without any value or data. 58 | ```bash 59 | yarn safe propose
--to 60 | ``` 61 | 62 | This will create a transaction based on the sample tx input json that mints some WETH and sets an approve for it. 63 | ```bash 64 | yarn safe propose-multi
tx_input.sample.json 65 | ``` 66 | 67 | ### Show Proposal 68 | Shows the information of the proposal. 69 | Note: This requires the proposal file created before for that Safe transaction in the `cli_cache`. 70 | 71 | #### Usage 72 | ```bash 73 | yarn safe show-proposal 74 | ``` 75 | 76 | ### Sign Proposal 77 | Signs a proposal with the imported account 78 | Note: This requires the proposal file created before for that Safe transaction in the `cli_cache`. 79 | 80 | #### Usage 81 | ```bash 82 | yarn safe sign-proposal 83 | ``` 84 | 85 | ### Submit Proposal 86 | Submits a proposal with the imported account 87 | Note: This requires the proposal file created before for that Safe transaction in the `cli_cache`. 88 | 89 | #### Usage 90 | ```bash 91 | yarn safe submit-proposal 92 | ``` 93 | 94 | ### Show Transaction History 95 | Displays the transaction history of a Safe based on events 96 | 97 | #### Usage 98 | ```bash 99 | yarn safe history
100 | ``` 101 | 102 | Security and Liability 103 | ---------------------- 104 | All contracts are WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 105 | 106 | License 107 | ------- 108 | All smart contracts are released under LGPL-3.0 109 | -------------------------------------------------------------------------------- /examples/add_owner.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "to": "0xA6DdF1aa7043EE4e70537dAAaE8C249cc7E34Da4", 4 | "value": "0", 5 | "method": "addOwnerWithThreshold(address,uint256)", 6 | "params": [ 7 | "0x4A4f5916Ed639d16545d84e753CB7DDCc5f50743", 8 | "2" 9 | ], 10 | "operation": 0 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /examples/double_transfer.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "to": "0xA6DdF1aa7043EE4e70537dAAaE8C249cc7E34Da4", 4 | "value": "0.001", 5 | "operation": 0 6 | }, 7 | { 8 | "to": "0x4200000000000000000000000000000000000006", 9 | "value": "0", 10 | "operation": 0, 11 | "method": "transfer(address,uint256)", 12 | "params": [ 13 | "0xA6DdF1aa7043EE4e70537dAAaE8C249cc7E34Da4", 14 | "10000000000000" 15 | ] 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /examples/eth_transfer.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "to": "0xBc089147F86Bcc1341D9F2870997920082A7b0BA", 4 | "value": "0.0000042", 5 | "operation": 0 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /examples/wrap_and_unwrap_eth.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "to": "0x4200000000000000000000000000000000000006", 4 | "value": "0.000001", 5 | "operation": 0 6 | }, 7 | { 8 | "to": "0x4200000000000000000000000000000000000006", 9 | "value": "0", 10 | "operation": 0, 11 | "method": "withdraw(uint256)", 12 | "params": [ 13 | "1000000000000" 14 | ] 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import "@nomiclabs/hardhat-ethers"; 2 | import "hardhat-deploy"; 3 | import dotenv from "dotenv"; 4 | import type { HardhatUserConfig, HttpNetworkUserConfig } from "hardhat/types"; 5 | import yargs from "yargs"; 6 | 7 | const argv = yargs 8 | .option("network", { 9 | type: "string", 10 | default: "hardhat", 11 | }) 12 | .help(false) 13 | .version(false).argv; 14 | 15 | // Load environment variables. 16 | dotenv.config(); 17 | const { NETWORK, NODE_URL, INFURA_KEY, MNEMONIC, PK, SOLIDITY_VERSION, SOLIDITY_SETTINGS } = process.env; 18 | 19 | const DEFAULT_MNEMONIC = 20 | "candy maple cake sugar pudding cream honey rich smooth crumble sweet treat"; 21 | 22 | const sharedNetworkConfig: HttpNetworkUserConfig = {}; 23 | if (PK) { 24 | sharedNetworkConfig.accounts = [PK]; 25 | } else { 26 | sharedNetworkConfig.accounts = { 27 | mnemonic: MNEMONIC || DEFAULT_MNEMONIC, 28 | }; 29 | } 30 | 31 | if (["mainnet", "rinkeby", "kovan", "goerli"].includes(argv.network) && INFURA_KEY === undefined) { 32 | throw new Error( 33 | `Could not find Infura key in env, unable to connect to network ${argv.network}`, 34 | ); 35 | } 36 | 37 | import "./src/tasks" 38 | 39 | const primarySolidityVersion = SOLIDITY_VERSION || "0.7.6" 40 | const soliditySettings = !!SOLIDITY_SETTINGS ? JSON.parse(SOLIDITY_SETTINGS) : undefined 41 | 42 | const userConfig: HardhatUserConfig = { 43 | paths: { 44 | artifacts: "build/artifacts", 45 | cache: "build/cache", 46 | sources: "contracts", 47 | }, 48 | solidity: { 49 | compilers: [ 50 | { version: primarySolidityVersion, settings: soliditySettings }, 51 | { version: "0.6.12" }, 52 | { version: "0.5.17" }, 53 | ] 54 | }, 55 | networks: { 56 | hardhat: { 57 | allowUnlimitedContractSize: true, 58 | blockGasLimit: 100000000, 59 | gas: 100000000 60 | }, 61 | mainnet: { 62 | ...sharedNetworkConfig, 63 | url: `https://mainnet.infura.io/v3/${INFURA_KEY}`, 64 | }, 65 | xdai: { 66 | ...sharedNetworkConfig, 67 | url: "https://xdai.poanetwork.dev", 68 | }, 69 | ewc: { 70 | ...sharedNetworkConfig, 71 | url: `https://rpc.energyweb.org`, 72 | }, 73 | rinkeby: { 74 | ...sharedNetworkConfig, 75 | url: `https://rinkeby.infura.io/v3/${INFURA_KEY}`, 76 | }, 77 | goerli: { 78 | ...sharedNetworkConfig, 79 | url: `https://goerli.infura.io/v3/${INFURA_KEY}`, 80 | }, 81 | kovan: { 82 | ...sharedNetworkConfig, 83 | url: `https://kovan.infura.io/v3/${INFURA_KEY}`, 84 | }, 85 | volta: { 86 | ...sharedNetworkConfig, 87 | url: `https://volta-rpc.energyweb.org`, 88 | }, 89 | bsc: { 90 | ...sharedNetworkConfig, 91 | url: `https://bsc-dataseed.binance.org/`, 92 | }, 93 | }, 94 | namedAccounts: { 95 | deployer: 0, 96 | }, 97 | mocha: { 98 | timeout: 2000000, 99 | }, 100 | }; 101 | if (NETWORK) { 102 | userConfig.defaultNetwork = NETWORK 103 | } 104 | if (NODE_URL) { 105 | userConfig.networks!!.custom = { 106 | ...sharedNetworkConfig, 107 | url: NODE_URL, 108 | } 109 | } 110 | export default userConfig 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gnosis.pm/safe-tasks", 3 | "version": "1.3.0", 4 | "description": "Hardhat tasks for the Gnosis Safe", 5 | "homepage": "https://github.com/gnosis/safe-tasks/", 6 | "license": "GPL-3.0", 7 | "files": [ 8 | "contracts", 9 | "test", 10 | "build", 11 | "networks.json" 12 | ], 13 | "scripts": { 14 | "lint": "eslint --max-warnings 0 .", 15 | "safe": "hardhat", 16 | "prepare": "husky install" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/gnosis/safe-tasks.git" 21 | }, 22 | "keywords": [ 23 | "Ethereum", 24 | "CLI", 25 | "Safe" 26 | ], 27 | "author": "stefan@gnosis.pm", 28 | "bugs": { 29 | "url": "https://github.com/gnosis/safe-tasks/issues" 30 | }, 31 | "devDependencies": { 32 | "@typescript-eslint/eslint-plugin": "^4.7.0", 33 | "@typescript-eslint/parser": "^4.7.0", 34 | "eslint": "^7.13.0", 35 | "eslint-config-prettier": "^6.15.0", 36 | "eslint-plugin-import": "^2.22.1", 37 | "eslint-plugin-no-only-tests": "^2.4.0", 38 | "eslint-plugin-prettier": "^3.1.4", 39 | "husky": "^5.1.3", 40 | "rimraf": "^3.0.2", 41 | "ts-node": "^9.1.1", 42 | "typescript": "^4.1.3" 43 | }, 44 | "dependencies": { 45 | "@gnosis.pm/safe-contracts": "1.3.0", 46 | "@gnosis.pm/safe-deployments": "1.5.0", 47 | "@nomiclabs/hardhat-ethers": "^2.0.0", 48 | "@types/mocha": "^8.2.0", 49 | "@types/yargs": "^15.0.10", 50 | "argv": "^0.0.2", 51 | "csv-parser": "^3.0.0", 52 | "dotenv": "^8.0.0", 53 | "ethers": "^5.1.0", 54 | "hardhat": "^2.1.2", 55 | "hardhat-deploy": "^0.7.4", 56 | "solc": "0.7.6", 57 | "yargs": "^16.1.1" 58 | }, 59 | "resolutions": { 60 | "bitcore-lib": "8.1.1", 61 | "ethers": "^5.1.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/contracts.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "@ethersproject/contracts"; 2 | import { getCompatibilityFallbackHandlerDeployment, getMultiSendDeployment, getProxyFactoryDeployment, getSafeSingletonDeployment, getSafeL2SingletonDeployment, SingletonDeployment, getMultiSendCallOnlyDeployment } from "@gnosis.pm/safe-deployments"; 3 | import { HardhatRuntimeEnvironment as HRE } from "hardhat/types"; 4 | 5 | export const contractFactory = (hre: HRE, contractName: string) => hre.ethers.getContractFactory(contractName); 6 | 7 | export const contractInstance = async (hre: HRE, deployment: SingletonDeployment | undefined, address?: string): Promise => { 8 | if (!deployment) throw Error("No deployment provided") 9 | // TODO: use network 10 | const contractAddress = address || deployment.defaultAddress 11 | return await hre.ethers.getContractAt(deployment.abi, contractAddress) 12 | } 13 | 14 | export const safeSingleton = async (hre: HRE, address?: string) => 15 | contractInstance(hre, getSafeSingletonDeployment({ released: undefined }), address) 16 | 17 | export const safeL2Singleton = async (hre: HRE, address?: string) => 18 | contractInstance(hre, getSafeL2SingletonDeployment({ released: undefined }), address) 19 | 20 | export const proxyFactory = async (hre: HRE, address?: string) => 21 | contractInstance(hre, getProxyFactoryDeployment(), address) 22 | 23 | export const multiSendLib = async (hre: HRE, address?: string) => 24 | contractInstance(hre, getMultiSendDeployment(), address) 25 | 26 | export const multiSendCallOnlyLib = async (hre: HRE, address?: string) => 27 | contractInstance(hre, getMultiSendCallOnlyDeployment(), address) 28 | 29 | export const compatHandler = async (hre: HRE, address?: string) => 30 | contractInstance(hre, getCompatibilityFallbackHandlerDeployment(), address) 31 | -------------------------------------------------------------------------------- /src/creation.ts: -------------------------------------------------------------------------------- 1 | import { task, types } from "hardhat/config"; 2 | import { AddressZero } from "@ethersproject/constants"; 3 | import { getAddress } from "@ethersproject/address"; 4 | import { buildMultiSendSafeTx, calculateProxyAddress, encodeMultiSend, MetaTransaction } from "@gnosis.pm/safe-contracts"; 5 | import { safeSingleton, proxyFactory, safeL2Singleton, multiSendCallOnlyLib, compatHandler } from "./contracts"; 6 | import { readCsv, writeJson, writeTxBuilderJson } from "./execution/utils"; 7 | 8 | const parseSigners = (rawSigners: string): string[] => { 9 | return rawSigners.split(",").map(address => getAddress(address)) 10 | } 11 | 12 | task("create", "Create a Safe") 13 | .addFlag("l2", "Should use version of the Safe contract that is more event heave") 14 | .addFlag("buildOnly", "Indicate whether this transaction should only be logged and not submitted on-chain") 15 | .addParam("signers", "Comma separated list of signer addresses (dafault is the address of linked account)", "", types.string, true) 16 | .addParam("threshold", "Threshold that should be used", 1, types.int, true) 17 | .addParam("fallback", "Fallback handler address", AddressZero, types.string, true) 18 | .addParam("nonce", "Nonce used with factory", new Date().getTime(), types.int, true) 19 | .addParam("singleton", "Set to overwrite which singleton address to use", "", types.string, true) 20 | .addParam("factory", "Set to overwrite which factory address to use", "", types.string, true) 21 | .setAction(async (taskArgs, hre) => { 22 | const singleton = taskArgs.l2 ? await safeL2Singleton(hre, taskArgs.singleton) : await safeSingleton(hre, taskArgs.singleton) 23 | const factory = await proxyFactory(hre, taskArgs.factory) 24 | const signers: string[] = taskArgs.signers ? parseSigners(taskArgs.signers) : [(await hre.getNamedAccounts()).deployer] 25 | const fallbackHandler = getAddress(taskArgs.fallback) 26 | const setupData = singleton.interface.encodeFunctionData( 27 | "setup", 28 | [signers, taskArgs.threshold, AddressZero, "0x", fallbackHandler, AddressZero, 0, AddressZero] 29 | ) 30 | const predictedAddress = await calculateProxyAddress(factory, singleton.address, setupData, taskArgs.nonce) 31 | console.log(`Deploy Safe to ${predictedAddress}`) 32 | console.log(`Singleton: ${singleton.address}`) 33 | console.log(`Setup data: ${setupData}`) 34 | console.log(`Nonce: ${taskArgs.nonce}`) 35 | console.log(`To (factory): ${factory.address}`) 36 | console.log(`Data: ${factory.interface.encodeFunctionData("createProxyWithNonce", [singleton.address, setupData, taskArgs.nonce])}`) 37 | if (!taskArgs.buildOnly) 38 | await factory.createProxyWithNonce(singleton.address, setupData, taskArgs.nonce).then((tx: any) => tx.wait()) 39 | // TODO verify deployment 40 | }); 41 | 42 | task("create-bulk", "Create multiple Safes from CSV") 43 | .addFlag("buildOnly", "Indicate whether this transaction should only be logged and not submitted on-chain") 44 | .addPositionalParam("csv", "CSV file with the information of the Safes that should be created", undefined, types.inputFile) 45 | .addParam("fallback", "Fallback handler address", undefined, types.string, true) 46 | .addParam("nonce", "Nonce used with factory", "0", types.string, true) 47 | .addParam("singleton", "Set to overwrite which singleton address to use", "", types.string, true) 48 | .addParam("factory", "Set to overwrite which factory address to use", "", types.string, true) 49 | .addFlag("l2", "Should use version of the Safe contract that is more event heave") 50 | .addParam("export", "If specified instead of executing the data will be exported as a json file for the transaction builder", undefined, types.string, true) 51 | .setAction(async (taskArgs, hre) => { 52 | const singleton = taskArgs.l2 ? await safeL2Singleton(hre, taskArgs.singleton) : await safeSingleton(hre, taskArgs.singleton) 53 | const factory = await proxyFactory(hre, taskArgs.factory) 54 | const fallbackHandler = await compatHandler(hre, taskArgs.fallback) 55 | 56 | const inputs: { threshold: string, signers: string, address: string }[] = await readCsv(taskArgs.csv) 57 | const encodedSetups: { data: string, signers: string[], threshold: string, expectedAddress?: string }[] = inputs.filter(entry => entry.signers.trim().length !== 0).map(entry => { 58 | const parsedThreshold = entry.threshold.split("/")[0] 59 | const expectedSignerCount = entry.threshold.split("/")[1] 60 | const parsedSigners = entry.signers.replace(/\n/g, ",").split(",") 61 | console.log({ parsedThreshold, expectedSignerCount, parsedSigners }) 62 | if (expectedSignerCount && parseInt(expectedSignerCount) !== parsedSigners.length) throw Error(`Expected ${expectedSignerCount} Signers, got ${parsedSigners}`) 63 | return { 64 | data: singleton.interface.encodeFunctionData( 65 | "setup", 66 | [parsedSigners, parsedThreshold, AddressZero, "0x", fallbackHandler.address, AddressZero, 0, AddressZero] 67 | ), 68 | signers: parsedSigners, 69 | threshold: parsedThreshold, 70 | expectedAddress: entry.address 71 | } 72 | }) 73 | const deploymentTxs: MetaTransaction[] = encodedSetups.map(setup => { 74 | const data = factory.interface.encodeFunctionData("createProxyWithNonce", [singleton.address, setup.data, taskArgs.nonce]) 75 | return { to: factory.address, data, operation: 0, value: "0" } 76 | }) 77 | const multiSend = await multiSendCallOnlyLib(hre) 78 | console.log(`Singleton: ${singleton.address}`) 79 | console.log(`Nonce: ${taskArgs.nonce}`) 80 | console.log(`Factory: ${factory.address}`) 81 | console.log(`Data: ${multiSend.interface.encodeFunctionData("multiSend", [encodeMultiSend(deploymentTxs)])}`) 82 | console.log(`Predicted Safes:`) 83 | for (const setupData of encodedSetups) { 84 | console.log(` Signers:`, setupData.signers.join(","), `Treshold:`, setupData.threshold, "of", setupData.signers.length) 85 | const calculatedAddress = await calculateProxyAddress(factory, singleton.address, setupData.data, taskArgs.nonce) 86 | console.log(` Address:`, calculatedAddress) 87 | if (setupData.expectedAddress && setupData.expectedAddress !== calculatedAddress) 88 | console.log(` Unexpected Safe address! Expected ${setupData.expectedAddress}.`) 89 | const onChainCode = await hre.ethers.provider.getCode(calculatedAddress) 90 | if (onChainCode !== "0x") 91 | console.log(` Safe already exists on this address.`) 92 | console.log() 93 | } 94 | 95 | if (taskArgs.export) { 96 | const chainId = (await hre.ethers.provider.getNetwork()).chainId.toString() 97 | await writeTxBuilderJson(taskArgs.export, chainId, deploymentTxs, "Batched Safe Creations") 98 | } else if (!taskArgs.buildOnly) { 99 | await multiSend.multiSend(encodeMultiSend(deploymentTxs)).then((tx: any) => tx.wait()) 100 | } 101 | // TODO verify deployment 102 | }); 103 | 104 | export { } 105 | -------------------------------------------------------------------------------- /src/execution/index.ts: -------------------------------------------------------------------------------- 1 | import "./signing" 2 | import "./simple" 3 | import "./submitting" 4 | import "./proposing" -------------------------------------------------------------------------------- /src/execution/proposing.ts: -------------------------------------------------------------------------------- 1 | import { task, types } from "hardhat/config"; 2 | import { multiSendLib, safeSingleton } from "../contracts"; 3 | import { buildMultiSendSafeTx, buildSafeTransaction, calculateSafeTransactionHash, SafeTransaction, MetaTransaction } from "@gnosis.pm/safe-contracts"; 4 | import { parseEther } from "@ethersproject/units"; 5 | import { getAddress, isHexString } from "ethers/lib/utils"; 6 | import { proposalFile, readFromCliCache, writeToCliCache, writeTxBuilderJson } from "./utils"; 7 | import { BigNumber } from "@ethersproject/bignumber"; 8 | import { Contract, ethers } from "ethers"; 9 | import fs from 'fs/promises' 10 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 11 | 12 | export interface SafeTxProposal { 13 | safe: string, 14 | chainId: number, 15 | safeTxHash: string, 16 | tx: SafeTransaction 17 | } 18 | 19 | const calcSafeTxHash = async (safe: Contract, tx: SafeTransaction, chainId: number, onChainOnly: boolean): Promise => { 20 | const onChainHash = await safe.getTransactionHash( 21 | tx.to, tx.value, tx.data, tx.operation, tx.safeTxGas, tx.baseGas, tx.gasPrice, tx.gasToken, tx.refundReceiver, tx.nonce 22 | ) 23 | if (onChainOnly) return onChainHash 24 | const offChainHash = calculateSafeTransactionHash(safe, tx, chainId) 25 | if (onChainHash != offChainHash) throw Error("Unexpected hash! (For pre-1.3.0 version use --on-chain-hash)") 26 | return offChainHash 27 | } 28 | 29 | task("propose", "Create a Safe tx proposal json file") 30 | .addPositionalParam("address", "Address or ENS name of the Safe to check", undefined, types.string) 31 | .addParam("to", "Address of the target", undefined, types.string) 32 | .addParam("value", "Value in ETH", "0", types.string, true) 33 | .addParam("data", "Data as hex string", "0x", types.string, true) 34 | .addFlag("delegatecall", "Indicator if tx should be executed as a delegatecall") 35 | .addFlag("onChainHash", "Get hash from chain (required for pre-1.3.0 version)") 36 | .setAction(async (taskArgs, hre) => { 37 | console.log(`Running on ${hre.network.name}`) 38 | const safe = await safeSingleton(hre, taskArgs.address) 39 | const safeAddress = await safe.resolvedAddress 40 | console.log(`Using Safe at ${safeAddress}`) 41 | const nonce = await safe.nonce() 42 | if (!isHexString(taskArgs.data)) throw Error(`Invalid hex string provided for data: ${taskArgs.data}`) 43 | const tx = buildSafeTransaction({ to: taskArgs.to, value: parseEther(taskArgs.value).toString(), data: taskArgs.data, nonce: nonce.toString(), operation: taskArgs.delegatecall ? 1 : 0 }) 44 | const chainId = (await safe.provider.getNetwork()).chainId 45 | const safeTxHash = await calcSafeTxHash(safe, tx, chainId, taskArgs.onChainHash) 46 | const proposal: SafeTxProposal = { 47 | safe: safeAddress, 48 | chainId, 49 | safeTxHash, 50 | tx 51 | } 52 | await writeToCliCache(proposalFile(safeTxHash), proposal) 53 | console.log(`Safe transaction hash: ${safeTxHash}`) 54 | }); 55 | 56 | interface TxDescription { 57 | to: string, 58 | value: string // in ETH 59 | data?: string 60 | method?: string 61 | params?: any[] 62 | operation: 0 | 1 63 | } 64 | 65 | const buildData = (method: string, params?: any[]): string => { 66 | const iface = new ethers.utils.Interface([`function ${method}`]) 67 | return iface.encodeFunctionData(method, params) 68 | } 69 | 70 | const buildMetaTx = (description: TxDescription): MetaTransaction => { 71 | const to = getAddress(description.to) 72 | const value = parseEther(description.value).toString() 73 | const operation = description.operation 74 | const data = isHexString(description.data) ? description.data!! : (description.method ? buildData(description.method, description.params) : "0x") 75 | return { to, value, data, operation } 76 | } 77 | 78 | const loadMetaTransactions = async (file: string) => { 79 | const txsData: TxDescription[] = JSON.parse(await fs.readFile(file, 'utf8')) 80 | if (txsData.length == 0) { 81 | throw Error("No transacitons provided") 82 | } 83 | return txsData.map(desc => buildMetaTx(desc)) 84 | } 85 | 86 | const parseMultiSendJsonFile = async (hre: HardhatRuntimeEnvironment, txs: MetaTransaction[], nonce: number, multiSendAddress?: string): Promise => { 87 | if (txs.length == 1) { 88 | return buildSafeTransaction({ ...txs[0], nonce: nonce }) 89 | } 90 | const multiSend = await multiSendLib(hre, multiSendAddress) 91 | return buildMultiSendSafeTx(multiSend, txs, nonce) 92 | } 93 | 94 | task("propose-multi", "Create a Safe tx proposal json file") 95 | .addPositionalParam("address", "Address or ENS name of the Safe to check", undefined, types.string) 96 | .addPositionalParam("txs", "Json file with transactions", undefined, types.inputFile) 97 | .addParam("multiSend", "Set to overwrite which multiSend address to use", "", types.string, true) 98 | .addParam("nonce", "Set nonce to use (will default to on-chain nonce)", "", types.string, true) 99 | .addParam("export", "If specified instead of executing the data will be exported as a json file for the transaction builder", undefined, types.string) 100 | .addParam("name", "Name to be used for the transaction builder json", undefined, types.string, true) 101 | .addFlag("onChainHash", "Get hash from chain (required for pre-1.3.0 version)") 102 | .setAction(async (taskArgs, hre) => { 103 | console.log(`Running on ${hre.network.name}`) 104 | const safe = await safeSingleton(hre, taskArgs.address) 105 | const safeAddress = await safe.resolvedAddress 106 | console.log(`Using Safe at ${safeAddress}`) 107 | const nonce = taskArgs.nonce || await safe.nonce() 108 | const txs = await loadMetaTransactions(taskArgs.txs) 109 | const chainId = (await safe.provider.getNetwork()).chainId 110 | if (taskArgs.export) { 111 | await writeTxBuilderJson(taskArgs.export, chainId.toString(), txs, taskArgs.name || "Custom Transactions") 112 | return 113 | } 114 | const tx = await parseMultiSendJsonFile(hre, txs, BigNumber.from(nonce).toNumber(), taskArgs.multiSend) 115 | console.log("Safe transaction", tx) 116 | const safeTxHash = await calcSafeTxHash(safe, tx, chainId, taskArgs.onChainHash) 117 | const proposal: SafeTxProposal = { 118 | safe: safeAddress, 119 | chainId, 120 | safeTxHash, 121 | tx 122 | } 123 | await writeToCliCache(proposalFile(safeTxHash), proposal) 124 | console.log("Safe transaction hash:", safeTxHash) 125 | return safeTxHash 126 | }); 127 | 128 | task("show-proposal", "Shows details for a Safe transaction") 129 | .addPositionalParam("hash", "Hash of Safe transaction to display", undefined, types.string) 130 | .setAction(async (taskArgs, hre) => { 131 | const proposal: SafeTxProposal = await readFromCliCache(proposalFile(taskArgs.hash)) 132 | const safe = await safeSingleton(hre, taskArgs.address) 133 | const safeAddress = await safe.resolvedAddress 134 | console.log(`Using Safe at ${safeAddress}@${proposal.chainId}`) 135 | const nonce = await safe.nonce() 136 | if (BigNumber.from(proposal.tx.nonce).lt(nonce)) { 137 | console.log(`!Nonce has already been used!`) 138 | } 139 | console.log("Details") 140 | console.log(proposal.tx) 141 | }); 142 | -------------------------------------------------------------------------------- /src/execution/signing.ts: -------------------------------------------------------------------------------- 1 | import { task, types } from "hardhat/config"; 2 | import { safeSingleton } from "../contracts"; 3 | import { buildSafeTransaction, SafeSignature, safeSignMessage, signHash } from "@gnosis.pm/safe-contracts"; 4 | import { parseEther } from "@ethersproject/units"; 5 | import { isHexString } from "ethers/lib/utils"; 6 | import { SafeTxProposal } from "./proposing"; 7 | import { proposalFile, signaturesFile, readFromCliCache, writeToCliCache, loadSignatures } from "./utils"; 8 | 9 | task("sign-tx", "Signs a Safe transaction") 10 | .addPositionalParam("address", "Address or ENS name of the Safe to check", undefined, types.string) 11 | .addParam("to", "Address of the target", undefined, types.string) 12 | .addParam("value", "Value in ETH", "0", types.string, true) 13 | .addParam("data", "Data as hex string", "0x", types.string, true) 14 | .addParam("signerIndex", "Index of the signer to use", 0, types.int, true) 15 | .addFlag("delegatecall", "Indicator if tx should be executed as a delegatecall") 16 | .setAction(async (taskArgs, hre) => { 17 | console.log(`Running on ${hre.network.name}`) 18 | const signers = await hre.ethers.getSigners() 19 | const signer = signers[taskArgs.signerIndex] 20 | const safe = await safeSingleton(hre, taskArgs.address) 21 | const safeAddress = await safe.resolvedAddress 22 | console.log(`Using Safe at ${safeAddress} with ${signer.address}`) 23 | const nonce = await safe.nonce() 24 | if (!isHexString(taskArgs.data)) throw Error(`Invalid hex string provided for data: ${taskArgs.data}`) 25 | const tx = buildSafeTransaction({ to: taskArgs.to, value: parseEther(taskArgs.value), data: taskArgs.data, nonce, operation: taskArgs.delegatecall ? 1 : 0 }) 26 | const signature = await safeSignMessage(signer, safe, tx) 27 | console.log(`Signature: ${signature.data}`) 28 | }); 29 | 30 | const updateSignatureFile = async(safeTxHash: string, signature: SafeSignature) => { 31 | const signatures: Record = await loadSignatures(safeTxHash) 32 | signatures[signature.signer] = signature.data 33 | await writeToCliCache(signaturesFile(safeTxHash), signatures) 34 | } 35 | 36 | task("sign-proposal", "Signs a Safe transaction") 37 | .addPositionalParam("hash", "Hash of Safe transaction to display", undefined, types.string) 38 | .addParam("signerIndex", "Index of the signer to use", 0, types.int, true) 39 | .setAction(async (taskArgs, hre) => { 40 | const proposal: SafeTxProposal = await readFromCliCache(proposalFile(taskArgs.hash)) 41 | const signers = await hre.ethers.getSigners() 42 | const signer = signers[taskArgs.signerIndex] 43 | const safe = await safeSingleton(hre, proposal.safe) 44 | const safeAddress = await safe.resolvedAddress 45 | console.log(`Using Safe at ${safeAddress} with ${signer.address}`) 46 | const owners: string[] = await safe.getOwners() 47 | if (owners.indexOf(signer.address) < 0) { 48 | throw Error(`Signer is not an owner of the Safe. Owners: ${owners}`) 49 | } 50 | const signature = await signHash(signer, taskArgs.hash) 51 | await updateSignatureFile(taskArgs.hash, signature) 52 | console.log(`Signature: ${signature.data}`) 53 | }); -------------------------------------------------------------------------------- /src/execution/simple.ts: -------------------------------------------------------------------------------- 1 | import { task, types } from "hardhat/config"; 2 | 3 | task("submit-multi", "Executes a Safe transaction from a file") 4 | .addPositionalParam("address", "Address or ENS name of the Safe to check", undefined, types.string) 5 | .addPositionalParam("txs", "Json file with transactions", undefined, types.inputFile) 6 | .addFlag("onChainHash", "Get hash from chain (required for pre-1.3.0 version)") 7 | .addParam("multiSend", "Set to overwrite which multiSend address to use", "", types.string, true) 8 | .addParam("signerIndex", "Index of the signer to use", 0, types.int, true) 9 | .addParam("signatures", "Comma seperated list of signatures", undefined, types.string, true) 10 | .addParam("gasPrice", "Gas price to be used", undefined, types.int, true) 11 | .addParam("gasLimit", "Gas limit to be used", undefined, types.int, true) 12 | .addFlag("buildOnly", "Flag to only output the final transaction") 13 | .setAction(async (taskArgs, hre) => { 14 | const safeTxHash = await hre.run("propose-multi", { 15 | address: taskArgs.address, 16 | txs: taskArgs.txs, 17 | onChainHash: taskArgs.onChainHash, 18 | multiSend: taskArgs.multiSend 19 | }) 20 | await hre.run("submit-proposal", { 21 | hash: safeTxHash, 22 | onChainHash: taskArgs.onChainHash, 23 | signerIndex: taskArgs.signerIndex, 24 | signatures: taskArgs.signatures, 25 | gasLimit: taskArgs.gasLimit, 26 | buildOnly: taskArgs.buildOnly, 27 | }) 28 | }); 29 | -------------------------------------------------------------------------------- /src/execution/submitting.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, Contract, PopulatedTransaction, Signer, utils } from "ethers"; 2 | import { task, types } from "hardhat/config"; 3 | import { safeSingleton } from "../contracts"; 4 | import { buildSafeTransaction, calculateSafeTransactionHash, populateExecuteTx, safeApproveHash, SafeSignature, SafeTransaction } from "@gnosis.pm/safe-contracts"; 5 | import { parseEther } from "@ethersproject/units"; 6 | import { getAddress } from "@ethersproject/address"; 7 | import { isHexString } from "ethers/lib/utils"; 8 | import { SafeTxProposal } from "./proposing"; 9 | import { loadSignatures, proposalFile, readFromCliCache } from "./utils"; 10 | 11 | const parsePreApprovedConfirmation = (data: string): SafeSignature => { 12 | const signer = getAddress("0x" + data.slice(26, 66)) 13 | return { 14 | signer, data 15 | } 16 | } 17 | 18 | const parseTypeDataConfirmation = (safeTxHash: string, data: string): SafeSignature => { 19 | const signer = utils.recoverAddress(safeTxHash, data) 20 | return { 21 | signer, data 22 | } 23 | } 24 | 25 | const parseEthSignConfirmation = (safeTxHash: string, data: string): SafeSignature => { 26 | const signer = utils.recoverAddress(utils.hashMessage(utils.arrayify(safeTxHash)), data.replace(/1f$/, "1b").replace(/20$/, "1c")) 27 | return { 28 | signer, data 29 | } 30 | } 31 | 32 | const parseSignature = (safeTxHash: string, signature: string): SafeSignature => { 33 | if (!isHexString(signature, 65)) throw Error(`Unsupported signature: ${signature}`) 34 | const type = parseInt(signature.slice(signature.length - 2), 16) 35 | switch (type) { 36 | case 1: return parsePreApprovedConfirmation(signature) 37 | case 27: 38 | case 28: 39 | return parseTypeDataConfirmation(safeTxHash, signature) 40 | case 31: 41 | case 32: 42 | return parseEthSignConfirmation(safeTxHash, signature) 43 | case 0: 44 | default: 45 | throw Error(`Unsupported type ${type} in ${signature}`) 46 | } 47 | } 48 | 49 | const isOwnerSignature = (owners: string[], signature: SafeSignature): SafeSignature => { 50 | if (owners.indexOf(signature.signer) < 0) throw Error(`Signer ${signature.signer} not found in owners ${owners}`) 51 | return signature 52 | } 53 | 54 | const prepareSignatures = async (safe: Contract, tx: SafeTransaction, signaturesCSV: string | undefined, submitter?: Signer, knownSafeTxHash?: string): Promise => { 55 | const owners = await safe.getOwners() 56 | const signatures = new Map() 57 | const submitterAddress = submitter && await submitter.getAddress() 58 | if (signaturesCSV) { 59 | const chainId = (await safe.provider.getNetwork()).chainId 60 | const safeTxHash = knownSafeTxHash ?? calculateSafeTransactionHash(safe, tx, chainId) 61 | for (const signatureString of signaturesCSV.split(",")) { 62 | const signature = isOwnerSignature(owners, parseSignature(safeTxHash, signatureString)) 63 | if (submitterAddress === signature.signer || signatures.has(signature.signer)) continue 64 | signatures.set(signature.signer, signature) 65 | } 66 | } 67 | const threshold = (await safe.getThreshold()).toNumber() 68 | const submitterIsOwner = submitterAddress && owners.indexOf(submitterAddress) >= 0 69 | const requiredSigntures = submitterIsOwner ? threshold - 1 : threshold 70 | if (requiredSigntures > signatures.size) throw Error(`Not enough signatures (${signatures.size} of ${threshold})`) 71 | const signatureArray = [] 72 | if (submitterIsOwner) { 73 | signatureArray.push(await safeApproveHash(submitter!!, safe, tx, true)) 74 | } 75 | return signatureArray.concat(Array.from(signatures.values()).slice(0, requiredSigntures)) 76 | } 77 | 78 | task("submit-tx", "Executes a Safe transaction") 79 | .addPositionalParam("address", "Address or ENS name of the Safe to check", undefined, types.string) 80 | .addParam("to", "Address of the target", undefined, types.string) 81 | .addParam("value", "Value in ETH", "0", types.string, true) 82 | .addParam("data", "Data as hex string", "0x", types.string, true) 83 | .addParam("signatures", "Comma seperated list of signatures", undefined, types.string, true) 84 | .addParam("gasPrice", "Gas price to be used", undefined, types.int, true) 85 | .addParam("gasLimit", "Gas limit to be used", undefined, types.int, true) 86 | .addFlag("delegatecall", "Indicator if tx should be executed as a delegatecall") 87 | .setAction(async (taskArgs, hre) => { 88 | console.log(`Running on ${hre.network.name}`) 89 | const [signer] = await hre.ethers.getSigners() 90 | const safe = await safeSingleton(hre, taskArgs.address) 91 | const safeAddress = await safe.resolvedAddress 92 | console.log(`Using Safe at ${safeAddress} with ${signer.address}`) 93 | const nonce = await safe.nonce() 94 | if (!isHexString(taskArgs.data)) throw Error(`Invalid hex string provided for data: ${taskArgs.data}`) 95 | const tx = buildSafeTransaction({ 96 | to: taskArgs.to, 97 | value: parseEther(taskArgs.value), 98 | data: taskArgs.data, 99 | nonce, 100 | operation: taskArgs.delegatecall ? 1 : 0 101 | }) 102 | const signatures = await prepareSignatures(safe, tx, taskArgs.signatures, signer) 103 | const populatedTx: PopulatedTransaction = await populateExecuteTx(safe, tx, signatures, { gasLimit: taskArgs.gasLimit, gasPrice: taskArgs.gasPrice }) 104 | const receipt = await signer.sendTransaction(populatedTx).then(tx => tx.wait()) 105 | console.log(receipt.transactionHash) 106 | }); 107 | 108 | 109 | task("submit-proposal", "Executes a Safe transaction") 110 | .addPositionalParam("hash", "Hash of Safe transaction to display", undefined, types.string) 111 | .addParam("signerIndex", "Index of the signer to use", 0, types.int, true) 112 | .addParam("signatures", "Comma seperated list of signatures", undefined, types.string, true) 113 | .addParam("gasPrice", "Gas price to be used", undefined, types.int, true) 114 | .addParam("gasLimit", "Gas limit to be used", undefined, types.int, true) 115 | .addFlag("buildOnly", "Flag to only output the final transaction") 116 | .setAction(async (taskArgs, hre) => { 117 | console.log(`Running on ${hre.network.name}`) 118 | const proposal: SafeTxProposal = await readFromCliCache(proposalFile(taskArgs.hash)) 119 | const signers = await hre.ethers.getSigners() 120 | const signer = signers[taskArgs.signerIndex] 121 | const safe = await safeSingleton(hre, proposal.safe) 122 | const safeAddress = await safe.resolvedAddress 123 | console.log(`Using Safe at ${safeAddress} with ${signer.address}`) 124 | const currentNonce = await safe.nonce() 125 | if (!BigNumber.from(proposal.tx.nonce).eq(currentNonce)) { 126 | throw Error("Proposal does not have correct nonce!") 127 | } 128 | const signatureStrings: Record = await loadSignatures(taskArgs.hash) 129 | const signatureArray = Object.values(signatureStrings) 130 | if (taskArgs.signatures) { 131 | signatureArray.push(taskArgs.signatures) 132 | } 133 | const signatures = await prepareSignatures(safe, proposal.tx, signatureArray.join(","), signer, taskArgs.hash) 134 | const populatedTx: PopulatedTransaction = await populateExecuteTx(safe, proposal.tx, signatures, { gasLimit: taskArgs.gasLimit, gasPrice: taskArgs.gasPrice }) 135 | 136 | if (taskArgs.buildOnly) { 137 | console.log("Ethereum transaction:", populatedTx) 138 | return 139 | } 140 | 141 | const receipt = await signer.sendTransaction(populatedTx).then(tx => tx.wait()) 142 | console.log("Ethereum transaction hash:", receipt.transactionHash) 143 | return receipt.transactionHash 144 | }); -------------------------------------------------------------------------------- /src/execution/utils.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs/promises' 3 | import fsSync from 'fs' 4 | import csvParser from "csv-parser" 5 | import { MetaTransaction } from '@gnosis.pm/safe-contracts' 6 | 7 | const cliCacheDir = "cli_cache" 8 | 9 | export const proposalFile = (safeTxHash: string) => `${safeTxHash}.proposal.json` 10 | export const signaturesFile = (safeTxHash: string) => `${safeTxHash}.signatures.json` 11 | 12 | export const writeToCliCache = async(key: string, content: any) => { 13 | const folder = path.join(process.cwd(), cliCacheDir) 14 | try { 15 | await fs.access(folder) 16 | } catch (e) { 17 | await fs.mkdir(folder); 18 | } 19 | await fs.writeFile(path.join(folder, key), JSON.stringify(content, null, 2)) 20 | } 21 | 22 | export const writeJson = async(file: string, content: any) => { 23 | await fs.writeFile(file, JSON.stringify(content, null, 2)) 24 | } 25 | 26 | export const writeTxBuilderJson = async(file: string, chainId: string, transactions: MetaTransaction[], name?: string, description?: string) => { 27 | return writeJson(file, { 28 | version: "1.0", 29 | chainId, 30 | createdAt: new Date().getTime(), 31 | meta: { 32 | name, 33 | description 34 | }, 35 | transactions 36 | }) 37 | } 38 | 39 | export const readFromCliCache = async(key: string): Promise => { 40 | const content = await fs.readFile(path.join(process.cwd(), cliCacheDir, key), 'utf8') 41 | return JSON.parse(content) 42 | } 43 | 44 | export const loadSignatures = async(safeTxHash: string): Promise> => { 45 | try { 46 | return await readFromCliCache(signaturesFile(safeTxHash)) 47 | } catch { 48 | return {} 49 | } 50 | } 51 | 52 | export const readCsv = async(file: string): Promise => new Promise((resolve, reject) => { 53 | const results: T[] = []; 54 | fsSync.createReadStream(file).pipe(csvParser()) 55 | .on("data", (data) => results.push(data)) 56 | .on("error", (err) => { reject(err) }) 57 | .on("end", () => { resolve(results)}) 58 | }) -------------------------------------------------------------------------------- /src/history/index.ts: -------------------------------------------------------------------------------- 1 | import { task, types } from "hardhat/config"; 2 | import { safeSingleton } from "../contracts"; 3 | import { loadHistoryTxs } from "./loading"; 4 | 5 | task("history", `WIP: Displays the transaction history of a Safe based on events (ordered newest first). 6 | Only outgoing transactions made with a Safe >=1.1.0 will be displayed.`) 7 | .addPositionalParam("address", "Address or ENS name of the Safe to check", undefined, types.string) 8 | .addParam("start", "Start index of the tx to load", 0, types.int, true) 9 | .setAction(async (taskArgs, hre) => { 10 | const safe = await safeSingleton(hre, taskArgs.address) 11 | const safeAddress = await safe.resolvedAddress 12 | console.log(`Checking Safe at ${safeAddress}`) 13 | console.log(await loadHistoryTxs(hre.ethers.provider, safeAddress, taskArgs.start)) 14 | }); -------------------------------------------------------------------------------- /src/history/loading.ts: -------------------------------------------------------------------------------- 1 | import { calculateSafeTransactionHash, EIP712_SAFE_TX_TYPE } from '@gnosis.pm/safe-contracts' 2 | import { getSafeL2SingletonDeployment } from '@gnosis.pm/safe-deployments' 3 | import { BigNumber, ethers } from 'ethers' 4 | import { EtherDetails, EventTx, ModuleTx, MultisigTx, MultisigUnknownTx, TransferDetails, TransferTx } from './types' 5 | 6 | const erc20InterfaceDefinition = [ 7 | "event Transfer(address indexed from, address indexed to, uint256 amount)" 8 | ] 9 | const erc20OldInterfaceDefinition = [ 10 | "event Transfer(address indexed from, address to, uint256 amount)" 11 | ] 12 | const erc721InterfaceDefinition = [ 13 | "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)" 14 | ] 15 | const erc20Interface = new ethers.utils.Interface(erc20InterfaceDefinition) 16 | const erc20OldInterface = new ethers.utils.Interface(erc20OldInterfaceDefinition) 17 | const erc721Interface = new ethers.utils.Interface(erc721InterfaceDefinition) 18 | // The same for all interfaces as `indexed` has no impact on the topic 19 | const transferTopic = erc20Interface.getEventTopic("Transfer") 20 | 21 | const safeAbi = getSafeL2SingletonDeployment({ released: undefined })!!.abi 22 | const safeInterface = new ethers.utils.Interface(safeAbi) 23 | const successTopic = safeInterface.getEventTopic("ExecutionSuccess") 24 | const failureTopic = safeInterface.getEventTopic("ExecutionFailure") 25 | const multisigDetailsTopic = safeInterface.getEventTopic("SafeMultiSigTransaction") 26 | const moduleSuccessTopic = safeInterface.getEventTopic("ExecutionFromModuleSuccess") 27 | const moduleFailureTopic = safeInterface.getEventTopic("ExecutionFromModuleFailure") 28 | const moduleDetailsTopic = safeInterface.getEventTopic("SafeModuleTransaction") 29 | const etherReceivedTopic = safeInterface.getEventTopic("SafeReceived") 30 | // Failure topics cannot generate sub events, we should remove them in the future 31 | const parentTopics = [successTopic, moduleSuccessTopic, failureTopic, moduleFailureTopic] 32 | const detailsTopics = [multisigDetailsTopic, moduleDetailsTopic] 33 | 34 | const loadBlock = async (provider: ethers.providers.Provider, blockHash: string): Promise => { 35 | return await provider.getBlock(blockHash) 36 | } 37 | 38 | const loadEthTx = async (provider: ethers.providers.Provider, txHash: string): Promise => { 39 | return await provider.getTransaction(txHash) 40 | } 41 | 42 | export interface DecodedMultisigTx { 43 | to: string 44 | value: string 45 | data: string 46 | operation: number 47 | safeTxGas: string 48 | baseGas: string 49 | gasPrice: string 50 | gasToken: string 51 | refundReceiver: string 52 | signatures: string 53 | nonce?: number 54 | } 55 | 56 | const decodeTx = (account: string, tx: ethers.providers.TransactionResponse): DecodedMultisigTx | undefined => { 57 | try { 58 | const result = safeInterface.decodeFunctionData("execTransaction", tx.data) 59 | if (tx.to !== account) return undefined 60 | return { 61 | to: result.to, 62 | value: result.value.toString(), 63 | data: result.data, 64 | operation: result.operation, 65 | safeTxGas: result.safeTxGas.toString(), 66 | baseGas: result.baseGas.toString(), 67 | gasPrice: result.gasPrice.toString(), 68 | gasToken: result.gasToken, 69 | refundReceiver: result.refundReceiver, 70 | signatures: result.signatures, 71 | } 72 | } catch (e) { 73 | // TODO: try to decode other ways 74 | console.log("Unknown function", tx.data.slice(0, 10)) 75 | return undefined 76 | } 77 | } 78 | 79 | const decodeMultisigDetails = (log: ethers.providers.Log | undefined): DecodedMultisigTx | undefined => { 80 | if (!log) return undefined 81 | const event = safeInterface.decodeEventLog("SafeMultiSigTransaction", log.data, log.topics) 82 | return { 83 | to: event.to, 84 | value: event.value.toString(), 85 | data: event.data, 86 | operation: event.operation, 87 | safeTxGas: event.safeTxGas.toString(), 88 | baseGas: event.baseGas.toString(), 89 | gasPrice: event.gasPrice.toString(), 90 | gasToken: event.gasToken, 91 | refundReceiver: event.refundReceiver, 92 | signatures: event.signatures, 93 | nonce: BigNumber.from(event.additionalInfo.slice(0, 66)).toNumber() 94 | } 95 | } 96 | 97 | const mutlisigTxEntry = async (provider: ethers.providers.Provider, account: string, nonceMapper: NonceMapper, log: ethers.providers.Log, safeTxHash: string, success: boolean, subLogs: ethers.providers.Log[], details: ethers.providers.Log | undefined): Promise => { 98 | let decodedTx: DecodedMultisigTx | undefined 99 | decodedTx = decodeMultisigDetails(details) 100 | if (!decodedTx) { 101 | console.log("Fallback to transaction decoding") 102 | const ethTx = await loadEthTx(provider, log.transactionHash) 103 | decodedTx = decodeTx(account, ethTx) 104 | } 105 | const block = await loadBlock(provider, log.blockHash) 106 | if (!decodedTx) return { 107 | type: "MultisigUnknown", 108 | id: "multisig_" + safeTxHash, 109 | timestamp: block.timestamp, 110 | logs: subLogs, 111 | txHash: log.transactionHash, 112 | safeTxHash, 113 | success 114 | } 115 | return { 116 | type: "Multisig", 117 | id: "multisig_" + safeTxHash, 118 | timestamp: block.timestamp, 119 | logs: subLogs, 120 | txHash: log.transactionHash, 121 | safeTxHash, 122 | success, 123 | ...decodedTx, 124 | nonce: decodedTx.nonce || await nonceMapper.map(safeTxHash, decodedTx) 125 | } 126 | } 127 | 128 | const decodeModuleDetails = (log: ethers.providers.Log | undefined): any => { 129 | if (!log) return undefined 130 | const event = safeInterface.decodeEventLog("SafeModuleTransaction", log.data, log.topics) 131 | return event 132 | } 133 | 134 | const moduleTxEntry = async (provider: ethers.providers.Provider, log: ethers.providers.Log, moduleAddress: string, success: boolean, subLogs: ethers.providers.Log[], details: ethers.providers.Log | undefined): Promise => { 135 | console.log("Module details:", decodeModuleDetails(details)) 136 | const block = await loadBlock(provider, log.blockHash) 137 | return { 138 | type: "Module", 139 | txHash: log.transactionHash, 140 | id: "module_" + log.blockNumber + " " + log.transactionIndex + " " + log.logIndex, 141 | timestamp: block.timestamp, 142 | module: moduleAddress, 143 | success, 144 | logs: subLogs 145 | } 146 | } 147 | 148 | const transferEntry = async (provider: ethers.providers.Provider, account: string, log: ethers.providers.Log): Promise => { 149 | const block = await loadBlock(provider, log.blockHash) 150 | let type: string = "" 151 | let eventInterface 152 | if (log.topics.length === 4) { 153 | eventInterface = erc721Interface 154 | type = "ERC721" 155 | } else if (log.topics.length === 3) { 156 | eventInterface = erc20Interface 157 | type = "ERC20" 158 | } else if (log.topics.length === 2) { 159 | eventInterface = erc20OldInterface 160 | type = "ERC20" 161 | } else { 162 | return undefined 163 | } 164 | const event = eventInterface.decodeEventLog("Transfer", log.data, log.topics) 165 | let details: TransferDetails 166 | if (type === "ERC20") { 167 | details = { 168 | type: "ERC20", 169 | tokenAddress: log.address, 170 | value: event.amount.toString() 171 | } 172 | } else { 173 | details = { 174 | type: "ERC721", 175 | tokenAddress: log.address, 176 | tokenId: event.tokenId.toString() 177 | } 178 | } 179 | return { 180 | type: "Transfer", 181 | id: `transfer_${log.blockNumber}_${log.transactionIndex}_${log.logIndex}`, 182 | timestamp: block.timestamp, 183 | sender: event.from, 184 | receipient: event.to, 185 | direction: (event.to.toLowerCase() === account.toLowerCase() ? "INCOMING" : "OUTGOING"), 186 | details 187 | } 188 | } 189 | 190 | const incomingEthEntry = async (provider: ethers.providers.Provider, account: string, log: ethers.providers.Log): Promise => { 191 | const block = await loadBlock(provider, log.blockHash) 192 | const event = safeInterface.decodeEventLog("SafeReceived", log.data, log.topics) 193 | let details: EtherDetails = { 194 | type: "ETHER", 195 | value: event.value.toString() 196 | } 197 | return { 198 | type: "Transfer", 199 | id: `transfer_${log.blockNumber}_${log.transactionIndex}_${log.logIndex}`, 200 | timestamp: block.timestamp, 201 | sender: event.sender, 202 | receipient: account, 203 | direction: "INCOMING", 204 | details 205 | } 206 | } 207 | 208 | const mapLog = async (provider: ethers.providers.Provider, account: string, nonceMapper: NonceMapper, group: GroupedLogs): Promise => { 209 | const { parent, children, details } = group 210 | switch (parent.topics[0]) { 211 | case successTopic: { 212 | const event = safeInterface.decodeEventLog("ExecutionSuccess", parent.data, parent.topics) 213 | return await mutlisigTxEntry(provider, account, nonceMapper, parent, event.txHash, true, children, details) 214 | } 215 | case failureTopic: { 216 | const event = safeInterface.decodeEventLog("ExecutionFailure", parent.data, parent.topics) 217 | return await mutlisigTxEntry(provider, account, nonceMapper, parent, event.txHash, false, children, details) 218 | } 219 | case moduleSuccessTopic: { 220 | const event = safeInterface.decodeEventLog("ExecutionFromModuleSuccess", parent.data, parent.topics) 221 | return await moduleTxEntry(provider, parent, event.module, true, children, details) 222 | } 223 | case moduleFailureTopic: { 224 | const event = safeInterface.decodeEventLog("ExecutionFromModuleFailure", parent.data, parent.topics) 225 | return await moduleTxEntry(provider, parent, event.module, false, children, details) 226 | } 227 | case transferTopic: { 228 | if (children.length > 0) console.error("Sub logs for transfer entry!", parent, children) 229 | return await transferEntry(provider, account, parent) 230 | } 231 | case etherReceivedTopic: { 232 | if (children.length > 0) console.error("Sub logs for transfer entry!", parent, children) 233 | return await incomingEthEntry(provider, account, parent) 234 | } 235 | default: 236 | console.error("Received unknown event", parent) 237 | return undefined 238 | } 239 | } 240 | 241 | const loadOutgoingTransfer = (provider: ethers.providers.Provider, account: string): Promise => { 242 | const filter = { 243 | topics: [[transferTopic], [ethers.utils.defaultAbiCoder.encode(["address"], [account])]], 244 | fromBlock: "earliest" 245 | } 246 | return provider.getLogs(filter).then((e) => { 247 | console.log("OUT", e.length) 248 | return e 249 | }) 250 | } 251 | 252 | const loadIncomingTransfer = (provider: ethers.providers.Provider, account: string): Promise => { 253 | const filter = { 254 | topics: [[transferTopic], null as any, [ethers.utils.defaultAbiCoder.encode(["address"], [account])]], 255 | fromBlock: "earliest" 256 | } 257 | return provider.getLogs(filter).then((e) => { 258 | console.log("IN", e.length) 259 | return e 260 | }) 261 | } 262 | 263 | const loadIncomingEther = (provider: ethers.providers.Provider, account: string): Promise => { 264 | const filter = { 265 | topics: [[etherReceivedTopic]], 266 | address: account, 267 | fromBlock: "earliest" 268 | } 269 | return provider.getLogs(filter).then((e) => { 270 | console.log("ETH", e.length) 271 | return e 272 | }) 273 | } 274 | 275 | const loadSafeModuleTransactions = (provider: ethers.providers.Provider, account: string): Promise => { 276 | const filter = { 277 | topics: [[moduleSuccessTopic, moduleFailureTopic, moduleDetailsTopic]], 278 | address: account, 279 | fromBlock: "earliest" 280 | } 281 | return provider.getLogs(filter).then((e) => { 282 | console.log("MODULE", e.length) 283 | return e 284 | }) 285 | } 286 | 287 | const loadSafeMultisigTransactions = (provider: ethers.providers.Provider, account: string): Promise => { 288 | const filter = { 289 | topics: [[successTopic, failureTopic, multisigDetailsTopic]], 290 | address: account, 291 | fromBlock: "earliest" 292 | } 293 | 294 | return provider.getLogs(filter).then((e) => { 295 | console.log("MULTISIG", e.length) 296 | return e 297 | }) 298 | } 299 | 300 | const isOlder = (compare: ethers.providers.Log | undefined, base: ethers.providers.Log | undefined) => { 301 | if (compare === undefined) return false 302 | if (base === undefined) return true 303 | if (compare.blockNumber != base.blockNumber) return compare.blockNumber < base.blockNumber 304 | if (compare.transactionIndex != base.transactionIndex) return compare.transactionIndex < base.transactionIndex 305 | if (compare.logIndex != base.logIndex) return compare.logIndex < base.logIndex 306 | return false // Equal defaults to false 307 | } 308 | 309 | /// Oldest first 310 | const mergeLogs = async (...loaders: Promise[]): Promise => { 311 | const loaderCount = loaders.length 312 | if (loaderCount == 0) return [] 313 | 314 | const logResults = await Promise.all(loaders) 315 | if (loaderCount == 1) return logResults[0] 316 | const currentLogIndex: number[] = new Array(loaderCount).fill(0) 317 | for (var i = 0; i < loaderCount; i++) currentLogIndex[i] = 0; 318 | const out: ethers.providers.Log[] = [] 319 | var runs = 0 320 | // Panic check against endless loop (10k is max amount of events, per loader) 321 | while (runs < 10000 * loaderCount) { 322 | let resultIndex = 0 323 | let nextLog = logResults[0][currentLogIndex[0]] 324 | for (var i = 1; i < loaderCount; i++) { 325 | let candidate = logResults[i][currentLogIndex[i]] 326 | if (isOlder(candidate, nextLog)) { 327 | resultIndex = i 328 | nextLog = candidate 329 | } 330 | } 331 | currentLogIndex[resultIndex]++ 332 | if (nextLog) out.push(nextLog) 333 | else break 334 | runs++ 335 | } 336 | return out 337 | } 338 | 339 | interface GroupedLogs { 340 | parent: ethers.providers.Log, 341 | details?: ethers.providers.Log, // This is for L2 Safes, we expect the event order details -> children -> parent 342 | children: ethers.providers.Log[] 343 | } 344 | 345 | const groupIdFromLog = (log: ethers.providers.Log): string => `${log.blockNumber}_${log.transactionIndex}` 346 | 347 | const updateGroupedLogs = (groups: GroupedLogs[], detailsCandidate: ethers.providers.Log | undefined, parentCandidate: ethers.providers.Log | undefined, currentChildren: ethers.providers.Log[]) => { 348 | if (parentCandidate) { 349 | groups.push({ 350 | parent: parentCandidate, 351 | details: detailsCandidate, 352 | children: currentChildren 353 | }) 354 | } else if (currentChildren.length > 0) { 355 | groups.push(...currentChildren.map((log) => { return { parent: log, children: [] } })) 356 | } 357 | } 358 | 359 | const groupLogs = (logs: ethers.providers.Log[]): GroupedLogs[] => { 360 | const out: GroupedLogs[] = [] 361 | let currentChildren: ethers.providers.Log[] = [] 362 | let detailsCandidates: ethers.providers.Log[] = [] 363 | let parentCandidate: ethers.providers.Log | undefined 364 | let currentGroupId: string | undefined = undefined 365 | for (const log of logs) { 366 | const groupId = groupIdFromLog(log) 367 | const isParentCandidate = parentTopics.indexOf(log.topics[0]) >= 0 368 | const isDetailsCandidate = detailsTopics.indexOf(log.topics[0]) >= 0 369 | if (currentGroupId !== groupId || (isParentCandidate && parentCandidate)) { 370 | updateGroupedLogs(out, detailsCandidates.pop(), parentCandidate, currentChildren) 371 | parentCandidate = undefined 372 | detailsCandidates = [] 373 | currentChildren = [] 374 | currentGroupId = undefined 375 | } 376 | if (!currentGroupId) currentGroupId = groupId 377 | if (isParentCandidate) { 378 | parentCandidate = log 379 | } else if (isDetailsCandidate) { 380 | detailsCandidates.push(log) 381 | } else { 382 | currentChildren.push(log) 383 | } 384 | } 385 | updateGroupedLogs(out, detailsCandidates.pop(), parentCandidate, currentChildren) 386 | return out 387 | } 388 | 389 | class NonceMapper { 390 | 391 | safe: ethers.Contract 392 | lastNonce: number|undefined 393 | chainId: number|undefined 394 | 395 | constructor(provider: ethers.providers.Provider, account: string) { 396 | this.safe = new ethers.Contract(account, safeAbi, provider) 397 | } 398 | 399 | async init() { 400 | this.lastNonce = (await this.safe.nonce()).toNumber() 401 | this.chainId = (await this.safe.provider.getNetwork()).chainId 402 | } 403 | 404 | calculateHash111(tx: DecodedMultisigTx, nonce: number): string { 405 | return ethers.utils._TypedDataEncoder.hash({ verifyingContract: this.safe.address }, EIP712_SAFE_TX_TYPE, {...tx, nonce}) 406 | } 407 | 408 | async map(expectedHash: string, tx: DecodedMultisigTx): Promise { 409 | if (!this.lastNonce || !this.chainId) { 410 | await this.init() 411 | } 412 | for (let nonce = this.lastNonce!!; nonce >= 0; nonce--) { 413 | if (this.calculateHash111(tx, nonce) === expectedHash) return nonce 414 | if (calculateSafeTransactionHash(this.safe, {...tx, nonce}, this.chainId!!) === expectedHash) return nonce 415 | } 416 | return -1 417 | } 418 | } 419 | 420 | export const loadHistoryTxs = async (provider: ethers.providers.Provider, account: string, start: number): Promise => { 421 | const txLogs = await mergeLogs( 422 | loadSafeMultisigTransactions(provider, account), 423 | loadSafeModuleTransactions(provider, account), 424 | loadOutgoingTransfer(provider, account), 425 | loadIncomingTransfer(provider, account), 426 | loadIncomingEther(provider, account) 427 | ) 428 | const nonceMapper = new NonceMapper(provider, account) 429 | await nonceMapper.init() 430 | const groups = groupLogs(txLogs.reverse()) 431 | const inter = groups.slice(start, start + 5).map((group) => mapLog(provider, account, nonceMapper, group)) 432 | return (await Promise.all(inter)).filter((e) => e !== undefined) as EventTx[] 433 | } -------------------------------------------------------------------------------- /src/history/types.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers" 2 | 3 | export type EventTx = MultisigTx | ModuleTx | TransferTx | MultisigUnknownTx 4 | 5 | interface Base { 6 | id: string, 7 | timestamp: number, 8 | } 9 | 10 | export interface MultisigUnknownTx extends Base { 11 | type: 'MultisigUnknown' 12 | txHash: string 13 | safeTxHash: string 14 | success: boolean 15 | logs: ethers.providers.Log[] 16 | } 17 | 18 | export interface MultisigTx extends Base { 19 | type: 'Multisig' 20 | txHash: string 21 | safeTxHash: string 22 | success: boolean 23 | to: string 24 | value: string 25 | data: string 26 | operation: number 27 | safeTxGas: string 28 | baseGas: string 29 | gasPrice: string 30 | gasToken: string 31 | refundReceiver: string 32 | signatures: string 33 | nonce: number 34 | logs: ethers.providers.Log[] 35 | } 36 | 37 | export interface ModuleTx extends Base { 38 | type: 'Module' 39 | txHash: string 40 | module: string 41 | success: boolean 42 | logs: ethers.providers.Log[] 43 | } 44 | 45 | export interface TransferTx extends Base { 46 | type: 'Transfer' 47 | sender: string 48 | receipient: string 49 | direction: 'INCOMING' | 'OUTGOING' 50 | details: TransferDetails 51 | } 52 | 53 | export type TransferDetails = Erc20Details | Erc721Details | EtherDetails 54 | 55 | export interface Erc20Details { 56 | type: "ERC20", 57 | tokenAddress: string, 58 | value: string 59 | } 60 | 61 | export interface Erc721Details { 62 | type: "ERC721", 63 | tokenAddress: string, 64 | tokenId: string 65 | } 66 | 67 | export interface EtherDetails { 68 | type: "ETHER", 69 | value: string 70 | } -------------------------------------------------------------------------------- /src/information.ts: -------------------------------------------------------------------------------- 1 | import { task, types } from "hardhat/config"; 2 | import { HardhatRuntimeEnvironment as HRE } from "hardhat/types"; 3 | import { getAddress } from "@ethersproject/address"; 4 | import { AddressOne } from "@gnosis.pm/safe-contracts"; 5 | import { Contract } from "@ethersproject/contracts"; 6 | import { compatHandler, contractFactory, safeSingleton } from "./contracts"; 7 | 8 | export const getSingletonAddress = async (hre: HRE, address: string): Promise => { 9 | const result = await hre.ethers.provider.getStorageAt(address, 0) 10 | return getAddress("0x" + result.slice(26)) 11 | } 12 | 13 | export const getFallbackHandlerAddress = async (hre: HRE, address: string): Promise => { 14 | const result = await hre.ethers.provider.getStorageAt(address, "0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5") 15 | return getAddress("0x" + result.slice(26)) 16 | } 17 | 18 | const getModules = async (hre: HRE, safe: Contract): Promise => { 19 | try { 20 | return (await safe.getModulesPaginated(AddressOne, 10))[0] 21 | } catch (e) { 22 | } 23 | try { 24 | const compat = await compatHandler(hre, safe.address) 25 | return await compat.getModules() 26 | } catch (e) { 27 | } 28 | return ["Could not load modules"] 29 | } 30 | 31 | task("info", "Displays information about a Safe") 32 | .addPositionalParam("address", "Address or ENS name of the Safe to check", undefined, types.string) 33 | .setAction(async (taskArgs, hre) => { 34 | const safe = await safeSingleton(hre, taskArgs.address) 35 | const safeAddress = await safe.resolvedAddress 36 | console.log(`Checking Safe at ${safeAddress}`) 37 | console.log(`Singleton: ${await getSingletonAddress(hre, safeAddress)}`) 38 | console.log(`Version: ${await safe.VERSION()}`) 39 | console.log(`Owners: ${await safe.getOwners()}`) 40 | console.log(`Threshold: ${await safe.getThreshold()}`) 41 | console.log(`Nonce: ${await safe.nonce()}`) 42 | console.log(`Fallback Handler: ${await getFallbackHandlerAddress(hre, safeAddress)}`) 43 | console.log(`Modules: ${await getModules(hre, safe)}`) 44 | }); -------------------------------------------------------------------------------- /src/tasks.ts: -------------------------------------------------------------------------------- 1 | import "./creation" 2 | import "./execution" 3 | import "./information" 4 | import "./history" -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 4 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 5 | "lib": ["ES2015"], 6 | "allowJs": false /* Allow javascript files to be compiled. */, 7 | "declaration": true /* Generates corresponding '.d.ts' file. */, 8 | "sourceMap": false /* Generates corresponding '.map' file. */, 9 | "outDir": "./dist" /* Redirect output structure to the directory. */, 10 | "strict": true /* Enable all strict type-checking options. */, 11 | "noUnusedParameters": false /* Report errors on unused parameters. */, 12 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 13 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 14 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 15 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 16 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 17 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 18 | "resolveJsonModule": true 19 | } 20 | } -------------------------------------------------------------------------------- /tx_input.sample.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "to": "0xc778417E063141139Fce010982780140Aa0cD5Ab", 4 | "value": "0.1", 5 | "operation": 0 6 | }, 7 | { 8 | "to": "0xc778417E063141139Fce010982780140Aa0cD5Ab", 9 | "value": "0", 10 | "method": "approve(address,uint256)", 11 | "params": [ 12 | "0xd0Dab4E640D95E9E8A47545598c33e31bDb53C7c", 13 | "1000000000000" 14 | ], 15 | "operation": 0 16 | } 17 | ] 18 | --------------------------------------------------------------------------------