diff --git a/.gitignore b/.gitignore index 7d727be9..e1424f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,5 @@ local.json keys.json temp-arguments.js + +signatures/ diff --git a/evm/README.md b/evm/README.md index 888e0c08..7d2caca4 100644 --- a/evm/README.md +++ b/evm/README.md @@ -186,3 +186,97 @@ To get details of options provided in the command run: ```bash node evm/verify-contract.js --help ``` + +## Combine Validator Signatures + +The following script is for emergency situations where we need to ask validators to manually sign gateway approvals. These validator signatures must be collected and stored within separate files in the signatures folder in the root directory. The script will then combine these signatures into a gateway batch and optionally execute this batch on the Axelar gateway contract on the specified chain. + +Ensure that the lcd endpoint has been provided for the specified environment in the chains config file under `axelar`: + +```json +"axelar": { + "id": "Axelarnet", + "axelarId": "Axelarnet", + "rpc": "", + "lcd": "", + "grpc": "", + "tokenSymbol": "AXL" + } +``` + +Below are instructions on how to utilize this script: + +1. Construct the gateway batch data by passing in the payload hash gotten from running the `governance.js` script. + + ```bash + node evm/combine-signatures.js -e [ENV] -n [CHAIN_NAME] --action createBatchData --payloadHash [PAYLOAD_HASH] + ``` + + Note: Other gateway approval variables have default values but can be overriden via CLI options + + - sourceChain: 'Axelarnet' + - sourceAddress: 'axelar10d07y265gmmuvt4z0w9aw880jnsr700j7v9daj' + - contractAddress = interchain governance address + - commandId = random bytes32 value + + Example payload hash generation via governance script for Axelar gateway upgrade. For more information see [here](https://github.com/axelarnetwork/axelar-contract-deployments/tree/main/evm#governance): + + ```bash + node evm/governance.js --targetContractName AxelarGateway --action upgrade --proposalAction schedule --date 2023-12-13T16:50:00 --file proposal.json + ``` + + Example output: + + ```bash + Axelar Proposal payload hash: 0x485cc20898a043994a38827c04230ed642db5362e7d412b84b909b0504862024 + ``` + +2. Running the command above will output the raw batch data, the data hash, and the vald sign command that should be used to generate validator signatures. Here is an example output: + + ```bash + axelard vald-sign evm-ethereum-2-3799882 [validator-addr] 0x2716d6d6c37ccfb1ea1db014b175ba23d0f522d7789f3676b7e5e64e4322731 + ``` + + The validator address should be entered dynamically based on which validator is signing. + +3. Connect to AWS and switch to the correct context depending on which environment validators will be signing in. + +4. Get all the namespaces within this context: + + ```bash + kubectl get ns + ``` + +5. List pods for each validator namespace. Example for stagenet validator 1: + + ```bash + kubectl get pods -n stagenet-validator1 + ``` + +6. Exec into the validator pod. Example for stagenet validator 1: + + ```bash + kubectl exec -ti axelar-core-node-validator1-5b68db5c49-mf5q6 -n stagenet-validator1 -c vald -- sh + ``` + +7. Within the validator container, run the vald command from step 2 with the correct validator address. Validator addresses can be found by appending this endpoint `/axelar/multisig/v1beta1/key?key_id=[KEY_ID]` to the Axelar lcd url in the chains config file. Running this command should result in a JSON output containing the signature, similar to the example below: + + ```json + { + "key_id": "evm-ethereum-2-3799882", + "msg_hash": "9dd126c2fc8b8a29f92894eb8830e552b7caf1a3c25e89cc0d74b99bc1165039", + "pub_key": "030d0b5bff4bff1adb9ab47df8c70defe27bfae84b12b77fac64cdcc2263e8e51b", + "signature": "634cb96060d13c083572d5c57ec9f6441103532434824a046dec4e8dfa0645544ea155230656ee37329572e1c52df1c85e48f0ef5f283bc7e036b9fd51ade6661b", + "validator": "axelarvaloper1yfrz78wthyzykzf08h7z6pr0et0zlzf0dnehwx" + } + ``` + +8. Create a signatures folder in the root directory of this repository. Repeat step 7 until enough signatures have been generated to surpass the threshold weight for the current epoch (threshold weight can be found at the same endpoint as validator addresses). Store each signature JSON in a separate file within the signatures folder, filenames do not have to follow any specific convention. + +9. Run the construct batch command passing in the raw batch data logged in step 1. This will generate the input bytes that can be passed directly into the execute command on Axelar gateway. You can also optionally pass the `--execute` command which will automatically execute the batch on the Axelar gateway contract. + + ```bash + node evm/combine-signatures.js -e [ENV] -n [CHAIN_NAME] --action constructBatch --batchData [RAW_BATCH_DATA] + ``` + + Note: This step will generate the proof and validate it against the Axelar auth module before printing out the input bytes. diff --git a/evm/combine-signatures.js b/evm/combine-signatures.js new file mode 100644 index 00000000..130f7bf0 --- /dev/null +++ b/evm/combine-signatures.js @@ -0,0 +1,294 @@ +'use strict'; + +const { ethers } = require('hardhat'); +const fs = require('fs'); +const path = require('path'); +const { + getDefaultProvider, + utils: { computePublicKey, keccak256, getAddress, arrayify, defaultAbiCoder, hashMessage, recoverAddress, hexZeroPad, hexlify }, + constants: { HashZero, MaxUint256 }, + Contract, +} = ethers; +const { Command, Option } = require('commander'); +const { + mainProcessor, + printInfo, + printWalletInfo, + getGasOptions, + printError, + validateParameters, + getContractJSON, + getEVMAddresses, +} = require('./utils'); +const { handleTx } = require('./its'); +const { getWallet } = require('./sign-utils'); +const { addBaseOptions } = require('./cli-utils'); +const IAxelarGateway = getContractJSON('IAxelarGateway'); + +function readSignatures() { + const signaturesDir = path.join(__dirname, '../signatures'); + const signatureFiles = fs.readdirSync(signaturesDir); + const signatures = []; + + signatureFiles.forEach((file) => { + const filePath = path.join(signaturesDir, file); + const fileContent = fs.readFileSync(filePath, 'utf8'); + + try { + const signature = JSON.parse(fileContent); + signatures.push(signature); + } catch (error) { + printError(`Error parsing JSON in file ${file}`, error.message); + } + }); + + return signatures; +} + +function getAddressFromPublicKey(publicKey) { + const uncompressedPublicKey = computePublicKey(publicKey, false); + const addressHash = keccak256(`0x${uncompressedPublicKey.slice(4)}`); + + return getAddress('0x' + addressHash.slice(-40)); +} + +async function getCommandId(gateway) { + let currentValue = MaxUint256; + + while (true) { + const isCommandIdExecuted = await gateway.isCommandExecuted(hexZeroPad(hexlify(currentValue), 32)); + + if (!isCommandIdExecuted) { + break; + } + + currentValue = currentValue.sub(1); + } + + return hexZeroPad(hexlify(currentValue), 32); +} + +async function processCommand(config, chain, options) { + const { address, action, privateKey } = options; + + const contracts = chain.contracts; + + if (address) { + validateParameters({ isValidAddress: { address } }); + } + + const rpc = chain.rpc; + const provider = getDefaultProvider(rpc); + + const wallet = await getWallet(privateKey, provider, options); + await printWalletInfo(wallet); + + const gatewayAddress = address || contracts.AxelarGateway?.address; + const gateway = new Contract(gatewayAddress, IAxelarGateway.abi, wallet); + + printInfo('Batch Action', action); + + switch (action) { + case 'createBatchData': { + const { commandId, payloadHash } = options; + + const sourceChain = options.sourceChain || 'Axelarnet'; + const sourceAddress = options.sourceAddress || 'axelar10d07y265gmmuvt4z0w9aw880jnsr700j7v9daj'; + const contractAddress = options.contractAddress || contracts.InterchainGovernance?.address; + const commandID = commandId || (await getCommandId(gateway)); + + printInfo('Command ID', commandID); + + validateParameters({ + isNonEmptyString: { sourceChain, sourceAddress }, + isValidAddress: { contractAddress }, + isKeccak256Hash: { commandID, payloadHash }, + }); + + const chainId = chain.chainId; + const command = 'approveContractCall'; + const params = defaultAbiCoder.encode( + ['string', 'string', 'address', 'bytes32', 'bytes32', 'uint256'], + [sourceChain, sourceAddress, contractAddress, payloadHash, HashZero, 0], + ); + + const data = defaultAbiCoder.encode( + ['uint256', 'bytes32[]', 'string[]', 'bytes[]'], + [chainId, [commandID], [command], [params]], + ); + + const dataHash = hashMessage(arrayify(keccak256(data))); + + const { keyID } = await getEVMAddresses(config, chain.id, options); + + printInfo('Original bytes message (pre-hash)', data); + printInfo('Message hash for validators to sign', dataHash); + printInfo('Vald sign command', `axelard vald-sign ${keyID} [validator-addr] ${dataHash}`); + + break; + } + + case 'constructBatch': { + const { batchData, execute } = options; + + validateParameters({ isValidCalldata: { batchData } }); + + const { addresses: validatorAddresses, weights, threshold } = await getEVMAddresses(config, chain.id, options); + + const validatorWeights = {}; + + validatorAddresses.forEach((address, index) => { + validatorWeights[address.toLowerCase()] = weights[index]; + }); + + const signatures = readSignatures(); + + const sortedSignatures = signatures.sort((a, b) => { + const addressA = getAddressFromPublicKey(`0x${a.pub_key}`).toLowerCase(); + const addressB = getAddressFromPublicKey(`0x${b.pub_key}`).toLowerCase(); + return addressA.localeCompare(addressB); + }); + + const batchSignatures = []; + const checkedAddresses = []; + + const expectedMessageHash = hashMessage(arrayify(keccak256(batchData))); + + const prevKeyId = sortedSignatures[0].key_id; + let totalWeight = 0; + + for (const signatureJSON of sortedSignatures) { + const keyId = signatureJSON.key_id; + const msgHash = signatureJSON.msg_hash.startsWith('0x') ? signatureJSON.msg_hash : `0x${signatureJSON.msg_hash}`; + const pubKey = signatureJSON.pub_key.startsWith('0x') ? signatureJSON.pub_key : `0x${signatureJSON.pub_key}`; + const signature = signatureJSON.signature.startsWith('0x') ? signatureJSON.signature : `0x${signatureJSON.signature}`; + + validateParameters({ + isNonEmptyString: { keyId }, + isKeccak256Hash: { msgHash }, + isValidCalldata: { pubKey, signature }, + }); + + if (prevKeyId !== keyId) { + printError('Signatures do not contain consistent key IDs', keyId); + return; + } + + if (msgHash.toLowerCase() !== expectedMessageHash.toLowerCase()) { + printError('Message hash does not equal expected message hash', msgHash); + return; + } + + const validatorAddress = getAddressFromPublicKey(pubKey).toLowerCase(); + + if (checkedAddresses.includes(validatorAddress)) { + printError('Duplicate validator address', validatorAddress); + return; + } + + checkedAddresses.push(validatorAddress); + + const signer = recoverAddress(msgHash, signature); + + if (signer.toLowerCase() !== validatorAddress) { + printError('Signature is invalid for the given validator address', validatorAddress); + return; + } + + const validatorWeight = validatorWeights[validatorAddress]; + + if (!validatorWeight) { + printError('Validator does not belong to current epoch', validatorAddress); + return; + } + + totalWeight += validatorWeight; + + batchSignatures.push(signature); + + if (totalWeight >= threshold) { + break; + } + } + + if (totalWeight < threshold) { + printError(`Total signer weight ${totalWeight} less than threshold`, threshold); + return; + } + + const proof = defaultAbiCoder.encode( + ['address[]', 'uint256[]', 'uint256', 'bytes[]'], + [validatorAddresses, weights, threshold, batchSignatures], + ); + + const IAxelarAuth = getContractJSON('IAxelarAuth'); + const authAddress = address || contracts.AxelarGateway?.authModule; + const auth = new Contract(authAddress, IAxelarAuth.abi, wallet); + + let isValidProof; + + try { + isValidProof = await auth.validateProof(expectedMessageHash, proof); + } catch (error) { + printError('Invalid batch proof', error); + return; + } + + if (!isValidProof) { + printError('Invalid batch proof'); + return; + } + + const input = defaultAbiCoder.encode(['bytes', 'bytes'], [batchData, proof]); + + printInfo('Batch input (data and proof) for gateway execute function', input); + + if (execute) { + printInfo('Executing gateway batch on chain', chain.name); + + const contractName = 'AxelarGateway'; + + const gasOptions = await getGasOptions(chain, options, contractName); + + const tx = await gateway.execute(input, gasOptions); + + await handleTx(tx, chain, gateway, action, 'Executed'); + } + + break; + } + + default: { + throw new Error(`Unknown signature action ${action}`); + } + } +} + +async function main(options) { + await mainProcessor(options, processCommand); +} + +if (require.main === module) { + const program = new Command(); + + program.name('combine-signatures').description('script to combine manually created signatures and construct gateway batch'); + + addBaseOptions(program, { address: true }); + + program.addOption(new Option('--action ', 'signature action').choices(['createBatchData', 'constructBatch'])); + program.addOption( + new Option('-i, --batchData ', 'batch data to be combined with proof for gateway execute command').env('BATCH_DATA'), + ); + program.addOption(new Option('--commandId ', 'gateway command id').env('COMMAND_ID')); + program.addOption(new Option('--sourceChain ', 'source chain for contract call').env('SOURCE_CHAIN')); + program.addOption(new Option('--sourceAddress ', 'source address for contract call').env('SOURCE_ADDRESS')); + program.addOption(new Option('--contractAddress ', 'contract address on current chain').env('CONTRACT_ADDRESS')); + program.addOption(new Option('--payloadHash ', 'payload hash').env('PAYLOAD_HASH')); + program.addOption(new Option('--execute', 'whether or not to immediately execute the batch').env('EXECUTE')); + + program.action((options) => { + main(options); + }); + + program.parse(); +}