diff --git a/.env.sample b/.env.sample index bc00a410..a342535e 100644 --- a/.env.sample +++ b/.env.sample @@ -6,4 +6,6 @@ ETHERSCAN_API_KEY= OZ_DEFENDER_API_KEY= OZ_DEFENDER_API_SECRET= # channel's url including key below: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks -DISCORD_URL_WITH_KEY= \ No newline at end of file +DISCORD_URL_WITH_KEY= +RPC_URL= +EXPLORER_URL= diff --git a/monitoring/defender/autotask/on_module_factory_events.js b/monitoring/defender/autotask/on_module_factory_events.js new file mode 100644 index 00000000..033ec958 --- /dev/null +++ b/monitoring/defender/autotask/on_module_factory_events.js @@ -0,0 +1,1354 @@ +const { ethers } = require("ethers"); +const axios = require("axios"); +const MIN_TIMEOUT = 86400; // 1 Day +const MIN_COOLDOWN = 0; +const MIN_BOND = 0.1; +const NETWORKS = { + 1: { + name: "mainnet", + networkExplorerUrl: "https://etherscan.io", + }, + 5: { + name: "goerli", + networkExplorerUrl: "https://goerli.etherscan.io", + }, + 100: { + name: "gnosis_chain", + networkExplorerUrl: "https://blockscout.com/xdai/mainnet", + }, +}; +const DISCORD_PARAMS = { + username: "zodiacbot", + avatar_url: "", + tts: false, + content: "", + embeds: [], +}; +const REALITY_ETH_ABI = [ + { + inputs: [ + { + internalType: "address", + name: "_owner", + type: "address", + }, + { + internalType: "address", + name: "_avatar", + type: "address", + }, + { + internalType: "address", + name: "_target", + type: "address", + }, + { + internalType: "contract RealitioV3", + name: "_oracle", + type: "address", + }, + { + internalType: "uint32", + name: "timeout", + type: "uint32", + }, + { + internalType: "uint32", + name: "cooldown", + type: "uint32", + }, + { + internalType: "uint32", + name: "expiration", + type: "uint32", + }, + { + internalType: "uint256", + name: "bond", + type: "uint256", + }, + { + internalType: "uint256", + name: "templateId", + type: "uint256", + }, + { + internalType: "address", + name: "arbitrator", + type: "address", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "previousAvatar", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "newAvatar", + type: "address", + }, + ], + name: "AvatarSet", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "address", + name: "guard", + type: "address", + }, + ], + name: "ChangedGuard", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "previousOwner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "newOwner", + type: "address", + }, + ], + name: "OwnershipTransferred", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "bytes32", + name: "questionId", + type: "bytes32", + }, + { + indexed: true, + internalType: "string", + name: "proposalId", + type: "string", + }, + ], + name: "ProposalQuestionCreated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "initiator", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "avatar", + type: "address", + }, + { + indexed: false, + internalType: "address", + name: "target", + type: "address", + }, + ], + name: "RealityModuleSetup", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "previousTarget", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "newTarget", + type: "address", + }, + ], + name: "TargetSet", + type: "event", + }, + { + inputs: [], + name: "DOMAIN_SEPARATOR_TYPEHASH", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "INVALIDATED", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "TRANSACTION_TYPEHASH", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "proposalId", + type: "string", + }, + { + internalType: "bytes32[]", + name: "txHashes", + type: "bytes32[]", + }, + ], + name: "addProposal", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "proposalId", + type: "string", + }, + { + internalType: "bytes32[]", + name: "txHashes", + type: "bytes32[]", + }, + { + internalType: "uint256", + name: "nonce", + type: "uint256", + }, + ], + name: "addProposalWithNonce", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "answerExpiration", + outputs: [ + { + internalType: "uint32", + name: "", + type: "uint32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "avatar", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "proposalId", + type: "string", + }, + { + internalType: "bytes32[]", + name: "txHashes", + type: "bytes32[]", + }, + ], + name: "buildQuestion", + outputs: [ + { + internalType: "string", + name: "", + type: "string", + }, + ], + stateMutability: "pure", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "proposalId", + type: "string", + }, + { + internalType: "bytes32[]", + name: "txHashes", + type: "bytes32[]", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "enum Enum.Operation", + name: "operation", + type: "uint8", + }, + ], + name: "executeProposal", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "proposalId", + type: "string", + }, + { + internalType: "bytes32[]", + name: "txHashes", + type: "bytes32[]", + }, + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "enum Enum.Operation", + name: "operation", + type: "uint8", + }, + { + internalType: "uint256", + name: "txIndex", + type: "uint256", + }, + ], + name: "executeProposalWithIndex", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + name: "executedProposalTransactions", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "enum Enum.Operation", + name: "operation", + type: "uint8", + }, + { + internalType: "uint256", + name: "nonce", + type: "uint256", + }, + ], + name: "generateTransactionHashData", + outputs: [ + { + internalType: "bytes", + name: "", + type: "bytes", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getChainId", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getGuard", + outputs: [ + { + internalType: "address", + name: "_guard", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "question", + type: "string", + }, + { + internalType: "uint256", + name: "nonce", + type: "uint256", + }, + ], + name: "getQuestionId", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "to", + type: "address", + }, + { + internalType: "uint256", + name: "value", + type: "uint256", + }, + { + internalType: "bytes", + name: "data", + type: "bytes", + }, + { + internalType: "enum Enum.Operation", + name: "operation", + type: "uint8", + }, + { + internalType: "uint256", + name: "nonce", + type: "uint256", + }, + ], + name: "getTransactionHash", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "guard", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "string", + name: "proposalId", + type: "string", + }, + { + internalType: "bytes32[]", + name: "txHashes", + type: "bytes32[]", + }, + ], + name: "markProposalAsInvalid", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "questionHash", + type: "bytes32", + }, + ], + name: "markProposalAsInvalidByHash", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "questionHash", + type: "bytes32", + }, + ], + name: "markProposalWithExpiredAnswerAsInvalid", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "minimumBond", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "oracle", + outputs: [ + { + internalType: "contract RealitioV3", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "owner", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "questionArbitrator", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "questionCooldown", + outputs: [ + { + internalType: "uint32", + name: "", + type: "uint32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + name: "questionIds", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "questionTimeout", + outputs: [ + { + internalType: "uint32", + name: "", + type: "uint32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "renounceOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint32", + name: "expiration", + type: "uint32", + }, + ], + name: "setAnswerExpiration", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "arbitrator", + type: "address", + }, + ], + name: "setArbitrator", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_avatar", + type: "address", + }, + ], + name: "setAvatar", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_guard", + type: "address", + }, + ], + name: "setGuard", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "bond", + type: "uint256", + }, + ], + name: "setMinimumBond", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint32", + name: "cooldown", + type: "uint32", + }, + ], + name: "setQuestionCooldown", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint32", + name: "timeout", + type: "uint32", + }, + ], + name: "setQuestionTimeout", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_target", + type: "address", + }, + ], + name: "setTarget", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "templateId", + type: "uint256", + }, + ], + name: "setTemplate", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes", + name: "initParams", + type: "bytes", + }, + ], + name: "setUp", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "target", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "template", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "newOwner", + type: "address", + }, + ], + name: "transferOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +]; +const ENS_REGISTRY = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"; // ENS: Registry with Fallback (singleton same address on different chains) +const ABI_REGISTRY = [ + "function owner(bytes32 node) external view returns (address)", + "function resolver(bytes32 node) external view returns (address)", +]; +const ENS_BASE_REGISTRAR_GOERLI = "0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85"; +const ENS_BASE_REGISTRAR_MAINNET = "0x8436F16c090B0A6B2A7ae4CfCc82E007302a4b38"; +const ABI_BASE_REGISTRAR = [ + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "ownerOf", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, +]; +const ORACLE_REALITY_ETH_MAINNET = "0x5b7dD1E86623548AF054A4985F7fc8Ccbb554E2c"; +const ORACLE_REALITY_ETH_GOERLI = "0x6F80C5cBCF9FbC2dA2F0675E56A5900BB70Df72f"; + +/** + * + * @param {number} chainId + * @returns {string} + */ +const getBaseRegistrarContractAddress = (chainId) => { + if (chainId === 1) { + return ENS_BASE_REGISTRAR_MAINNET; + } + if (chainId === 5) { + return ENS_BASE_REGISTRAR_GOERLI; + } +}; + +/** + * + * @param {*} provider Ethers.providers.Provider, + * @param {string} ensName Ens name + * @param {string} address Avatar address + * @returns {boolean} + */ +const checkIfIsController = async (provider, ensName, address) => { + const ensRegistryContract = new ethers.Contract( + ENS_REGISTRY, + ABI_REGISTRY, + provider + ); + const nameHash = ethers.utils.namehash(ensName); + const owner = await ensRegistryContract.owner(nameHash); + return ethers.utils.getAddress(address) === ethers.utils.getAddress(owner); +}; + +/** + * Grab the owner using https://docs.ens.domains/dapp-developer-guide/ens-as-nft#deriving-tokenid-from-ens-name + * @param {*} provider Ethers.providers.Provider, + * @param {string} ensName Ens name + * @param {string} address Avatar address + * @param {number} chainId Chain Id + * @returns {boolean} + */ +const checkIfIsOwner = async (provider, ensName, address, chainId) => { + const contract = new ethers.Contract( + getBaseRegistrarContractAddress(chainId), + ABI_BASE_REGISTRAR, + provider + ); + const BigNumber = ethers.BigNumber; + const utils = ethers.utils; + const name = ensName.replace(".eth", ""); + const labelHash = utils.keccak256(utils.toUtf8Bytes(name)); + const tokenId = BigNumber.from(labelHash).toString(); + const ensOwner = await contract.ownerOf(tokenId); + return ethers.utils.getAddress(address) === ethers.utils.getAddress(ensOwner); +}; + +/** + * + * @param {Array} logs //Array of transaction logs + * @param {string} realityMastercopy //RealityMasterCopy Address + * @param {string} factoryMastercopy //factoryMastercopy Address + * @param {*} utils + * @returns + */ +const extractModuleProxyCreations = async ( + logs, + realityMastercopy, + factoryMastercopy, + utils +) => { + const sig = "ModuleProxyCreation(address,address)"; //ModuleProxyCreation (index_topic_1 address proxy, index_topic_2 address masterCopy) + const bytes = utils.toUtf8Bytes(sig); + const keccak = utils.keccak256(bytes); + if (logs) { + const newRealityModuleProxies = logs + .filter((log) => log.topics !== null) + .filter((log) => log.topics[0] === keccak) + .filter( + (log) => + utils.getAddress(log.address) === utils.getAddress(factoryMastercopy) + ) + .map((log) => { + const proxy = utils.defaultAbiCoder.decode( + ["address"], + log.topics[1] + )[0]; + const mastercopy = utils.defaultAbiCoder.decode( + ["address"], + log.topics[2] + )[0]; + return { + proxy: ethers.utils.getAddress(proxy), + mastercopy: ethers.utils.getAddress(mastercopy), + }; + }) + // in tx it can be more than one proxy creation. We need to find only the ones that is the reality module + .filter(({ mastercopy }) => mastercopy == realityMastercopy) + .map(({ proxy }) => proxy); + return { + proxyAddresses: newRealityModuleProxies, + }; + } +}; + +/** + * + * @param {Array} logs + * @param {string} templateKeccak + * @param {string} templateId + * @returns + */ +const filterTemplateLogs = (logs, templateKeccak, templateId) => { + return logs + .filter((log) => log.topics !== null) + .filter((log) => log.topics[0] === templateKeccak) + .filter( + (log) => + ethers.utils.defaultAbiCoder + .decode(["uint256"], log.topics[1]) + .toString() == templateId + ); +}; + +/** + * + * @param {Array} logs + * @param {string} templateId + * @param {*} utils + * @param {string} etherscanUrl + * @param {string} etherscanApiKey + * @param {number} chainId + * @param {number} txBlock + * @param {number} currentBlock + * @returns + */ +const decodeTemplate = async ( + logs, + templateId, + utils, + etherscanUrl, + etherscanApiKey, + chainId, + txBlock, + currentBlock +) => { + const templateSig = "LogNewTemplate(uint256,address,string)"; //LogNewTemplate (index_topic_1 uint256 template_id, index_topic_2 address user, string question_text) + const templateBytes = utils.toUtf8Bytes(templateSig); + const templateKeccak = utils.keccak256(templateBytes); + let newRealityModuleTemplate = []; + newRealityModuleTemplate = filterTemplateLogs( + logs, + templateKeccak, + templateId + ); + if (!newRealityModuleTemplate.length) { + const templateLogs = await getRealityEthLogs( + etherscanUrl, + etherscanApiKey, + chainId, + txBlock, + currentBlock.number + ); + newRealityModuleTemplate = filterTemplateLogs( + templateLogs, + templateKeccak, + templateId + ); + } + const templateQuestionText = utils.defaultAbiCoder.decode( + ["string"], + newRealityModuleTemplate[0].data + ); + return { + templateQuestionText: templateQuestionText[0], + }; +}; + +/** + * + * @param {number} questionTimeout + * @param {number} questionCooldown + * @param {number} bond + * @param {boolean} isController + * @param {boolean} isOwner + * @param {string} ensName + * @param {number} chainId + * @param {string} txHash + * @returns + */ +const generateDiscordParams = ( + questionTimeout, + questionCooldown, + bond, + isController, + isOwner, + ensName, + chainId, + txHash +) => { + const minBond = ethers.utils.parseUnits(MIN_BOND.toString(), 18); + const params = DISCORD_PARAMS; + let invalidInputs = ""; + let invalidTimeout = false; + let invalidCooldown = false; + let invalidBond = false; + let sendNotification = false; + if (questionTimeout < MIN_TIMEOUT) { + invalidTimeout = true; + } + if (questionCooldown < MIN_COOLDOWN) { + invalidCooldown = true; + } + if (bond.toString() < minBond.toString()) { + invalidBond = true; + } + console.log("invalidTimeout", invalidTimeout); + console.log("invalidCooldown", invalidCooldown); + console.log("invalidBond", invalidBond); + console.log("isOwner", isOwner); + console.log("isController", isController); + if (invalidTimeout) { + invalidInputs = `Timeout (Min expected - ${MIN_TIMEOUT}) = ${questionTimeout}\n`; + } + if (invalidCooldown) { + invalidInputs = `${invalidInputs}Cooldown (Min expected - ${MIN_COOLDOWN}) = ${questionCooldown}\n`; + } + if (invalidBond) { + invalidInputs = `${invalidInputs}Minimum Bond (Min expected - ${minBond.toString()}) = ${bond.toString()}\n`; + } + if (!isController && ensName) { + invalidInputs = `${invalidInputs}The Avatar is not the controller of the ENS name (${ensName}) mentioned in the template.\n`; + } + if (!isOwner && ensName) { + invalidInputs = `${invalidInputs}The Avatar is not the owner of the ENS name (${ensName}) mentioned in the template.\n`; + } + params.embeds = [ + { + type: "rich", + title: `⚡ Autotask Notification - Some inputs from Reality Module didn't pass the validations ⚡`, + description: `A matching transaction was detected on ${NETWORKS[chainId].name}`, + color: 0x0e0e0e, + fields: [ + { + name: `Network`, + + value: `${NETWORKS[chainId].name}`, + }, + { + name: `Hash`, + value: txHash, + }, + { + name: `Link`, + + value: `${NETWORKS[chainId].networkExplorerUrl}/tx/${txHash}`, + }, + { + name: `Invalid Inputs`, + value: invalidInputs, + }, + ], + url: `${NETWORKS[chainId].networkExplorerUrl}/tx/${txHash}`, + }, + ]; + + if ([invalidTimeout, invalidCooldown, invalidBond].includes(true)) { + sendNotification = true; + } + + if ([isController, isOwner].includes(false)) { + sendNotification = true; + } + return { sendNotification, params }; +}; + +/** + * We check the current block logs (from the event), and if the template creation is not there, + * we check on etherscan + * @param {*} eventLogs + * @param {*} url + * @param {*} apiKey + * @param {*} chainId + * @param {*} fromBlock + * @param {*} toBlock + * @returns + */ +const getRealityEthLogs = async (url, apiKey, chainId, fromBlock, toBlock) => { + let contract = ""; + if (chainId === 1) { + contract = ORACLE_REALITY_ETH_MAINNET; + } + if (chainId === 5) { + contract = ORACLE_REALITY_ETH_GOERLI; + } + const response = await axios.get( + `${url}api?module=logs&action=getLogs&address=${contract}&fromBlock=${fromBlock}&toBlock=${toBlock}&page=1&offset=1000&apikey=${apiKey}`, + { + headers: { + "content-type": "application/x-www-form-urlencoded", + "accept-encoding": "*", + }, + } + ); + if (response.status === 200) { + return response.data.result; + } +}; + +/** + * + * @param {*} realityContract + * @param {*} provider + * @param {string} txHash + * @param {number} chainId + * @param {*} ensName + * @param {string} discordWebHookUrl + */ +const handleContractMethods = async ( + realityContract, + provider, + txHash, + chainId, + ensName, + discordWebHookUrl +) => { + const avatar = await realityContract.avatar(); + let isController; + let isOwner; + if (ensName) { + isController = await checkIfIsController(provider, ensName, avatar); + isOwner = await checkIfIsOwner(provider, ensName, avatar, chainId); + } + const questionTimeout = await realityContract.questionTimeout(); + const questionCooldown = await realityContract.questionCooldown(); + const bond = await realityContract.minimumBond(); + const { sendNotification, params } = generateDiscordParams( + questionTimeout, + questionCooldown, + bond, + isController, + isOwner, + ensName, + chainId, + txHash + ); + if (sendNotification) { + await axios.post(discordWebHookUrl, params); + } +}; + +/** + * + * @param {string} templateQuestionText //Template question text + * @returns {string} + */ +const getEnsName = (templateQuestionText) => { + if (templateQuestionText.includes(".eth")) { + const initialTemplateText = templateQuestionText.substr( + 0, + templateQuestionText.indexOf(".eth") + ); + const words = initialTemplateText.split(" "); + const ensName = words[words.length - 1]; + return `${ensName}.eth`; + } +}; + +exports.handler = async function (event) { + console.log( + "\n\n/************************** EVENT *****************************/" + ); + console.log(JSON.stringify(event)); + console.log( + "/**********************************************************/\n\n" + ); + if (event && event.request && event.request.body) { + // variables from autotask creation + const factoryMastercopy = "{{factoryMastercopy}}"; + const etherscanUrl = "{{etherscanUrl}}"; + const etherscanApiKey = "{{etherscanApiKey}}"; + const rpcUrl = "{{rpcUrl}}"; + const discordWebHookUrl = "{{discordWebHookUrl}}"; + const mastercopy = "{{mastercopyAddress}}"; + const creationBlock = parseInt(event.request.body.blockNumber, 16); + const chainId = event.request.body.sentinel.chainId; + const transaction = event.request.body.transaction; + const logs = transaction.logs; + const txHash = transaction.transactionHash; + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + const currentBlock = await provider.getBlock(); + const utils = ethers.utils; + console.log( + "\n\n/*********************** TRANSACTION **************************/" + ); + const decoded = await extractModuleProxyCreations( + logs, + mastercopy, + factoryMastercopy, + utils + ); + if (decoded && decoded.proxyAddresses.length) { + for (const proxyAddress of decoded.proxyAddresses) { + const realityContract = new ethers.Contract( + proxyAddress, + REALITY_ETH_ABI, + provider + ); + const template = await realityContract.template(); + const templateId = template.toString(); + const decodedTemplate = await decodeTemplate( + logs, + templateId, + utils, + etherscanUrl, + etherscanApiKey, + chainId, + creationBlock, + currentBlock.number + ); + const ensName = getEnsName(decodedTemplate.templateQuestionText); + console.log("ensName", ensName); + await handleContractMethods( + realityContract, + provider, + txHash, + chainId, + ensName, + discordWebHookUrl + ); + } + } else { + console.log("No proxy addresses found!"); + } + + console.log( + "/**********************************************************/\n\n" + ); + } +}; diff --git a/monitoring/defender/index.ts b/monitoring/defender/index.ts index 66681f61..841a6777 100644 --- a/monitoring/defender/index.ts +++ b/monitoring/defender/index.ts @@ -1,3 +1,7 @@ +import { + AutotaskClient, + CreateAutotaskRequest, +} from "defender-autotask-client"; import { Network } from "defender-base-client"; import { CreateSentinelRequest, @@ -6,17 +10,25 @@ import { } from "defender-sentinel-client"; import { ContractAbis, ContractAddresses } from "../../src/factory/contracts"; import { KnownContracts } from "../../src/factory/types"; -import { defenderNetworkToSupportedNetwork } from "./util"; - +import { + defenderNetworkToSupportedNetwork, + packageCode, + readFileAndReplace, +} from "./util"; export { NotificationType } from "defender-sentinel-client"; -export const setupSentinelClient = ({ +export const setupClients = ({ apiKey, apiSecret, }: { apiKey: string; apiSecret: string; -}) => new SentinelClient({ apiKey, apiSecret }); +}) => { + return { + sentinel: new SentinelClient({ apiKey, apiSecret }), + autotask: new AutotaskClient({ apiKey, apiSecret }), + }; +}; export const setupNewNotificationChannel = async ( client: SentinelClient, @@ -51,6 +63,7 @@ export const createSentinelForModuleFactory = async ( notificationChannels: string[], network: Network, module: KnownContracts, + autotaskId?: string ) => { const moduleMastercopyAddress = @@ -62,7 +75,7 @@ export const createSentinelForModuleFactory = async ( const requestParameters: CreateSentinelRequest = { type: "BLOCK", network, - name: `New ${module} Module is set up via the Module Factory on ${network})`, + name: `New ${module} Module is set up via the Module Factory on (${network})`, addresses: [moduleProxyFactoryAddress], paused: false, abi: ContractAbis[KnownContracts.FACTORY], @@ -81,3 +94,47 @@ export const createSentinelForModuleFactory = async ( return sentinel.subscriberId; }; + +/** + * + * @param client The AutotaskClient + * @param rpcUrl URL to generate Json Rpc Provider + * @param discordWebHookUrl Discord URL with key + * @param mastercopyAddress Will only handle modules using this mastercopy + * @param factoryMastercopyAddress Will only handle modules using this factoryMastercopyAddress + * @param etherscanUrl + * @param etherscanApiKey + * @returns + */ +export const createAutotaskForModuleFactory = async ( + client: AutotaskClient, + rpcUrl: string, + discordWebHookUrl: string, + mastercopyAddress: string, + factoryMastercopyAddress: string, + etherscanUrl: string, + etherscanApiKey: string +) => { + const code = readFileAndReplace( + "monitoring/defender/autotask/on_module_factory_events.js", + { + "{{proxyFactoryAddress}}": factoryMastercopyAddress, + "{{rpcUrl}}": rpcUrl, + "{{discordWebHookUrl}}": discordWebHookUrl, + "{{mastercopyAddress}}": mastercopyAddress, + "{{etherscanUrl}}": etherscanUrl, + "{{etherscanApiKey}}": etherscanApiKey, + } + ); + const params: CreateAutotaskRequest = { + name: "Reality Module Autotask", + encodedZippedCode: await packageCode(code), + trigger: { + type: "webhook", + }, + paused: false, + }; + const createdAutotask = await client.create(params); + console.log("Created Autotask with ID: ", createdAutotask.autotaskId); + return createdAutotask.autotaskId; +}; diff --git a/monitoring/defender/util.ts b/monitoring/defender/util.ts index 45f9caaf..058fe6c9 100644 --- a/monitoring/defender/util.ts +++ b/monitoring/defender/util.ts @@ -1,4 +1,6 @@ import { Network } from "defender-base-client"; +import fs from "fs"; +import JSZip from "jszip"; import { SupportedNetworks } from "../../src/factory/contracts"; export const defenderNetworkToSupportedNetwork = (networks: Network) => { @@ -13,3 +15,22 @@ export const defenderNetworkToSupportedNetwork = (networks: Network) => { throw new Error(`Unsupported network ${networks}`); } }; + +export const readFileAndReplace = ( + filePath: string, + replaceMap: { [toReplace: string]: string } +) => { + const buffer = fs.readFileSync(filePath); + let fileContent = buffer.toString(); + Object.keys(replaceMap).forEach((key) => { + fileContent = fileContent.replace(key, replaceMap[key]); + }); + return fileContent; +}; + +export const packageCode = async (code: string) => { + const zip = new JSZip(); + zip.file("index.js", code, { binary: false }); + const zippedCode = await zip.generateAsync({ type: "nodebuffer" }); + return zippedCode.toString("base64"); +}; diff --git a/monitoring/setup-script.ts b/monitoring/setup-script.ts index 0394a14b..ad7bd73d 100644 --- a/monitoring/setup-script.ts +++ b/monitoring/setup-script.ts @@ -1,14 +1,19 @@ import dotenv from "dotenv"; +import { ContractAddresses } from "../src/factory/contracts"; import { KnownContracts } from "../src/factory/types"; import { - setupSentinelClient, setupNewNotificationChannel, createSentinelForModuleFactory, + setupClients, + createAutotaskForModuleFactory, } from "./defender"; +import { defenderNetworkToSupportedNetwork } from "./defender/util"; dotenv.config(); +const NETWORK = "goerli"; // for testing + const API_KEY = process.env.OZ_DEFENDER_API_KEY; if (API_KEY == null) { throw new Error("API_KEY is not defined"); @@ -22,11 +27,28 @@ if (DISCORD_URL_WITH_KEY == null) { throw new Error("DISCORD_URL_WITH_KEY is not defined"); } +const RPC_URL = process.env.RPC_URL; +if (RPC_URL == null) { + throw new Error("RPC_URL is not defined"); +} + +const EXPLORER_URL = process.env.EXPLORER_URL; +if (EXPLORER_URL == null) { + throw new Error("EXPLORER_URL is not defined"); +} + +const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY; +if (ETHERSCAN_API_KEY == null) { + throw new Error("ETHERSCAN_API_KEY is not defined"); +} + const setup = async () => { - const sentinelClient = setupSentinelClient({ + const clients = setupClients({ apiKey: API_KEY, apiSecret: API_SECRET, }); + const sentinelClient = clients.sentinel; + const autotaskClient = clients.autotask; console.log("Client is ready"); const notificationChannelId = await setupNewNotificationChannel( @@ -37,11 +59,32 @@ const setup = async () => { } ); + const moduleMastercopyAddress = + ContractAddresses[defenderNetworkToSupportedNetwork(NETWORK)][ + KnownContracts.REALITY_ETH + ]; + + const factoryMastercopyAddress = + ContractAddresses[defenderNetworkToSupportedNetwork(NETWORK)][ + KnownContracts.FACTORY + ]; + + const autotaskId = await createAutotaskForModuleFactory( + autotaskClient, + RPC_URL, + DISCORD_URL_WITH_KEY, + moduleMastercopyAddress, + factoryMastercopyAddress, + EXPLORER_URL, + ETHERSCAN_API_KEY + ); + const sentinelCreationResponds = await createSentinelForModuleFactory( sentinelClient, [notificationChannelId], - "goerli", - KnownContracts.REALITY_ETH + NETWORK, + KnownContracts.REALITY_ETH, + autotaskId ); console.log("Sentinel creation responds", sentinelCreationResponds); }; diff --git a/package.json b/package.json index ec29fef0..bac4adfe 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@openzeppelin/contracts": "^4.3.2", "@openzeppelin/contracts-upgradeable": "^4.2.0", "argv": "^0.0.2", + "defender-autotask-client": "^1.37.0", "dotenv": "^16.0.3", "ethers": "^5.7.1", "solc": "^0.8.17", diff --git a/yarn.lock b/yarn.lock index fb7e8488..56c4fcea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3347,6 +3347,19 @@ deep-is@^0.1.3, deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +defender-autotask-client@^1.37.0: + version "1.37.0" + resolved "https://registry.yarnpkg.com/defender-autotask-client/-/defender-autotask-client-1.37.0.tgz#3b59ac826e6d5df4fa3f706f36e09aad07e786d0" + integrity sha512-N66buqg7mgYiEMbN6R+/6tUB4gxp5Q9cu8QDFuDkviR32AxZ2jY7LqV+GkR/9dbRufQtb4ZRf+ihG/b01w5O5Q== + dependencies: + axios "^0.21.2" + defender-base-client "1.37.0" + dotenv "^10.0.0" + glob "^7.1.6" + jszip "^3.5.0" + lodash "^4.17.19" + node-fetch "^2.6.0" + defender-base-client@1.37.0, defender-base-client@^1.37.0: version "1.37.0" resolved "https://registry.yarnpkg.com/defender-base-client/-/defender-base-client-1.37.0.tgz#22f63357ac99c2c8f64eab6e52c99ef113c62d3a" @@ -3524,6 +3537,11 @@ dom-walk@^0.1.0: resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== +dotenv@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" + integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== + dotenv@^16.0.3: version "16.0.3" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" @@ -5153,7 +5171,7 @@ glob@^5.0.15: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.1.2, glob@^7.1.3, glob@~7.2.3: +glob@^7.0.0, glob@^7.1.2, glob@^7.1.3, glob@^7.1.6, glob@~7.2.3: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -5611,6 +5629,11 @@ immediate@^3.2.3: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.3.0.tgz#1aef225517836bcdf7f2a2de2600c79ff0269266" integrity sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immediate@~3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.2.3.tgz#d140fa8f614659bd6541233097ddaac25cdd991c" @@ -6263,6 +6286,16 @@ jsprim@^1.2.2: json-schema "0.4.0" verror "1.10.0" +jszip@^3.5.0: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + keccak@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/keccak/-/keccak-3.0.1.tgz#ae30a0e94dbe43414f741375cff6d64c8bea0bff" @@ -6516,6 +6549,13 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + load-json-file@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -7491,6 +7531,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"