Skip to content

Commit

Permalink
Multisig support (#37)
Browse files Browse the repository at this point in the history
* Create multisig command

* Prettier + example

* Merged feature/relay and required changes for running the flow

* Merged example with raw transaction; removed log

* Change threshold working

* Abstract transaction working with JSON file

* Set owners, thredhold and able to run arbitrary transactions directly

* Formatting

* moved multisig into its library

* fix rebase

* multisig command wrapper

* moved cli script

* Working, supporting setting threshold owners and example command setBilling

* Prettier

* Fixed develop merge

* Prettier

* Addressing review comments, improving flow with single --proposal flag, adding inspection and validations

* Fix run issue + fix approvals inspection

* Fix version issue

* Moved multisig create to package, fixed creation parameters

* Able to execute setBilling from any proposal address, on access given to multisigSigner

* Restructuring of multisig wrapper command + distint actions UX

* Small changes

* Added README

* Revert BN imports

* refactored multisig

refactor

* Fixes

* Revert .map

* Move multisigAddress to constructor

* remove set billing default value

Co-authored-by: RodrigoAD <15104916+RodrigoAD@users.noreply.github.com>
  • Loading branch information
ebarakos and RodrigoAD authored Jan 14, 2022
1 parent 256f635 commit 6ca7765
Show file tree
Hide file tree
Showing 25 changed files with 621 additions and 43 deletions.
3 changes: 2 additions & 1 deletion gauntlet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"main": "packages/gauntlet-solana-contracts/dist/index.js",
"bin": "packages/gauntlet-solana-contracts/dist/index.js",
"scripts": {
"gauntlet": "yarn build && node ./packages/gauntlet-solana-contracts/dist/index.js",
"gauntlet": "yarn build && node ./packages/gauntlet-solana-contracts/dist/cli.js",
"gauntlet-serum-multisig": "yarn build && node ./packages/gauntlet-serum-multisig/dist/index.js",
"lint": "tsc -b ./tsconfig.json",
"test": "yarn build && SKIP_PROMPTS=true jest --runInBand ./packages",
"test:coverage": "yarn test --collectCoverage",
Expand Down
21 changes: 21 additions & 0 deletions gauntlet/packages/gauntlet-serum-multisig/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2019 Khalid Zoabi

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
40 changes: 40 additions & 0 deletions gauntlet/packages/gauntlet-serum-multisig/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Gauntlet Serum Multisig

## Creating a Multisig

Example with 3 owners and threshold=2

`yarn gauntlet-serum-multisig create --network=local 3W37Aopzbtzczi8XWdkFTvBeSyYgXLuUkaodkq59xBCT ETqajtkz4xcsB397qTBPetprR8jMC3JszkjJJp3cjWJS QMaHW2Fpyet4ZVf7jgrGB6iirZLjwZUjN9vPKcpQrHs --threshold=2`

## Actions

Rest of the commands will adhere to the following flow:

### Create

You run a regular command, and you just replace `gauntlet` with `gauntlet-serum-multisig`.
For e.g if you have this command: `yarn gauntlet ocr2:set_billing --network=local --state=k91NrbTgTt4bo86fXN3SXqUzVvoDRiivxf2KcU1p5Gp`
it becomes:
`yarn gauntlet-serum-multisig ocr2:set_billing --network=local --state=k91NrbTgTt4bo86fXN3SXqUzVvoDRiivxf2KcU1p5Gp`

The creator automatically signs/approves the proposal.
You get a message on console with the proposal PublicKey that you need for continuing on next actions

### Approve/Execute

You run a previously created command and you just append `--proposal` flag,
For e.g for the above: `yarn gauntlet-serum-multisig ocr2:set_billing --network=local --state=k91NrbTgTt4bo86fXN3SXqUzVvoDRiivxf2KcU1p5Gp --proposal=CyU1HR7Ebs4aQVQVabT6KeNFusHqov1nwCpCDs9CRZhw`

If the threshold is met, the proposal is executed. Else, you need to repeat the action for the rest of the owners until the threshold is met.

### Setting owners

Example of setting one more owner to the above created multisig

`yarn gauntlet-serum-multisig set:owners --network=local QMaHW2Fpyet4ZVf7jgrGB6iirZLjwZUjN9vPKcpQrHs ETqajtkz4xcsB397qTBPetprR8jMC3JszkjJJp3cjWJS 3W37Aopzbtzczi8XWdkFTvBeSyYgXLuUkaodkq59xBCT 8i1ZbY9S7VPV4AKVEL1xewyYFvtkrjAnffqfsCf3FoRB`

### Setting threshold

Example of setting threshold to 3

`yarn gauntlet-serum-multisig set:threshold --network=local --threshold=1`
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
NODE_URL=https://api.devnet.solana.com

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NODE_URL=http://127.0.0.1:8899
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NODE_URL=https://api.testnet.solana.com
34 changes: 34 additions & 0 deletions gauntlet/packages/gauntlet-serum-multisig/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@chainlink/gauntlet-serum-multisig",
"version": "0.0.1",
"description": "Gauntlet Serum Multisig",
"keywords": [
"typescript",
"cli"
],
"main": "./dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/**/*",
"!dist/**/*.test.js"
],
"scripts": {
"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-solana": "*",
"@chainlink/gauntlet-solana-contracts": "*",
"@project-serum/anchor": "^0.20.0",
"@solana/web3.js": "^1.30.2"
}
}
73 changes: 73 additions & 0 deletions gauntlet/packages/gauntlet-serum-multisig/src/commands/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Result } from '@chainlink/gauntlet-core'
import { logger } from '@chainlink/gauntlet-core/dist/utils'
import { SolanaCommand, TransactionResponse } from '@chainlink/gauntlet-solana'
import { PublicKey, SYSVAR_RENT_PUBKEY, Keypair } from '@solana/web3.js'
import BN from 'bn.js'
import { CONTRACT_LIST, getContract } from '@chainlink/gauntlet-solana-contracts'

type Input = {
owners: string[]
threshold: number | string
}

const DEFAULT_MAXIMUM_SIZE = 200

export default class MultisigCreate extends SolanaCommand {
static id = 'create'
static category = CONTRACT_LIST.MULTISIG

static examples = ['yarn gauntlet-serum-multisig create --network=local']

constructor(flags, args) {
super(flags, args)
this.requireFlag('threshold', 'Please provide multisig threshold')
}

makeInput = (userInput: any): Input => {
if (userInput) return userInput as Input

return {
//TODO: validate args. Maybe wrap them int PublicKey?
owners: this.args,
threshold: this.flags.threshold,
}
}

execute = async () => {
this.require(this.args.length > 0, 'Please provide at least one owner as an argument')
const contract = getContract(CONTRACT_LIST.MULTISIG, '')
const address = contract.programId.toString()
const program = this.loadProgram(contract.idl, address)

const input = this.makeInput(this.flags.input)

const multisig = Keypair.generate()

const [multisigSigner, nonce] = await PublicKey.findProgramAddress(
[multisig.publicKey.toBuffer()],
program.programId,
)
const maximumSize = this.flags.maximumSize || DEFAULT_MAXIMUM_SIZE
const owners = input.owners.map((key) => new PublicKey(key))

const tx = await program.rpc.createMultisig(owners, new BN(input.threshold), nonce, {
accounts: {
multisig: multisig.publicKey,
rent: SYSVAR_RENT_PUBKEY,
},
signers: [multisig],
instructions: [await program.account.multisig.createInstruction(multisig, maximumSize)],
})
logger.info(`Multisig address: ${multisig.publicKey}`)
logger.info(`Multisig Signer: ${multisigSigner.toString()}`)

return {
responses: [
{
tx: this.wrapResponse(tx, multisig.publicKey.toString()),
contract: multisig.publicKey.toString(),
},
],
} as Result<TransactionResponse>
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import SetOwners from './setOwners'
import SetThreshold from './setThreshold'

export default [SetOwners, SetThreshold]
202 changes: 202 additions & 0 deletions gauntlet/packages/gauntlet-serum-multisig/src/commands/multisig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { Result } from '@chainlink/gauntlet-core'
import { TransactionResponse, SolanaCommand, RawTransaction } from '@chainlink/gauntlet-solana'
import { logger, BN } from '@chainlink/gauntlet-core/dist/utils'
import { PublicKey, SYSVAR_RENT_PUBKEY, Keypair } from '@solana/web3.js'
import { CONTRACT_LIST, getContract } from '@chainlink/gauntlet-solana-contracts'
import { Idl, Program } from '@project-serum/anchor'

type ProposalContext = {
rawTx: RawTransaction
multisigSigner: PublicKey
proposalState: any
}

type ProposalAction = (proposal: Keypair | PublicKey, context: ProposalContext) => Promise<string>

export const wrapCommand = (command) => {
return class Multisig extends SolanaCommand {
command: SolanaCommand
program: Program<Idl>
multisigAddress: PublicKey

static id = `${command.id}`

constructor(flags, args) {
super(flags, args)
logger.info(`Running ${command.id} command using Serum Multisig`)

this.command = new command(flags, args)
this.command.invokeMiddlewares(this.command, this.command.middlewares)
this.require(!!process.env.MULTISIG_ADDRESS, 'Please set MULTISIG_ADDRESS env var')
this.multisigAddress = new PublicKey(process.env.MULTISIG_ADDRESS)
}

getRemainingSigners = (proposalState: any, threshold: number): number =>
Number(threshold) - proposalState.signers.filter(Boolean).length

isReadyForExecution = (proposalState: any, threshold: number): boolean => {
return this.getRemainingSigners(proposalState, threshold) <= 0
}

execute = async () => {
const multisig = getContract(CONTRACT_LIST.MULTISIG, '')
this.program = this.loadProgram(multisig.idl, multisig.programId.toString())
const [multisigSigner] = await PublicKey.findProgramAddress(
[this.multisigAddress.toBuffer()],
this.program.programId,
)
const multisigState = await this.program.account.multisig.fetch(process.env.MULTISIG_ADDRESS)
const threshold = multisigState.threshold
const owners = multisigState.owners

logger.info(`Multisig Info:
- Address: ${this.multisigAddress.toString()}
- Signer: ${multisigSigner.toString()}
- Threshold: ${threshold.toString()}
- Owners: ${owners}`)

// TODO: Should we support many txs?
const rawTx = (await this.command.makeRawTransaction(multisigSigner))[0]
const isCreation = !this.flags.proposal
if (isCreation) {
const proposal = Keypair.generate()
const result = await this.wrapAction(this.createProposal)(proposal, {
rawTx,
multisigSigner,
proposalState: {},
})
this.inspectProposalState(proposal.publicKey, threshold, owners)
return result
}

const proposal = new PublicKey(this.flags.proposal)
const proposalState = await this.program.account.transaction.fetch(proposal)
const proposalContext = {
rawTx,
multisigSigner,
proposalState,
}

logger.debug(`Proposal state: ${JSON.stringify(proposalState, null, 4)}`)

const isAlreadyExecuted = proposalState.didExecute
if (isAlreadyExecuted) {
logger.info(`Proposal is already executed`)
return {} as Result<TransactionResponse>
}

if (!this.isReadyForExecution(proposalState, threshold)) {
const result = await this.wrapAction(this.approveProposal)(proposal, proposalContext)
this.inspectProposalState(proposal, threshold, owners)
return result
}

const result = await this.wrapAction(this.executeProposal)(proposal, proposalContext)
this.inspectProposalState(proposal, threshold, owners)
return result
}

wrapAction = (action: ProposalAction) => async (proposal: Keypair | PublicKey, context: ProposalContext) => {
try {
const tx = await action(proposal, context)
return {
responses: [
{
tx: this.wrapResponse(tx, proposal.toString()),
contract: proposal.toString(),
},
],
} as Result<TransactionResponse>
} catch (e) {
// known errors, defined in multisig contract. see serum_multisig.json
if (e.code >= 300 && e.code < 400) {
logger.error(e.msg)
} else {
logger.error(e)
}
return {} as Result<TransactionResponse>
}
}

createProposal: ProposalAction = async (proposal: Keypair, context): Promise<string> => {
logger.loading(`Creating proposal`)
const txSize = 1000
const tx = await this.program.rpc.createTransaction(
context.rawTx.programId,
context.rawTx.accounts,
context.rawTx.data,
{
accounts: {
multisig: this.multisigAddress,
transaction: proposal.publicKey,
proposer: this.wallet.payer.publicKey,
rent: SYSVAR_RENT_PUBKEY,
},
instructions: [await this.program.account.transaction.createInstruction(proposal, txSize)],
signers: [proposal, this.wallet.payer],
},
)
return tx
}

approveProposal: ProposalAction = async (proposal: PublicKey): Promise<string> => {
logger.loading(`Approving proposal`)
const tx = await this.program.rpc.approve({
accounts: {
multisig: this.multisigAddress,
transaction: proposal,
owner: this.wallet.publicKey,
},
})
return tx
}

executeProposal: ProposalAction = async (proposal: PublicKey, context): Promise<string> => {
logger.loading(`Executing proposal`)

const tx = await this.program.rpc.executeTransaction({
accounts: {
multisig: this.multisigAddress,
multisigSigner: context.multisigSigner,
transaction: proposal,
},
remainingAccounts: context.proposalState.accounts
.map((t) => (t.pubkey.equals(context.multisigSigner) ? { ...t, isSigner: false } : t))
.concat({
pubkey: context.proposalState.programId,
isWritable: false,
isSigner: false,
}),
})
logger.info(`Execution TX hash: ${tx.toString()}`)
return tx
}

inspectProposalState = async (proposal, threshold, owners) => {
const proposalState = await this.program.account.transaction.fetch(proposal)
logger.debug('Proposal state after action:')
logger.debug(JSON.stringify(proposalState, null, 4))
if (proposalState.didExecute == true) {
logger.info(`Proposal has been executed`)
return
}

if (this.isReadyForExecution(proposalState, threshold)) {
logger.info(
`Threshold has been met, an owner needs to run the command once more in order to execute it, with flag --proposal=${proposal}`,
)
return
}
// inverting the signers boolean array and filtering owners by it
const remainingEligibleSigners = owners.filter((_, i) => proposalState.signers.map((s) => !s)[i])
logger.info(
`${this.getRemainingSigners(
proposalState,
threshold,
)} more owners should sign this proposal, using the same command with flag --proposal=${proposal}`,
)
logger.info(`Eligible owners to sign: `)
logger.info(remainingEligibleSigners.toString())
}
}
}
Loading

0 comments on commit 6ca7765

Please sign in to comment.