Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: governance script #31

Merged
merged 10 commits into from
Sep 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 285 additions & 0 deletions evm/governance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
'use strict';

require('dotenv').config();

const {
Wallet,
getDefaultProvider,
utils: { isAddress, defaultAbiCoder, keccak256, Interface },
Contract,
BigNumber,
} = require('ethers');
const readlineSync = require('readline-sync');
const { Command, Option } = require('commander');

const {
printInfo,
printWalletInfo,
loadConfig,
isNumber,
isValidTimeFormat,
etaToUnixTimestamp,
getCurrentTimeInSeconds,
wasEventEmitted,
printWarn,
printError,
} = require('./utils');
const IGovernance = require('@axelar-network/axelar-gmp-sdk-solidity/interfaces/IAxelarServiceGovernance.json');

async function processCommand(options, chain) {
const { contractName, address, governanceAction, calldata, nativeValue, eta, privateKey, yes } = options;

const contracts = chain.contracts;
const contractConfig = contracts[contractName];

let governanceAddress;

if (isAddress(address)) {
governanceAddress = address;
} else {
if (!contractConfig?.address) {
throw new Error(`Contract ${contractName} is not deployed on ${chain.name}`);
}

governanceAddress = contractConfig.address;
}

const target = chain.contracts.AxelarGateway?.address;

if (!isAddress(target)) {
throw new Error(`Missing AxelarGateway address in the chain info.`);
}

if (!isNumber(parseFloat(nativeValue))) {
throw new Error(`Invalid native value: ${nativeValue}`);
}

if (!isValidTimeFormat(eta)) {
throw new Error(`Invalid ETA: ${eta}. Please pass the eta in the format YYYY-MM-DDTHH:mm:ss`);
}

const rpc = chain.rpc;
const provider = getDefaultProvider(rpc);

const wallet = new Wallet(privateKey, provider);
await printWalletInfo(wallet);

printInfo('Contract name', contractName);

const governanceContract = new Contract(governanceAddress, IGovernance.abi, wallet);

const gasOptions = contractConfig.gasOptions || chain.gasOptions || {};
console.log(`Gas override for chain ${chain.name}: ${JSON.stringify(gasOptions)}`);

printInfo('Proposal Action', governanceAction);

const unixEta = etaToUnixTimestamp(eta);

const types = ['uint256', 'address', 'bytes', 'uint256', 'uint256'];
const values = [0, target, calldata, nativeValue, unixEta];

let gmpPayload;

switch (governanceAction) {
case 'scheduleTimeLock': {
blockchainguyy marked this conversation as resolved.
Show resolved Hide resolved
if (unixEta < getCurrentTimeInSeconds() + contractConfig.minimumTimeDelay && !yes) {
printWarn(`${eta} is less than the minimum eta.`);
const answer = readlineSync.question(`Proceed with ${governanceAction}?`);
if (answer !== 'y') return;
}

gmpPayload = defaultAbiCoder.encode(types, values);

printInfo(`Destination chain: ${chain.name}\nDestination governance address: ${governanceAddress}\nGMP payload: ${gmpPayload}`);

break;
}

case 'cancelTimeLock': {
const commandType = 1;

if (unixEta < getCurrentTimeInSeconds() && !yes) {
printWarn(`${eta} has already passed.`);
const answer = readlineSync.question(`Proceed with ${governanceAction}?`);
if (answer !== 'y') return;
}

const proposalEta = await governanceContract.getProposalEta(target, calldata, nativeValue);

if (proposalEta.eq(BigNumber.from(0))) {
throw new Error(`Proposal does not exist.`);
}

values[0] = commandType;
gmpPayload = defaultAbiCoder.encode(types, values);

printInfo(`Destination chain: ${chain.name}\nDestination governance address: ${governanceAddress}\nGMP payload: ${gmpPayload}`);

break;
}

case 'approveMultisig': {
if (contractName === 'InterchainGovernance') {
throw new Error(`Invalid governance action for InterchainGovernance: ${governanceAction}`);
}

const commandType = 2;

values[0] = commandType;
gmpPayload = defaultAbiCoder.encode(types, values);

printInfo(`Destination chain: ${chain.name}\nDestination governance address: ${governanceAddress}\nGMP payload: ${gmpPayload}`);

break;
}

case 'cancelMultisig': {
if (contractName === 'InterchainGovernance') {
throw new Error(`Invalid governance action for InterchainGovernance: ${governanceAction}`);
}

const commandType = 3;

values[0] = commandType;
gmpPayload = defaultAbiCoder.encode(types, values);

printInfo(`Destination chain: ${chain.name}\nDestination governance address: ${governanceAddress}\nGMP payload: ${gmpPayload}`);

break;
}

case 'executeProposal': {
const proposalHash = keccak256(defaultAbiCoder.encode(['address', 'bytes', 'uint256'], [target, calldata, nativeValue]));
const minimumEta = await governanceContract.getTimeLock(proposalHash);

if (minimumEta === 0) {
throw new Error('Proposal does not exist.');
}

if (getCurrentTimeInSeconds() < minimumEta) {
throw new Error(`TimeLock proposal is not yet eligible for execution.`);
}

let receipt;

try {
const tx = await governanceContract.executeProposal(target, calldata, nativeValue, gasOptions);
receipt = tx.wait();
} catch (error) {
printError(error);
}

const eventEmitted = wasEventEmitted(receipt, governanceContract, 'ProposalExecuted');

if (!eventEmitted) {
throw new Error('Proposal execution failed.');
}

printInfo('Proposal executed.');

break;
}

case 'executeMultisigProposal': {
if (contractName === 'InterchainGovernance') {
throw new Error(`Invalid governance action for InterchainGovernance: ${governanceAction}`);
}

const proposalHash = keccak256(defaultAbiCoder.encode(['address', 'bytes', 'uint256'], [target, calldata, nativeValue]));
const isApproved = await governanceContract.multisigApprovals(proposalHash);

if (!isApproved) {
throw new Error('Multisig proposal has not been approved.');
}

const isSigner = await governanceContract.isSigner(wallet.address);

if (!isSigner) {
throw new Error(`Caller is not a valid signer address: ${wallet.address}`);
}

const executeInterface = new Interface(governanceContract.interface.fragments);
const executeCalldata = executeInterface.encodeFunctionData('executeMultisigProposal', [target, calldata, nativeValue]);
const topic = keccak256(executeCalldata);

const hasSignerVoted = await governanceContract.hasSignerVoted(wallet.address, topic);

if (hasSignerVoted) {
throw new Error(`Signer has already voted: ${wallet.address}`);
}

const signerVoteCount = await governanceContract.getSignerVotesCount(topic);
printInfo(`${signerVoteCount} signers have already voted.`);

let receipt;

try {
const tx = await governanceContract.executeMultisigProposal(target, calldata, nativeValue, gasOptions);
receipt = await tx.wait();
} catch (error) {
printError(error);
}

const eventEmitted = wasEventEmitted(receipt, governanceContract, 'MultisigExecuted');

if (!eventEmitted) {
throw new Error('Multisig proposal execution failed.');
}

printInfo('Multisig proposal executed.');

break;
}

default: {
throw new Error(`Unknown governance action ${governanceAction}`);
}
}
}

async function main(options) {
const config = loadConfig(options.env);

const chain = options.destinationChain;

if (config.chains[chain.toLowerCase()] === undefined) {
throw new Error(`Destination chain ${chain} is not defined in the info file`);
}

await processCommand(options, config.chains[chain.toLowerCase()]);
}

const program = new Command();

program.name('governance-script').description('Script to manage interchain governance actions');

program.addOption(
new Option('-e, --env <env>', 'environment')
.choices(['local', 'devnet', 'stagenet', 'testnet', 'mainnet'])
.default('testnet')
.makeOptionMandatory(true)
.env('ENV'),
);
program.addOption(
new Option('-c, --contractName <contractName>', 'contract name')
.choices(['InterchainGovernance', 'AxelarServiceGovernance'])
.default('InterchainGovernance'),
);
program.addOption(new Option('-a, --address <address>', 'override address').makeOptionMandatory(false));
program.addOption(new Option('-n, --destinationChain <destinationChain>', 'destination chain').makeOptionMandatory(true));
program.addOption(
new Option('-g, --governanceAction <governanceAction>', 'governance action')
.choices(['scheduleTimeLock', 'cancelTimeLock', 'approveMultisig', 'cancelMultisig', 'executeProposal', 'executeMultisigProposal'])
.default('scheduleTimeLock'),
);
program.addOption(new Option('-d, --calldata <calldata>', 'calldata').makeOptionMandatory(true));
program.addOption(new Option('-v, --nativeValue <nativeValue>', 'nativeValue').makeOptionMandatory(false).default(0));
program.addOption(new Option('-t, --eta <eta>', 'eta').makeOptionMandatory(false).default('0'));
program.addOption(new Option('-p, --privateKey <privateKey>', 'private key').makeOptionMandatory(true).env('PRIVATE_KEY'));
program.addOption(new Option('-y, --yes', 'skip deployment prompt confirmation').env('YES'));

program.action((options) => {
main(options);
});

program.parse();
59 changes: 59 additions & 0 deletions evm/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const {
ContractFactory,
Contract,
provider,
utils: { computeAddress, getContractAddress, keccak256, isAddress, getCreate2Address, defaultAbiCoder },
} = require('ethers');
const https = require('https');
Expand Down Expand Up @@ -331,6 +332,11 @@ const isAddressArray = (arg) => {
return true;
};

const isContract = async (target) => {
const code = await provider.getCode(target);
return code !== '0x';
};

/**
* Determines if a given input is a valid keccak256 hash.
*
Expand Down Expand Up @@ -642,6 +648,54 @@ const deployContract = async (
}
};

/**
* Validate if the input string matches the time format YYYY-MM-DDTHH:mm:ss
*
* @param {string} timeString - The input time string.
* @return {boolean} - Returns true if the format matches, false otherwise.
*/
function isValidTimeFormat(timeString) {
const regex = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|1\d|2\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/;

if (timeString === '0') {
return true;
}

return regex.test(timeString);
}

const etaToUnixTimestamp = (utcTimeString) => {
if (utcTimeString === '0') {
return 0;
}

const date = new Date(utcTimeString + 'Z');

if (isNaN(date.getTime())) {
throw new Error(`Invalid date format provided: ${utcTimeString}`);
}

return Math.floor(date.getTime() / 1000);
};

const getCurrentTimeInSeconds = () => {
return Date.now() / 1000;
};

/**
* Check if a specific event was emitted in a transaction receipt.
*
* @param {object} receipt - The transaction receipt object.
* @param {object} contract - The ethers.js contract instance.
* @param {string} eventName - The name of the event.
* @return {boolean} - Returns true if the event was emitted, false otherwise.
*/
function wasEventEmitted(receipt, contract, eventName) {
const event = contract.filters[eventName]();

return receipt.logs.some((log) => log.topics[0] === event.topics[0]);
}

module.exports = {
deployCreate,
deployCreate2,
Expand All @@ -664,6 +718,7 @@ module.exports = {
isNumber,
isNumberArray,
isAddressArray,
isContract,
isKeccak256Hash,
parseArgs,
getProxy,
Expand All @@ -672,4 +727,8 @@ module.exports = {
loadConfig,
saveConfig,
printWalletInfo,
isValidTimeFormat,
etaToUnixTimestamp,
getCurrentTimeInSeconds,
wasEventEmitted,
};