diff --git a/packages/dispatch/.env.example b/packages/dispatch/.env.example deleted file mode 100644 index 766f80ac2..000000000 --- a/packages/dispatch/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -MNEMONIC=abc def ghi -DAO_CONTRACTS_DIR=/Users/user/Developer/dao-contracts/artifacts -POLYTONE_CONTRACTS_DIR=/Users/user/Developer/polytone/artifacts diff --git a/packages/dispatch/.gitignore b/packages/dispatch/.gitignore index 4c49bd78f..5b6c0960c 100644 --- a/packages/dispatch/.gitignore +++ b/packages/dispatch/.gitignore @@ -1 +1 @@ -.env +config.toml diff --git a/packages/dispatch/README.md b/packages/dispatch/README.md index 6bf67a411..992b48d97 100644 --- a/packages/dispatch/README.md +++ b/packages/dispatch/README.md @@ -4,8 +4,8 @@ DAO DAO Dispatch. Control center for DAO DAO. ## Usage -Make sure to copy `.env.example` to `.env` and set the environment variables -correctly. +Make sure to copy `config.toml.example` to `config.toml` and configure it +appropriately. ### Deploy @@ -20,11 +20,12 @@ behalf of the granter you pass to `-a`. Usage: yarn deploy [options] Options: - -c, --chain chain ID - -p, --polytone only deploy polytone contracts - -a, --authz upload contracts via authz exec as this granter - -x, --exclude ignore contracts containing any of these comma-separated substrings (e.g. cw721) - -h, --help display help for command + -c, --chain chain ID + -m, --mode deploy mode (dao = deploy DAO contracts and instantiate admin factory, polytone = deploy Polytone contracts, factory = instantiate admin factory) (default: "dao") + -v, --version contract version to save code IDs under in the config when deploying DAO contracts (e.g. 1.0.0) + -a, --authz upload contracts via authz exec as this granter + -r, --restrict-instantiation restrict instantiation to only the uploader; this must be used on some chains to upload contracts, like Kujira + -h, --help display help for command ``` ### Polytone diff --git a/packages/dispatch/config.toml.example b/packages/dispatch/config.toml.example new file mode 100644 index 000000000..424115733 --- /dev/null +++ b/packages/dispatch/config.toml.example @@ -0,0 +1,8 @@ +### The mnemonic to use for signing transactions. +mnemonic = "abc def ghi" + +### The directories to look in for compiled contracts. +contract_dirs = [ + "/Users/user/Developer/dao-contracts/artifacts", + "/Users/user/Developer/polytone/artifacts", +] diff --git a/packages/dispatch/package.json b/packages/dispatch/package.json index 1940c26a9..700259bb9 100644 --- a/packages/dispatch/package.json +++ b/packages/dispatch/package.json @@ -6,8 +6,8 @@ "scripts": { "format": "eslint . --fix", "lint": "eslint .", - "deploy": "tsx ./scripts/deploy.ts", - "polytone": "tsx ./scripts/polytone.ts" + "deploy": "tsx ./scripts/deploy/run.ts", + "polytone": "tsx ./scripts/polytone/run.ts" }, "devDependencies": { "@confio/relayer": "^0.12.0", @@ -15,11 +15,13 @@ "@dao-dao/state": "2.5.0-rc.3", "@dao-dao/types": "2.5.0-rc.3", "@dao-dao/utils": "2.5.0-rc.3", + "@types/proper-lockfile": "^4.1.4", "chalk": "^4", "commander": "^11.0.0", "dotenv": "^16.4.5", "eslint": "^8.23.1", "sinon": "^18.0.0", + "toml": "^3.0.0", "tsx": "^4.19.0", "typescript": "5.3.3" }, @@ -29,6 +31,7 @@ "@cosmjs/crypto": "^0.32.3", "@cosmjs/proto-signing": "^0.32.3", "@cosmjs/stargate": "^0.32.3", - "@cosmjs/tendermint-rpc": "^0.32.3" + "@cosmjs/tendermint-rpc": "^0.32.3", + "proper-lockfile": "^4.1.2" } } diff --git a/packages/dispatch/scripts/deploy.ts b/packages/dispatch/scripts/deploy.ts deleted file mode 100644 index 109573fd1..000000000 --- a/packages/dispatch/scripts/deploy.ts +++ /dev/null @@ -1,425 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' -import { stringToPath as stringToHdPath } from '@cosmjs/crypto' -import { DirectSecp256k1HdWallet, EncodeObject } from '@cosmjs/proto-signing' -import chalk from 'chalk' -import { Command } from 'commander' -import dotenv from 'dotenv' - -import { - chainQueries, - makeGetSignerOptions, - makeReactQueryClient, -} from '@dao-dao/state' -import { ContractVersion, SupportedChainConfig } from '@dao-dao/types' -import { MsgExec } from '@dao-dao/types/protobuf/codegen/cosmos/authz/v1beta1/tx' -import { MsgStoreCode } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/tx' -import { AccessType } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/types' -import { - CHAIN_GAS_MULTIPLIER, - findEventsAttributeValue, - getChainForChainId, - getRpcForChainId, - gzipCompress, -} from '@dao-dao/utils' - -import { instantiateContract } from './utils' - -const { log } = console - -const { parsed: { MNEMONIC, DAO_CONTRACTS_DIR, POLYTONE_CONTRACTS_DIR } = {} } = - dotenv.config() - -if (!MNEMONIC) { - log(chalk.red('MNEMONIC not set')) - process.exit(1) -} -if (!DAO_CONTRACTS_DIR) { - log(chalk.red('DAO_CONTRACTS_DIR not set')) - process.exit(1) -} -if (!POLYTONE_CONTRACTS_DIR) { - log(chalk.red('POLYTONE_CONTRACTS_DIR not set')) - process.exit(1) -} - -enum Mode { - Dao = 'dao', - Polytone = 'polytone', - Factory = 'factory', -} - -const program = new Command() -program.requiredOption('-c, --chain ', 'chain ID') -program.option( - '-m, --mode ', - 'deploy mode (dao = deploy DAO contracts and instantiate admin factory, polytone = deploy Polytone contracts, factory = instantiate admin factory)', - 'dao' -) -program.option( - '-a, --authz ', - 'upload contracts via authz exec as this granter' -) -program.option( - '-x, --exclude ', - 'ignore contracts containing any of these comma-separated substrings (e.g. cw721)' -) -program.option( - '-i, --include ', - 'only deploy contracts containing any of these comma-separated substrings (e.g. cw721)' -) -program.option( - '-r, --restrict-instantiation', - 'restrict instantiation to only the uploader; this must be used on some chains to upload contracts, like Kujira' -) - -program.parse(process.argv) -const { - chain: chainId, - mode, - authz, - exclude: _exclude, - include: _include, - restrictInstantiation, -} = program.opts() - -const exclude: string[] | undefined = _exclude?.split(',') -const include: string[] | undefined = _include?.split(',') - -if (!Object.values(Mode).includes(mode)) { - log( - chalk.red('Invalid mode. Must be one of: ' + Object.values(Mode).join(', ')) - ) - process.exit(1) -} - -const codeIdMap: Record = {} - -const main = async () => { - const queryClient = await makeReactQueryClient() - - const { - chainName, - bech32Prefix, - chainRegistry: { network_type: networkType, slip44 } = {}, - } = getChainForChainId(chainId) - - await queryClient.prefetchQuery(chainQueries.dynamicGasPrice({ chainId })) - - const signer = await DirectSecp256k1HdWallet.fromMnemonic(MNEMONIC, { - prefix: bech32Prefix, - hdPaths: [stringToHdPath(`m/44'/${slip44}'/0'/0/0`)], - }) - const sender = (await signer.getAccounts())[0].address - - log() - log( - chalk.underline( - `Deploying on ${chainName} from ${sender}${ - authz ? ` as ${authz}` : '' - }...` - ) - ) - - const client = await SigningCosmWasmClient.connectWithSigner( - getRpcForChainId(chainId), - signer, - makeGetSignerOptions(queryClient)(chainName) - ) - - const uploadContract = async ({ - id, - file, - prefixLength, - restrictInstantiation, - }: { - id: string - file: string - prefixLength: number - restrictInstantiation?: boolean - }) => { - const wasmData = new Uint8Array(fs.readFileSync(file).buffer) - const compressedWasmData = await gzipCompress(wasmData) - - const msgStoreCode = MsgStoreCode.fromPartial({ - sender: authz || sender, - wasmByteCode: compressedWasmData, - instantiatePermission: restrictInstantiation - ? { - permission: AccessType.AnyOfAddresses, - addresses: [authz || sender], - } - : { - permission: AccessType.Everybody, - addresses: [], - }, - }) - - const msg: EncodeObject = authz - ? { - typeUrl: MsgExec.typeUrl, - value: MsgExec.fromPartial({ - grantee: sender, - msgs: [MsgStoreCode.toProtoMsg(msgStoreCode)], - }), - } - : { - typeUrl: MsgStoreCode.typeUrl, - value: msgStoreCode, - } - - let transactionHash - try { - transactionHash = await client.signAndBroadcastSync( - sender, - [msg], - CHAIN_GAS_MULTIPLIER - ) - } catch (err) { - if ( - err instanceof Error && - err.message.includes('authorization not found') - ) { - log( - chalk.red( - `[${id}.CODE_ID]${' '.repeat( - prefixLength - id.length - 10 - )}no authz permission granted` - ) - ) - process.exit(1) - } else { - log( - chalk.red( - `[${id}.CODE_ID]${' '.repeat(prefixLength - id.length - 10)}failed` - ) - ) - throw err - } - } - - log( - chalk.greenBright( - `[${id}.TX]${' '.repeat( - prefixLength - id.length - 5 - )}${transactionHash}` - ) - ) - - // Poll for TX. - let events - let tries = 15 - while (tries > 0) { - try { - events = (await client.getTx(transactionHash))?.events - if (events) { - break - } - } catch {} - - tries-- - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - - if (!events) { - log( - chalk.red( - `[${id}.CODE_ID]${' '.repeat( - prefixLength - id.length - 10 - )}TX not found` - ) - ) - process.exit(1) - } - - const codeId = findEventsAttributeValue(events, 'store_code', 'code_id') - - if (!codeId) { - log( - chalk.red( - `[${id}.CODE_ID]${' '.repeat(prefixLength - id.length - 10)}not found` - ) - ) - process.exit(1) - } - - log( - chalk.green( - `[${id}.CODE_ID]${' '.repeat(prefixLength - id.length - 10)}${codeId}` - ) - ) - - return Number(codeId) - } - - log() - - // Upload polytone contracts only. - if (mode === Mode.Polytone) { - const contracts = [ - 'polytone_listener', - 'polytone_note', - 'polytone_proxy', - 'polytone_voice', - ] - - for (const contract of contracts) { - const file = path.join(POLYTONE_CONTRACTS_DIR, `${contract}.wasm`) - - await uploadContract({ - id: contract, - file, - prefixLength: 32, - restrictInstantiation, - }) - } - - log() - process.exit(0) - } - - let consolePrefixLength = 32 - - // Upload DAO contracts. - if (mode === Mode.Dao) { - // List files in the contracts directory. - const contracts = fs - .readdirSync(DAO_CONTRACTS_DIR) - .filter((file) => file.endsWith('.wasm')) - .sort() - - // Set console prefix length to the max file length plus space for brackets - // and longest ID suffix (CONTRACT). - consolePrefixLength = Math.max(...contracts.map((file) => file.length)) + 10 - - try { - for (const contract of contracts) { - const id = contract.slice(0, -5) - if (exclude?.some((substring) => id.includes(substring))) { - continue - } - if (include && !include.some((substring) => id.includes(substring))) { - continue - } - - const file = path.join(DAO_CONTRACTS_DIR, contract) - - if (!(id in codeIdMap)) { - codeIdMap[id] = await uploadContract({ - id, - file, - prefixLength: consolePrefixLength, - restrictInstantiation, - }) - } else { - log( - chalk.green( - `[${id}.CODE_ID]${' '.repeat( - consolePrefixLength - id.length - 10 - )}${codeIdMap[id]}` - ) - ) - } - } - } catch (err) { - // Log current Code ID Map to make it easy to restart the script and reuse - // already uploaded contracts. - log( - chalk.red( - 'Error uploading contracts. Current Code ID Map: \n' + - JSON.stringify(codeIdMap, null, 2) - ) - ) - - throw err - } - } - - // Upload just admin factory if needed. - else if (mode === Mode.Factory && !codeIdMap['cw_admin_factory']) { - const file = path.join(DAO_CONTRACTS_DIR, 'cw_admin_factory.wasm') - if (!fs.existsSync(file)) { - log(chalk.red('cw_admin_factory.wasm not found')) - process.exit(1) - } - - codeIdMap['cw_admin_factory'] = await uploadContract({ - id: 'cw_admin_factory', - file, - prefixLength: consolePrefixLength, - restrictInstantiation, - }) - } - - // Instantiate admin factory. - const cwAdminFactoryCodeId = codeIdMap['cw_admin_factory'] - if (!cwAdminFactoryCodeId) { - log() - log( - chalk.blueBright('cw_admin_factory.CODE_ID not found, not instantiating') - ) - } - - const adminFactoryAddress = cwAdminFactoryCodeId - ? await instantiateContract({ - client, - sender, - chainId, - id: 'cw_admin_factory', - codeId: cwAdminFactoryCodeId, - msg: {}, - label: 'daodao_admin_factory', - prefixLength: consolePrefixLength, - }) - : '' - - log() - log(chalk.green('Done! Config entries:')) - - const mainnet = networkType === 'mainnet' - const explorerUrlDomain = mainnet ? 'ping.pub' : 'testnet.ping.pub' - - const config: Omit = { - chainId, - name: chainName, - mainnet, - accentColor: 'ACCENT_COLOR', - factoryContractAddress: adminFactoryAddress, - explorerUrlTemplates: { - tx: `https://${explorerUrlDomain}/${chainName}/tx/REPLACE`, - gov: `https://${explorerUrlDomain}/${chainName}/gov`, - govProp: `https://${explorerUrlDomain}/${chainName}/gov/REPLACE`, - wallet: `https://${explorerUrlDomain}/${chainName}/account/REPLACE`, - }, - latestVersion: ContractVersion.Unknown, - } - - const allCodeIds = { - [chainId]: { - [ContractVersion.Unknown]: Object.fromEntries( - Object.entries(codeIdMap).map(([key, value]) => [ - key - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(''), - value ?? -1, - ]) - ), - }, - } - - log(JSON.stringify(config, null, 2)) - - log() - - log(JSON.stringify(allCodeIds, null, 2)) -} - -main() - .then(() => process.exit(0)) - .catch((error) => { - console.error(error) - process.exit(1) - }) diff --git a/packages/dispatch/scripts/deploy/CodeIdConfig.ts b/packages/dispatch/scripts/deploy/CodeIdConfig.ts new file mode 100644 index 000000000..bcad2d027 --- /dev/null +++ b/packages/dispatch/scripts/deploy/CodeIdConfig.ts @@ -0,0 +1,213 @@ +import fs from 'fs' +import path from 'path' + +import chalk from 'chalk' +import lockfile from 'proper-lockfile' +import semverCompare from 'semver/functions/compare' + +/** + * Path to all uploaded code IDs. + */ +const codeIdsPath = path.join( + __dirname, + '../../../utils/constants/codeIds.json' +) + +type CodeIds = Record>> + +/** + * A class that manages the code ID config. + */ +export class CodeIdConfig { + private _codeIds: CodeIds = {} + + constructor() { + if (!fs.existsSync(codeIdsPath)) { + console.log(chalk.red(`Code IDs file not found at ${codeIdsPath}`)) + process.exit(1) + } + } + + get codeIds() { + return this._codeIds + } + + private load() { + this._codeIds = JSON.parse(fs.readFileSync(codeIdsPath, 'utf8')) + } + + private save() { + fs.writeFileSync(codeIdsPath, JSON.stringify(this._codeIds, null, 2)) + } + + async setCodeId({ + chainId, + version, + name: _name, + codeId, + }: { + /** + * The chain ID. + */ + chainId: string + /** + * The contract version being deployed. + */ + version: string + /** + * The contract name being deployed. + */ + name: string + /** + * The code ID being set. + */ + codeId: number + }) { + // Establish lock. + const releaseLock = await lockfile.lock(codeIdsPath, { + retries: { + forever: true, + minTimeout: 100, + factor: 1.1, + randomize: true, + }, + }) + + try { + const name = contractNameToCodeIdName(_name) + + this.load() + + if (!this._codeIds[chainId]) { + this._codeIds[chainId] = {} + } + if (!this._codeIds[chainId][version]) { + this._codeIds[chainId][version] = {} + } + + this._codeIds[chainId][version][name] = codeId + + this.save() + } finally { + // Release lock. + await releaseLock() + } + } + + /** + * Get the most recent code ID and version for this chain, or null if code ID + * has not been set for this chain. + */ + async getLatestCodeId({ + chainId, + name: _name, + }: { + /** + * The chain ID. + */ + chainId: string + /** + * The contract name. + */ + name: string + }): Promise<{ + /** + * The latest version of the contract found. + */ + version: string + /** + * The code ID. + */ + codeId: number + } | null> { + // Establish lock. + const releaseLock = await lockfile.lock(codeIdsPath, { + retries: { + forever: true, + minTimeout: 100, + factor: 1.1, + randomize: true, + }, + }) + + try { + const name = contractNameToCodeIdName(_name) + + this.load() + + const versionsDescending = Object.keys(this._codeIds[chainId]) + .sort(semverCompare) + .reverse() + + for (const version of versionsDescending) { + if (typeof this._codeIds[chainId]?.[version]?.[name] === 'number') { + return { + version, + codeId: this._codeIds[chainId][version][name], + } + } + } + + return null + } finally { + // Release lock. + await releaseLock() + } + } + + /** + * Return the code ID for this chain and version, or null if not set. + */ + async getCodeId({ + chainId, + name: _name, + version, + }: { + /** + * The chain ID. + */ + chainId: string + /** + * The contract name. + */ + name: string + /** + * The version of the contract. + */ + version: string + }): Promise { + // Establish lock. + const releaseLock = await lockfile.lock(codeIdsPath, { + retries: { + forever: true, + minTimeout: 100, + factor: 1.1, + randomize: true, + }, + }) + + try { + const name = contractNameToCodeIdName(_name) + + this.load() + + if (typeof this._codeIds[chainId]?.[version]?.[name] === 'number') { + return this._codeIds[chainId][version][name] + } + + return null + } finally { + // Release lock. + await releaseLock() + } + } +} + +/** + * Convert snake case contract name to title case code ID name. + */ +export const contractNameToCodeIdName = (name: string) => + name + .split(/[_\-]/g) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join('') diff --git a/packages/dispatch/scripts/deploy/config.ts b/packages/dispatch/scripts/deploy/config.ts new file mode 100644 index 000000000..39fa9ced7 --- /dev/null +++ b/packages/dispatch/scripts/deploy/config.ts @@ -0,0 +1,286 @@ +import { ChainId } from '@dao-dao/types' + +type DeploySet = { + name: string + /** + * The type of set to deploy. + * + * - `once`: Only deploy the set once. If the contracts already exist, do not + * deploy new versions. + * - `always`: Always deploy new versions of the contracts. + * - `manual`: Do not deploy automatically, but store for manual deployment. + */ + type: 'once' | 'always' | 'manual' + /** + * Contracts to deploy. + */ + contracts: string[] + /** + * If defined, only deploy the set for the given chain IDs. + */ + chainIds?: string[] + /** + * If defined, skip the set for the given chain IDs. + */ + skipChainIds?: string[] +} + +/** + * List of contracts that should deploy on each chain. + */ +export const deploySets: DeploySet[] = [ + // the polytone contracts to deploy manually + { + name: 'polytone', + type: 'manual', + contracts: [ + 'polytone_listener', + 'polytone_note', + 'polytone_proxy', + 'polytone_voice', + ], + }, + + // the contracts to deploy on all chains once + { + name: 'external', + type: 'once', + contracts: ['cw1_whitelist', 'cw4_group'], + }, + + // the contracts to deploy on all chains every time + { + name: 'core DAO stuff', + type: 'always', + contracts: [ + 'cw_admin_factory', + 'cw_payroll_factory', + 'cw_token_swap', + 'dao_dao_core', + 'dao_pre_propose_approval_single', + 'dao_pre_propose_approver', + 'dao_pre_propose_multiple', + 'dao_pre_propose_single', + 'dao_proposal_multiple', + 'dao_proposal_single', + 'dao_rewards_distributor', + 'dao_voting_cw4', + ], + }, + + // cw-vesting with staking, which all chains but Neutron support + { + name: 'cw-vesting with staking', + type: 'always', + contracts: ['cw_vesting-staking'], + skipChainIds: [ChainId.NeutronMainnet, ChainId.NeutronTestnet], + }, + + // cw-vesting without staking + { + name: 'cw-vesting without staking', + type: 'always', + contracts: ['cw_vesting-no_staking'], + chainIds: [ChainId.NeutronMainnet, ChainId.NeutronTestnet], + }, + + // cw20 contract to deploy once + { + name: 'cw20 base', + type: 'once', + contracts: ['cw20_base'], + chainIds: [ + ChainId.JunoMainnet, + ChainId.JunoTestnet, + + 'layer', + + ChainId.OraichainMainnet, + + ChainId.TerraMainnet, + ChainId.TerraClassicMainnet, + ], + }, + + // cw20 contracts to deploy every time + { + name: 'cw20 DAO stuff', + type: 'always', + contracts: ['cw20_stake', 'dao_voting_cw20_staked'], + chainIds: [ + ChainId.JunoMainnet, + ChainId.JunoTestnet, + + 'layer', + + ChainId.OraichainMainnet, + + ChainId.TerraMainnet, + ChainId.TerraClassicMainnet, + ], + }, + + // cw721 contract to deploy once + { + name: 'cw721 base', + type: 'once', + contracts: ['cw721_base'], + chainIds: [ + ChainId.JunoMainnet, + ChainId.JunoTestnet, + + ChainId.KujiraMainnet, + ChainId.KujiraTestnet, + + 'layer', + + ChainId.MigalooMainnet, + ChainId.MigalooTestnet, + + ChainId.NeutronMainnet, + ChainId.NeutronTestnet, + + ChainId.OraichainMainnet, + + ChainId.OsmosisMainnet, + ChainId.OsmosisTestnet, + + ChainId.TerraMainnet, + ChainId.TerraClassicMainnet, + ], + }, + + // cw721 contracts to deploy every time + { + name: 'cw721 DAO stuff', + type: 'always', + contracts: ['dao_voting_cw721_staked'], + chainIds: [ + ChainId.BitsongMainnet, + ChainId.BitsongTestnet, + + ChainId.JunoMainnet, + ChainId.JunoTestnet, + + ChainId.KujiraMainnet, + ChainId.KujiraTestnet, + + 'layer', + + ChainId.MigalooMainnet, + ChainId.MigalooTestnet, + + ChainId.NeutronMainnet, + ChainId.NeutronTestnet, + + ChainId.OraichainMainnet, + + ChainId.OsmosisMainnet, + ChainId.OsmosisTestnet, + + ChainId.StargazeMainnet, + ChainId.StargazeTestnet, + + ChainId.TerraMainnet, + ChainId.TerraClassicMainnet, + ], + }, + + // token factory contract to deploy every time + { + name: 'token factory', + type: 'always', + contracts: ['cw_tokenfactory_issuer'], + chainIds: [ + ChainId.JunoMainnet, + ChainId.JunoTestnet, + + 'layer', + + ChainId.MigalooMainnet, + ChainId.MigalooTestnet, + + ChainId.NeutronMainnet, + ChainId.NeutronTestnet, + + ChainId.OmniflixHubMainnet, + ChainId.OmniflixHubTestnet, + + ChainId.OraichainMainnet, + + ChainId.OsmosisMainnet, + ChainId.OsmosisTestnet, + + ChainId.StargazeMainnet, + ChainId.StargazeTestnet, + + ChainId.TerraMainnet, + ], + }, + + // token factory kujira contract to deploy every time + { + name: 'token factory kujira', + type: 'always', + contracts: ['cw_tokenfactory_issuer-kujira'], + chainIds: [ChainId.KujiraMainnet, ChainId.KujiraTestnet], + }, + + // token staking contract to deploy every time + { + name: 'token staking', + type: 'always', + contracts: ['dao_voting_token_staked'], + chainIds: [ + ChainId.BitsongMainnet, + ChainId.BitsongTestnet, + + ChainId.CosmosHubMainnet, + ChainId.CosmosHubThetaTestnet, + ChainId.CosmosHubProviderTestnet, + + ChainId.JunoMainnet, + ChainId.JunoTestnet, + + ChainId.KujiraMainnet, + ChainId.KujiraTestnet, + + 'layer', + + ChainId.MigalooMainnet, + ChainId.MigalooTestnet, + + ChainId.NeutronMainnet, + ChainId.NeutronTestnet, + + ChainId.OmniflixHubMainnet, + ChainId.OmniflixHubTestnet, + + ChainId.OraichainMainnet, + + ChainId.OsmosisMainnet, + ChainId.OsmosisTestnet, + + ChainId.StargazeMainnet, + ChainId.StargazeTestnet, + + ChainId.TerraMainnet, + ], + }, + + // bitsong contract to deploy every time + { + name: 'bitsong', + type: 'always', + contracts: ['btsg_ft_factory'], + chainIds: [ChainId.BitsongMainnet, ChainId.BitsongTestnet], + }, + + // omniflix NFT staking to deploy every time + { + name: 'omniflix', + type: 'always', + contracts: ['dao_voting_onft_staked'], + chainIds: [ChainId.OmniflixHubMainnet, ChainId.OmniflixHubTestnet], + }, +] diff --git a/packages/dispatch/scripts/deploy/run.ts b/packages/dispatch/scripts/deploy/run.ts new file mode 100644 index 000000000..15fcf4152 --- /dev/null +++ b/packages/dispatch/scripts/deploy/run.ts @@ -0,0 +1,513 @@ +import fs from 'fs' +import path from 'path' + +import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' +import { stringToPath as stringToHdPath } from '@cosmjs/crypto' +import { DirectSecp256k1HdWallet, EncodeObject } from '@cosmjs/proto-signing' +import chalk from 'chalk' +import { Command } from 'commander' +import toml from 'toml' + +import { + chainQueries, + makeGetSignerOptions, + makeReactQueryClient, +} from '@dao-dao/state' +import { ContractVersion, SupportedChainConfig } from '@dao-dao/types' +import { MsgExec } from '@dao-dao/types/protobuf/codegen/cosmos/authz/v1beta1/tx' +import { MsgStoreCode } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/tx' +import { AccessType } from '@dao-dao/types/protobuf/codegen/cosmwasm/wasm/v1/types' +import { + CHAIN_GAS_MULTIPLIER, + findEventsAttributeValue, + getChainForChainId, + getRpcForChainId, + gzipCompress, +} from '@dao-dao/utils' + +import { instantiateContract } from '../utils' +import { CodeIdConfig } from './CodeIdConfig' +import { deploySets } from './config' + +const { log } = console + +/** + * Path to the config file. + */ +const configPath = path.join(__dirname, '../../config.toml') + +if (!fs.existsSync(configPath)) { + log(chalk.red(`Config file not found at ${configPath}`)) + process.exit(1) +} + +let config: any +try { + config = toml.parse(fs.readFileSync(configPath, 'utf8')) +} catch (err) { + log(chalk.red(`Error parsing ${configPath}: ${err}`)) + process.exit(1) +} + +const { mnemonic, contract_dirs: contractDirs } = config + +if (!mnemonic) { + log(chalk.red('mnemonic not set')) + process.exit(1) +} + +enum Mode { + Dao = 'dao', + Polytone = 'polytone', + Factory = 'factory', +} + +const program = new Command() +program.requiredOption('-c, --chain ', 'chain ID') +program.option( + '-m, --mode ', + 'deploy mode (dao = deploy DAO contracts and instantiate admin factory, polytone = deploy Polytone contracts, factory = instantiate admin factory)', + 'dao' +) +program.option( + '-v, --version ', + 'contract version to save code IDs under in the config when deploying DAO contracts (e.g. 1.0.0)' +) +program.option( + '-a, --authz ', + 'upload contracts via authz exec as this granter' +) +program.option( + '-r, --restrict-instantiation', + 'restrict instantiation to only the uploader; this must be used on some chains to upload contracts, like Kujira' +) + +program.parse(process.argv) +const { + chain: chainId, + mode, + version, + authz, + restrictInstantiation, +} = program.opts() + +if (!Object.values(Mode).includes(mode)) { + log( + chalk.red('Invalid mode. Must be one of: ' + Object.values(Mode).join(', ')) + ) + process.exit(1) +} + +const main = async () => { + const queryClient = await makeReactQueryClient() + + const { + chainName, + bech32Prefix, + chainRegistry: { network_type: networkType, slip44 } = {}, + } = getChainForChainId(chainId) + + const codeIds = new CodeIdConfig() + + await queryClient.prefetchQuery(chainQueries.dynamicGasPrice({ chainId })) + + const signer = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { + prefix: bech32Prefix, + hdPaths: [stringToHdPath(`m/44'/${slip44}'/0'/0/0`)], + }) + const sender = (await signer.getAccounts())[0].address + + log() + log( + chalk.underline( + `Deploying on ${chainName} from ${sender}${ + authz ? ` as ${authz}` : '' + }...` + ) + ) + + const client = await SigningCosmWasmClient.connectWithSigner( + getRpcForChainId(chainId), + signer, + makeGetSignerOptions(queryClient)(chainName) + ) + + const uploadContract = async ({ + id, + file, + prefixLength, + restrictInstantiation, + }: { + id: string + file: string + prefixLength: number + restrictInstantiation?: boolean + }) => { + const wasmData = new Uint8Array(fs.readFileSync(file).buffer) + const compressedWasmData = await gzipCompress(wasmData) + + const msgStoreCode = MsgStoreCode.fromPartial({ + sender: authz || sender, + wasmByteCode: compressedWasmData, + instantiatePermission: restrictInstantiation + ? { + permission: AccessType.AnyOfAddresses, + addresses: [authz || sender], + } + : { + permission: AccessType.Everybody, + addresses: [], + }, + }) + + const msg: EncodeObject = authz + ? { + typeUrl: MsgExec.typeUrl, + value: MsgExec.fromPartial({ + grantee: sender, + msgs: [MsgStoreCode.toProtoMsg(msgStoreCode)], + }), + } + : { + typeUrl: MsgStoreCode.typeUrl, + value: msgStoreCode, + } + + let transactionHash + try { + transactionHash = await client.signAndBroadcastSync( + sender, + [msg], + CHAIN_GAS_MULTIPLIER + ) + } catch (err) { + if ( + err instanceof Error && + err.message.includes('authorization not found') + ) { + log( + chalk.red( + `[${id}]${' '.repeat( + prefixLength - id.length - 5 + )}no authz permission granted` + ) + ) + process.exit(1) + } else { + log( + chalk.red(`[${id}]${' '.repeat(prefixLength - id.length - 5)}failed`) + ) + throw err + } + } + + log( + chalk.greenBright( + `[${id}]${' '.repeat(prefixLength - id.length - 5)}${transactionHash}` + ) + ) + + // Poll for TX. + let events + let tries = 15 + while (tries > 0) { + try { + events = (await client.getTx(transactionHash))?.events + if (events) { + break + } + } catch {} + + tries-- + await new Promise((resolve) => setTimeout(resolve, 200)) + } + + if (!events) { + log( + chalk.red( + `[${id}]${' '.repeat(prefixLength - id.length - 5)}TX not found` + ) + ) + process.exit(1) + } + + const codeId = findEventsAttributeValue(events, 'store_code', 'code_id') + + if (!codeId) { + log( + chalk.red(`[${id}]${' '.repeat(prefixLength - id.length - 5)}not found`) + ) + process.exit(1) + } + + log( + chalk.green(`[${id}]${' '.repeat(prefixLength - id.length - 5)}${codeId}`) + ) + + return Number(codeId) + } + + log() + + const getPathToContract = (contract: string) => { + if ( + !contractDirs || + !Array.isArray(contractDirs) || + contractDirs.length === 0 + ) { + log(chalk.red('contract_dirs not set')) + process.exit(1) + } + + for (const contractDir of contractDirs) { + const file = path.join(contractDir, `${contract}.wasm`) + if (fs.existsSync(file)) { + return file + } + } + + log(chalk.red(`${contract}.wasm not found in contract directories`)) + process.exit(1) + } + + // Upload polytone contracts only. + if (mode === Mode.Polytone) { + const polytoneContracts = deploySets + .find((s) => s.name === 'polytone') + ?.contracts?.map((id) => ({ + id, + file: getPathToContract(id), + })) + if (!polytoneContracts) { + log(chalk.red('polytone deploy set not found')) + process.exit(1) + } + + for (const { id, file } of polytoneContracts) { + await uploadContract({ + id, + file, + prefixLength: 32, + restrictInstantiation, + }) + } + + log() + process.exit(0) + } + + let consolePrefixLength = 32 + + // Upload DAO contracts. + if (mode === Mode.Dao) { + if (!version) { + log(chalk.red('-v/--version is required when deploying DAO contracts')) + process.exit(1) + } + + // Get automatic deploy sets for this chain. + const chainDeploySets = deploySets.filter( + (s) => + s.type !== 'manual' && + (!s.chainIds || s.chainIds.includes(chainId)) && + !s.skipChainIds?.includes(chainId) + ) + + // Set console prefix length to the max contract name length plus space for + // brackets and longest ID suffix (CONTRACT). + consolePrefixLength = + Math.max( + ...chainDeploySets.flatMap((s) => s.contracts.map((c) => c.length)) + ) + 14 + + try { + // For one-time deploy sets, only deploy if the code ID is not already + // set. Otherwise, copy over the code ID from an earlier version if + // available. + const oneTimeDeploySets = chainDeploySets.filter((s) => s.type === 'once') + for (const { contracts } of oneTimeDeploySets) { + for (const name of contracts) { + // If exists, skip. + const existingCodeId = await codeIds.getCodeId({ + chainId, + name, + version, + }) + + if (existingCodeId !== null) { + log( + chalk.green( + `[${name}]${' '.repeat( + consolePrefixLength - name.length - 5 + )}${existingCodeId} (already set)` + ) + ) + + continue + } else { + const latest = await codeIds.getLatestCodeId({ + chainId, + name, + }) + + // Copy over the code ID from an earlier version if available. + if (latest) { + log( + chalk.green( + `[${name}]${' '.repeat( + consolePrefixLength - name.length - 5 + )}${latest.codeId} (set from version ${latest.version})` + ) + ) + + await codeIds.setCodeId({ + chainId, + name, + version, + codeId: latest.codeId, + }) + } else { + // Otherwise, upload the contract. + const codeId = await uploadContract({ + id: name, + file: getPathToContract(name), + prefixLength: consolePrefixLength, + restrictInstantiation, + }) + + await codeIds.setCodeId({ + chainId, + name, + version, + codeId, + }) + } + } + } + } + + // For always deploy sets, upload all contracts. + const alwaysDeploySets = chainDeploySets.filter( + (s) => s.type === 'always' + ) + for (const { contracts } of alwaysDeploySets) { + for (const name of contracts) { + // If exists, skip. + const existingCodeId = await codeIds.getCodeId({ + chainId, + name, + version, + }) + + if (existingCodeId !== null) { + log( + chalk.green( + `[${name}]${' '.repeat( + consolePrefixLength - name.length - 5 + )}${existingCodeId} (already set)` + ) + ) + continue + } else { + // Otherwise, upload the contract. + const codeId = await uploadContract({ + id: name, + file: getPathToContract(name), + prefixLength: consolePrefixLength, + restrictInstantiation, + }) + + await codeIds.setCodeId({ + chainId, + name, + version, + codeId, + }) + } + } + } + } catch (err) { + log(chalk.red('Error uploading contracts.')) + + throw err + } + } + + // Instantiate admin factory. + const cwAdminFactoryCodeId = await codeIds.getCodeId({ + chainId, + name: 'cw_admin_factory', + version, + }) + + if (cwAdminFactoryCodeId === null) { + if (mode === Mode.Factory) { + log() + log( + chalk.red( + `cw_admin_factory code ID not found for version ${version} but is needed` + ) + ) + process.exit(1) + } else { + log() + log(chalk.blueBright('cw_admin_factory not found, not instantiating')) + } + } + + const adminFactoryAddress = + cwAdminFactoryCodeId !== null + ? await instantiateContract({ + client, + sender, + chainId, + id: 'cw_admin_factory', + codeId: cwAdminFactoryCodeId, + msg: {}, + label: 'daodao_admin_factory', + prefixLength: consolePrefixLength, + }) + : '' + + if (mode === Mode.Factory) { + log() + log(chalk.green('Done! Instantiated admin factory:')) + log(adminFactoryAddress) + } else { + log() + log(chalk.green('Done! Config entries:')) + + const mainnet = networkType === 'mainnet' + const explorerUrlDomain = mainnet ? 'ping.pub' : 'testnet.ping.pub' + + const config: Omit = { + chainId, + name: chainName, + mainnet, + accentColor: 'ACCENT_COLOR', + factoryContractAddress: adminFactoryAddress, + explorerUrlTemplates: { + tx: `https://${explorerUrlDomain}/${chainName}/tx/REPLACE`, + gov: `https://${explorerUrlDomain}/${chainName}/gov`, + govProp: `https://${explorerUrlDomain}/${chainName}/gov/REPLACE`, + wallet: `https://${explorerUrlDomain}/${chainName}/account/REPLACE`, + }, + latestVersion: ContractVersion.Unknown, + } + + log(JSON.stringify(config, null, 2)) + } + + log() +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error) + process.exit(1) + }) + +process.on('SIGINT', () => { + process.exit(0) +}) diff --git a/packages/dispatch/scripts/chains.ts b/packages/dispatch/scripts/polytone/config.ts similarity index 100% rename from packages/dispatch/scripts/chains.ts rename to packages/dispatch/scripts/polytone/config.ts diff --git a/packages/dispatch/scripts/polytone.ts b/packages/dispatch/scripts/polytone/run.ts similarity index 99% rename from packages/dispatch/scripts/polytone.ts rename to packages/dispatch/scripts/polytone/run.ts index 3fcb14ce5..0af5bc67e 100644 --- a/packages/dispatch/scripts/polytone.ts +++ b/packages/dispatch/scripts/polytone/run.ts @@ -21,8 +21,8 @@ import { maybeGetChainForChainId, } from '@dao-dao/utils' -import { chains } from './chains' -import { getBlockMaxGas, instantiateContract } from './utils' +import { getBlockMaxGas, instantiateContract } from '../utils' +import { chains } from './config' const { log } = console diff --git a/packages/dispatch/scripts/utils.ts b/packages/dispatch/scripts/utils.ts index ff1119493..44669d864 100644 --- a/packages/dispatch/scripts/utils.ts +++ b/packages/dispatch/scripts/utils.ts @@ -74,9 +74,7 @@ export const instantiateContract = async ({ log( chalk.greenBright( - `[${id}.TX]${' '.repeat( - prefixLength - id.length - 5 - )}${transactionHash}` + `[${id}]${' '.repeat(prefixLength - id.length - 5)}${transactionHash}` ) ) @@ -98,9 +96,7 @@ export const instantiateContract = async ({ if (!events) { log( chalk.red( - `[${id}.CONTRACT]${' '.repeat( - prefixLength - id.length - 11 - )}TX not found` + `[${id}]${' '.repeat(prefixLength - id.length - 5)}TX not found` ) ) process.exit(1) diff --git a/yarn.lock b/yarn.lock index a51bfe01f..7859824bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9164,6 +9164,13 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== +"@types/proper-lockfile@^4.1.4": + version "4.1.4" + resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz#cd9fab92bdb04730c1ada542c356f03620f84008" + integrity sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ== + dependencies: + "@types/retry" "*" + "@types/qs@*", "@types/qs@^6.9.5": version "6.9.7" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" @@ -9223,6 +9230,11 @@ resolved "https://registry.yarnpkg.com/@types/remove-markdown/-/remove-markdown-0.3.1.tgz#82bc3664c313f50f7c77f1bb59935f567689dc63" integrity sha512-JpJNEJEsmmltyL2LdE8KRjJ0L2ad5vgLibqNj85clohT9AyTrfN6jvHxStPshDkmtcL/ShFu0p2tbY7DBS1mqQ== +"@types/retry@*": + version "0.12.5" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.5.tgz#f090ff4bd8d2e5b940ff270ab39fd5ca1834a07e" + integrity sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw== + "@types/rimraf@3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-3.0.2.tgz#a63d175b331748e5220ad48c901d7bbf1f44eef8" @@ -21769,6 +21781,15 @@ propagate@^2.0.0: resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== +proper-lockfile@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== + dependencies: + graceful-fs "^4.2.4" + retry "^0.12.0" + signal-exit "^3.0.2" + property-information@^5.0.0, property-information@^5.3.0: version "5.6.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69" @@ -24758,6 +24779,11 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +toml@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee" + integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w== + totalist@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df"