diff --git a/packages-ts/gauntlet-terra-contracts/networks/.env.bombay-testnet b/packages-ts/gauntlet-terra-contracts/networks/.env.bombay-testnet index 15fc2f61..f659c2de 100644 --- a/packages-ts/gauntlet-terra-contracts/networks/.env.bombay-testnet +++ b/packages-ts/gauntlet-terra-contracts/networks/.env.bombay-testnet @@ -4,5 +4,6 @@ DEFAULT_GAS_PRICE=0.5 LINK=terra1fcksmfjncl6m7apvpalvhwv5jxd9djv5lwyu82 BILLING_ACCESS_CONTROLLER=terra1trcufj64y53hxk7g8cra33xw3jkyvlr9lu99eu REQUESTER_ACCESS_CONTROLLER=terra1s38kfu4qp0ttwxkka9zupaysefl5qruhv5rc0z -MULTISIG_GROUP=terra168lv95kfm49y9zu0409jmplj756ukxdrew7uta -MULTISIG_WALLET=terra1u89pduw4enewduy9qydj925738cyn9juszgj54 + +CW4_GROUP=terra1edk45cc6rckjszfmr87qfx50pfn2mnhg5mn3vd +CW3_FLEX_MULTISIG=terra1cql0r4csmce0ntf68dmkvu3negs7m662uyg90g diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/executionWrapper.ts b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/executionWrapper.ts index ca24ed2c..00766832 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/executionWrapper.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/executionWrapper.ts @@ -1,6 +1,7 @@ import AbstractCommand, { makeAbstractCommand } from '.' import { Result } from '@chainlink/gauntlet-core' import { TerraCommand, TransactionResponse } from '@chainlink/gauntlet-terra' +import { AccAddress, MsgExecuteContract } from '@terra-money/terra.js' export interface AbstractInstruction { instruction: { @@ -26,7 +27,7 @@ export const instructionToCommand = (instruction: AbstractInstruction) super(flags, args) } - execute = async (): Promise> => { + buildCommand = async (): Promise => { const commandInput = await instruction.makeInput(this.flags, this.args) if (!instruction.validateInput(commandInput)) { throw new Error(`Invalid input params: ${JSON.stringify(commandInput)}`) @@ -34,7 +35,17 @@ export const instructionToCommand = (instruction: AbstractInstruction) const input = await instruction.makeContractInput(commandInput) const abstractCommand = await makeAbstractCommand(id, this.flags, this.args, input) await abstractCommand.invokeMiddlewares(abstractCommand, abstractCommand.middlewares) - let response = await abstractCommand.execute() + return abstractCommand + } + + makeRawTransaction = async (signer: AccAddress): Promise => { + const command = await this.buildCommand() + return command.makeRawTransaction(signer) + } + + execute = async (): Promise> => { + const command = await this.buildCommand() + let response = await command.execute() if (instruction.afterExecute) { const data = instruction.afterExecute(response) response = { ...response, data: { ...data } } diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/index.ts b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/index.ts index 67e5370a..74b60a21 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/index.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/index.ts @@ -1,4 +1,5 @@ import { Result } from '@chainlink/gauntlet-core' +import { AccAddress, MsgExecuteContract } from '@terra-money/terra.js' import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' import { TransactionResponse, TerraCommand } from '@chainlink/gauntlet-terra' import { Contract, CONTRACT_LIST, getContract, TerraABI, TERRA_OPERATIONS } from '../../lib/contracts' @@ -130,6 +131,11 @@ export default class AbstractCommand extends TerraCommand { this.contracts = [this.opts.contract.id] } + makeRawTransaction = async (signer: AccAddress): Promise => { + const address = this.args[0] + return new MsgExecuteContract(signer, address, this.params) + } + abstractDeploy: AbstractExecute = async (params: any) => { logger.loading(`Deploying contract ${this.opts.contract.id}`) const codeId = this.codeIds[this.opts.contract.id] diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/inspectionWrapper.ts b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/inspectionWrapper.ts index af08a195..c58578da 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/abstract/inspectionWrapper.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/abstract/inspectionWrapper.ts @@ -47,6 +47,10 @@ export const instructionToInspectCommand = ( super(flags, args) } + makeRawTransaction = () => { + throw new Error('Inspection command does not involve any transaction') + } + execute = async (): Promise> => { const input = await inspectInstruction.makeInput(this.flags, this.args) const commands = await Promise.all( diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/deploy.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/deploy.ts index c644a4e9..33fd1b45 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/deploy.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/deploy.ts @@ -19,6 +19,10 @@ export default class DeployLink extends TerraCommand { super(flags, args) } + makeRawTransaction = async () => { + throw new Error('Deploy LINK command: makeRawTransaction method not implemented') + } + execute = async () => { await prompt(`Begin deploying LINK Token?`) const deploy = await this.deploy(CW20_BASE_CODE_IDs[this.flags.network], { diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/transfer.ts b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/transfer.ts index 367a1ea2..fd2732f6 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/transfer.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/contracts/link/transfer.ts @@ -17,6 +17,10 @@ export default class TransferLink extends TerraCommand { super(flags, args) } + makeRawTransaction = async () => { + throw new Error('Transfer LINK command: makeRawTransaction method not implemented') + } + execute = async () => { const decimals = this.flags.decimals || 18 const link = this.flags.link || process.env.LINK diff --git a/packages-ts/gauntlet-terra-contracts/src/commands/tooling/upload.ts b/packages-ts/gauntlet-terra-contracts/src/commands/tooling/upload.ts index 3862513a..0840c116 100644 --- a/packages-ts/gauntlet-terra-contracts/src/commands/tooling/upload.ts +++ b/packages-ts/gauntlet-terra-contracts/src/commands/tooling/upload.ts @@ -23,6 +23,10 @@ export default class UploadContractCode extends TerraCommand { super(flags, args) } + makeRawTransaction = async () => { + throw new Error('Upload command: makeRawTransaction method not implemented') + } + getCodeId(response): number | undefined { return Number(this.parseResponseValue(response, 'store_code', 'code_id')) } diff --git a/packages-ts/gauntlet-terra-contracts/src/index.ts b/packages-ts/gauntlet-terra-contracts/src/index.ts index dcf0b1ba..ef5a87fa 100644 --- a/packages-ts/gauntlet-terra-contracts/src/index.ts +++ b/packages-ts/gauntlet-terra-contracts/src/index.ts @@ -1,4 +1,5 @@ import { executeCLI } from '@chainlink/gauntlet-core' +import { multisigWrapCommand } from '@chainlink/gauntlet-terra-cw-plus' import { existsSync } from 'fs' import path from 'path' import { io } from '@chainlink/gauntlet-core/dist/utils' @@ -7,7 +8,7 @@ import { makeAbstractCommand } from './commands/abstract' import { defaultFlags } from './lib/args' const commands = { - custom: [...Terra], + custom: [...Terra, ...Terra.map(multisigWrapCommand)], loadDefaultFlags: () => defaultFlags, abstract: { findPolymorphic: () => undefined, diff --git a/packages-ts/gauntlet-terra-contracts/src/lib/constants.ts b/packages-ts/gauntlet-terra-contracts/src/lib/constants.ts index 20cb82fe..228a97ec 100644 --- a/packages-ts/gauntlet-terra-contracts/src/lib/constants.ts +++ b/packages-ts/gauntlet-terra-contracts/src/lib/constants.ts @@ -11,7 +11,7 @@ export const enum CATEGORIES { DEVIATION_FLAGGING_VALIDATOR = 'Devaiation Flagging Validator', } -export const DEFAULT_RELEASE_VERSION = 'v0.0.4' +export const DEFAULT_RELEASE_VERSION = 'local' export const DEFAULT_CWPLUS_VERSION = 'v0.9.1' export const ORACLES_MAX_LENGTH = 31 diff --git a/packages-ts/gauntlet-terra-cw-plus/README.md b/packages-ts/gauntlet-terra-cw-plus/README.md new file mode 100644 index 00000000..3c95c93f --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/README.md @@ -0,0 +1 @@ +# Gauntlet Terra CW Plus \ No newline at end of file diff --git a/packages-ts/gauntlet-terra-cw-plus/package.json b/packages-ts/gauntlet-terra-cw-plus/package.json new file mode 100644 index 00000000..c7f36436 --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/package.json @@ -0,0 +1,32 @@ +{ + "name": "@chainlink/gauntlet-terra-cw-plus", + "version": "0.0.1", + "description": "Gauntlet Terra CW Plus contracts", + "keywords": [ + "typescript", + "cli" + ], + "main": "./dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*", + "!dist/**/*.test.js" + ], + "scripts": { + "preinstall": "node ../../scripts/require-yarn.js", + "gauntlet": "ts-node ./src/index.ts", + "lint": "tsc", + "test": "SKIP_PROMPTS=true jest --runInBand", + "test:coverage": "yarn test --collectCoverage", + "test:ci": "yarn test --ci", + "lint:format": "yarn prettier --check ./src", + "format": "yarn prettier --write ./src", + "clean": "rm -rf ./dist/ ./bin/", + "build": "yarn clean && tsc", + "bundle": "yarn build && pkg ." + }, + "dependencies": { + "@chainlink/gauntlet-core": "0.0.7", + "@chainlink/gauntlet-terra": "*" + } +} diff --git a/packages-ts/gauntlet-terra-cw-plus/src/commands/multisig.ts b/packages-ts/gauntlet-terra-cw-plus/src/commands/multisig.ts new file mode 100644 index 00000000..c15d5ce0 --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/src/commands/multisig.ts @@ -0,0 +1,275 @@ +import { Result } from '@chainlink/gauntlet-core' +import { logger, prompt } from '@chainlink/gauntlet-core/dist/utils' +import { TerraCommand, TransactionResponse } from '@chainlink/gauntlet-terra' +import { AccAddress, MsgExecuteContract } from '@terra-money/terra.js' +import { isDeepEqual } from '../lib/utils' + +type ProposalAction = ( + signer: AccAddress, + proposalId: number, + message: MsgExecuteContract, +) => Promise + +enum Vote { + YES = 'yes', + NO = 'no', + ABS = 'abstain', + VETO = 'veto', +} + +type WasmMsg = { + wasm: { + execute: { + contract_addr: string + funds: { + denom: string + amount: string + }[] + msg: string + } + } +} + +enum Action { + CREATE = 'create', + APPROVE = 'approve', + EXECUTE = 'execute', + NONE = 'none', +} + +type State = { + threshold: number + nextAction: Action + owners: AccAddress[] + approvers: string[] + // https://github.com/CosmWasm/cw-plus/blob/82138f9484e538913f7faf78bc292fb14407aae8/packages/cw3/src/query.rs#L75 + currentStatus?: 'pending' | 'open' | 'rejected' | 'passed' | 'executed' + data?: WasmMsg[] +} + +export const wrapCommand = (command) => { + return class Multisig extends TerraCommand { + command: TerraCommand + multisig: AccAddress + multisigGroup: AccAddress + + static id = `${command.id}:multisig` + + constructor(flags, args) { + super(flags, args) + + this.command = new command(flags, args) + + if (!AccAddress.validate(process.env.CW3_FLEX_MULTISIG)) throw new Error(`Invalid Multisig wallet address`) + if (!AccAddress.validate(process.env.CW4_GROUP)) throw new Error(`Invalid Multisig group address`) + this.multisig = process.env.CW3_FLEX_MULTISIG as AccAddress + this.multisigGroup = process.env.CW4_GROUP as AccAddress + } + + makeRawTransaction = async (signer: AccAddress, state?: State) => { + const message = await this.command.makeRawTransaction(this.multisig) + + const operations = { + [Action.CREATE]: this.makeProposeTransaction, + [Action.APPROVE]: this.makeAcceptTransaction, + [Action.EXECUTE]: this.makeExecuteTransaction, + [Action.NONE]: () => { + throw new Error('No action needed') + }, + } + + if (state.nextAction !== Action.CREATE) { + this.require( + await this.isSameProposal(state.data, [this.toWasmMsg(message)]), + 'The transaction generated is different from the proposal provided', + ) + } + + return operations[state.nextAction](signer, Number(this.flags.proposal), message) + } + + isSameProposal = (proposalMsgs: WasmMsg[], generatedMsgs: WasmMsg[]) => { + return isDeepEqual(proposalMsgs, generatedMsgs) + } + + toWasmMsg = (message: MsgExecuteContract): WasmMsg => { + return { + wasm: { + execute: { + contract_addr: message.contract, + funds: message.coins.toArray().map((c) => c.toData()), + msg: Buffer.from(JSON.stringify(message.execute_msg)).toString('base64'), + }, + }, + } + } + + makeProposeTransaction: ProposalAction = async (signer, _, message) => { + logger.info('Generating data for creating new proposal') + const proposeInput = { + propose: { + description: command.id, + msgs: [this.toWasmMsg(message)], + title: command.id, + // TODO: Set expiration time + // latest: { never: {} } + }, + } + return new MsgExecuteContract(signer, this.multisig, proposeInput) + } + + makeAcceptTransaction: ProposalAction = async (signer, proposalId) => { + logger.info(`Generating data for approving proposal ${proposalId}`) + const approvalInput = { + vote: { + vote: Vote.YES, + proposal_id: proposalId, + }, + } + return new MsgExecuteContract(signer, this.multisig, approvalInput) + } + + makeExecuteTransaction: ProposalAction = async (signer, proposalId) => { + logger.info(`Generating data for executing proposal ${proposalId}`) + const executeInput = { + execute: { + proposal_id: proposalId, + }, + } + return new MsgExecuteContract(signer, this.multisig, executeInput) + } + + fetchState = async (proposalId?: number): Promise => { + const groupState = await this.query(this.multisigGroup, { + list_members: {}, + }) + const owners = groupState.members.map((m) => m.addr) + const thresholdState = await this.query(this.multisig, { + threshold: {}, + }) + const threshold = thresholdState.absolute_count.total_weight + if (!proposalId) { + return { + threshold, + nextAction: Action.CREATE, + owners, + approvers: [], + } + } + const proposalState = await this.query(this.multisig, { + proposal: { + proposal_id: proposalId, + }, + }) + const votes = await this.query(this.multisig, { + list_votes: { + proposal_id: proposalId, + }, + }) + const status = proposalState.status + const toNextAction = { + passed: Action.EXECUTE, + open: Action.APPROVE, + pending: Action.APPROVE, + rejected: Action.NONE, + executed: Action.NONE, + } + return { + threshold, + nextAction: toNextAction[status], + owners, + currentStatus: status, + data: proposalState.msgs, + approvers: votes.votes.filter((v) => v.vote === Vote.YES).map((v) => v.voter), + } + } + + printPostInstructions = async (proposalId: number) => { + const state = await this.fetchState(proposalId) + const approvalsLeft = state.threshold - state.approvers.length + const messages = { + [Action.APPROVE]: `The proposal needs ${approvalsLeft} more approvals. Run the same command with the flag --proposal=${proposalId}`, + [Action.EXECUTE]: `The proposal reached the threshold and can be executed. Run the same command with the flag --proposal=${proposalId}`, + [Action.NONE]: `The proposal has been executed. No more actions needed`, + } + logger.line() + logger.info(`${messages[state.nextAction]}`) + logger.line() + } + + execute = async () => { + let proposalId = !!this.flags.proposal && Number(this.flags.proposal) + const state = await this.fetchState(proposalId) + + if (state.nextAction === Action.NONE) { + await this.printPostInstructions(proposalId) + return + } + const rawTx = await this.makeRawTransaction(this.wallet.key.accAddress, state) + + logger.info(`Proposal State: + - Total Owners: ${state.owners.length} + - Owners List: ${state.owners} + + - Threshold: ${state.threshold} + - Total Approvers: ${state.approvers.length} + - Approvers List: ${state.approvers} + + - Next Action: ${state.nextAction.toUpperCase()} + `) + + const actionMessage = { + [Action.CREATE]: 'CREATING', + [Action.APPROVE]: 'APPROVING', + [Action.EXECUTE]: 'EXECUTING', + } + + if (this.flags.execute) { + await prompt(`Continue ${actionMessage[state.nextAction]} proposal?`) + const tx = await this.signAndSend([rawTx]) + + if (state.nextAction === Action.CREATE) { + const proposalFromEvent = tx.events[0].wasm.proposal_id[0] + logger.success(`New proposal created with ID: ${proposalFromEvent}`) + proposalId = Number(proposalFromEvent) + } + + await this.printPostInstructions(proposalId) + + return { + responses: [ + { + tx, + contract: this.multisig, + }, + ], + data: { + proposalId, + }, + } as Result + } + + // TODO: Test raw message + const msgData = Buffer.from(JSON.stringify(rawTx.execute_msg)).toString('base64') + logger.line() + logger.success(`Message generated succesfully for ${actionMessage[state.nextAction]} proposal`) + logger.log() + logger.log(msgData) + logger.log() + logger.line() + + return { + responses: [ + { + tx: {}, + contract: this.multisig, + }, + ], + data: { + proposalId, + message: msgData, + }, + } as Result + } + } +} diff --git a/packages-ts/gauntlet-terra-cw-plus/src/index.ts b/packages-ts/gauntlet-terra-cw-plus/src/index.ts new file mode 100644 index 00000000..08a5280c --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/src/index.ts @@ -0,0 +1,3 @@ +import { wrapCommand as multisigWrapCommand } from './commands/multisig' + +export { multisigWrapCommand } diff --git a/packages-ts/gauntlet-terra-cw-plus/src/lib/utils.ts b/packages-ts/gauntlet-terra-cw-plus/src/lib/utils.ts new file mode 100644 index 00000000..07401085 --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/src/lib/utils.ts @@ -0,0 +1,13 @@ +import assert from 'assert' + +export const isDeepEqual = (a: any, b: any) => { + try { + assert.deepStrictEqual(a, b) + } catch (error) { + if (error.name === 'AssertionError') { + return false + } + throw error + } + return true +} diff --git a/packages-ts/gauntlet-terra-cw-plus/tsconfig.json b/packages-ts/gauntlet-terra-cw-plus/tsconfig.json new file mode 100644 index 00000000..2c84c1fc --- /dev/null +++ b/packages-ts/gauntlet-terra-cw-plus/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages-ts/gauntlet-terra/src/commands/internal/terra.ts b/packages-ts/gauntlet-terra/src/commands/internal/terra.ts index 72a140bf..ee6298ee 100644 --- a/packages-ts/gauntlet-terra/src/commands/internal/terra.ts +++ b/packages-ts/gauntlet-terra/src/commands/internal/terra.ts @@ -1,6 +1,6 @@ import { Result, WriteCommand } from '@chainlink/gauntlet-core' import { logger } from '@chainlink/gauntlet-core/dist/utils' -import { EventsByType, MsgStoreCode, TxLog } from '@terra-money/terra.js' +import { EventsByType, MsgStoreCode, AccAddress, TxLog } from '@terra-money/terra.js' import { SignMode } from '@terra-money/terra.proto/cosmos/tx/signing/v1beta1/signing' import { withProvider, withWallet, withCodeIds, withNetwork } from '../middlewares' @@ -23,6 +23,7 @@ export default abstract class TerraCommand extends WriteCommand Promise> + abstract makeRawTransaction: (signer: AccAddress) => Promise constructor(flags, args) { super(flags, args) @@ -63,6 +64,25 @@ export default abstract class TerraCommand extends WriteCommand => { + try { + logger.loading('Signing transaction...') + const tx = await this.wallet.createAndSignTx({ + msgs: messages, + ...(this.wallet.key instanceof LedgerKey && { + signMode: SignMode.SIGN_MODE_LEGACY_AMINO_JSON, + }), + }) + + logger.loading('Sending transaction...') + const res = await this.provider.tx.broadcast(tx) + return this.wrapResponse(res) + } catch (e) { + const message = e?.response?.data?.message || e.message + throw new Error(message) + } + } + async call(address, input) { const msg = new MsgExecuteContract(this.wallet.key.accAddress, address, input) @@ -74,8 +94,6 @@ export default abstract class TerraCommand extends WriteCommand