diff --git a/governance/xc_admin/packages/xc_admin_cli/package.json b/governance/xc_admin/packages/xc_admin_cli/package.json index bb12fc52..0887118e 100644 --- a/governance/xc_admin/packages/xc_admin_cli/package.json +++ b/governance/xc_admin/packages/xc_admin_cli/package.json @@ -16,7 +16,8 @@ }, "scripts": { "build": "tsc", - "format": "prettier --write \"src/**/*.ts\"" + "format": "prettier --write \"src/**/*.ts\"", + "cli": "npm run build && node lib/cli.js" }, "dependencies": { "@coral-xyz/anchor": "^0.26.0", diff --git a/governance/xc_admin/packages/xc_admin_cli/src/cli.ts b/governance/xc_admin/packages/xc_admin_cli/src/cli.ts new file mode 100644 index 00000000..08d22091 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_cli/src/cli.ts @@ -0,0 +1,178 @@ +import { program } from "commander"; +import { loadContractConfig, ContractType, SyncOp } from "xc_admin_common"; +import * as fs from "fs"; + +// TODO: extract this configuration to a file +const contractsConfig = [ + { + type: ContractType.EvmPythUpgradable, + networkId: "arbitrum", + address: "0xff1a0f4744e8582DF1aE09D5611b887B6a12925C", + }, + { + type: ContractType.EvmWormholeReceiver, + networkId: "canto", + address: "0x87047526937246727E4869C5f76A347160e08672", + }, + { + type: ContractType.EvmPythUpgradable, + networkId: "canto", + address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603", + }, + { + type: ContractType.EvmPythUpgradable, + networkId: "avalanche", + address: "0x4305FB66699C3B2702D4d05CF36551390A4c69C6", + }, +]; + +const networksConfig = { + evm: { + optimism_goerli: { + url: `https://rpc.ankr.com/optimism_testnet`, + }, + arbitrum: { + url: "https://arb1.arbitrum.io/rpc", + }, + avalanche: { + url: "https://api.avax.network/ext/bc/C/rpc", + }, + canto: { + url: "https://canto.gravitychain.io", + }, + }, +}; + +// TODO: we will need configuration of this stuff to decide which multisig to run. +const multisigs = [ + { + name: "", + wormholeNetwork: "mainnet", + }, +]; + +program + .name("pyth_governance") + .description("CLI for governing Pyth contracts") + .version("0.1.0"); + +program + .command("get") + .description("Find Pyth contracts matching the given search criteria") + .option("-n, --network ", "Find contracts on the given network") + .option("-a, --address
", "Find contracts with the given address") + .option("-t, --type ", "Find contracts of the given type") + .action(async (options: any) => { + const contracts = loadContractConfig(contractsConfig, networksConfig); + + console.log(JSON.stringify(options)); + + const matches = []; + for (const contract of contracts) { + if ( + (options.network === undefined || + contract.networkId == options.network) && + (options.address === undefined || + contract.getAddress() == options.address) && + (options.type === undefined || contract.type == options.type) + ) { + matches.push(contract); + } + } + + for (const contract of matches) { + const state = await contract.getState(); + console.log({ + networkId: contract.networkId, + address: contract.getAddress(), + type: contract.type, + state: state, + }); + } + }); + +class Cache { + private path: string; + + constructor(path: string) { + this.path = path; + } + + private opFilePath(op: SyncOp): string { + return `${this.path}/${op.id()}.json`; + } + + public readOpCache(op: SyncOp): Record { + const path = this.opFilePath(op); + if (fs.existsSync(path)) { + return JSON.parse(fs.readFileSync(path).toString("utf-8")); + } else { + return {}; + } + } + + public writeOpCache(op: SyncOp, cache: Record) { + fs.writeFileSync(this.opFilePath(op), JSON.stringify(cache)); + } + + public deleteCache(op: SyncOp) { + fs.rmSync(this.opFilePath(op)); + } +} + +program + .command("set") + .description("Set a configuration parameter for one or more Pyth contracts") + .option("-n, --network ", "Find contracts on the given network") + .option("-a, --address
", "Find contracts with the given address") + .option("-t, --type ", "Find contracts of the given type") + .argument("", "Fields to set on the given contracts") + .action(async (fields, options: any, command) => { + const contracts = loadContractConfig(contractsConfig, networksConfig); + + console.log(JSON.stringify(fields)); + console.log(JSON.stringify(options)); + + const setters = fields.map((value: string) => value.split("=")); + + const matches = []; + for (const contract of contracts) { + if ( + (options.network === undefined || + contract.networkId == options.network) && + (options.address === undefined || + contract.getAddress() == options.address) && + (options.type === undefined || contract.type == options.type) + ) { + matches.push(contract); + } + } + + const ops = []; + for (const contract of matches) { + const state = await contract.getState(); + // TODO: make a decent format for this + for (const [field, value] of setters) { + state[field] = value; + } + + ops.push(...(await contract.sync(state))); + } + + // TODO: extract constant + const cacheDir = "cache"; + fs.mkdirSync(cacheDir, { recursive: true }); + const cache = new Cache(cacheDir); + + for (const op of ops) { + const opCache = cache.readOpCache(op); + const isDone = await op.run(opCache); + if (isDone) { + cache.deleteCache(op); + } else { + cache.writeOpCache(op, opCache); + } + } + }); + +program.parse(); diff --git a/governance/xc_admin/packages/xc_admin_common/package.json b/governance/xc_admin/packages/xc_admin_common/package.json index 472f9728..a0790194 100644 --- a/governance/xc_admin/packages/xc_admin_common/package.json +++ b/governance/xc_admin/packages/xc_admin_common/package.json @@ -23,9 +23,12 @@ "@certusone/wormhole-sdk": "^0.9.8", "@coral-xyz/anchor": "^0.26.0", "@pythnetwork/client": "^2.17.0", + "@pythnetwork/pyth-sdk-solidity": "*", + "@pythnetwork/xc-governance-sdk": "*", "@solana/buffer-layout": "^4.0.1", "@solana/web3.js": "^1.73.0", "@sqds/mesh": "^1.0.6", + "ethers": "^5.7.2", "lodash": "^4.17.21", "typescript": "^4.9.4" }, diff --git a/governance/xc_admin/packages/xc_admin_common/src/contracts/Contract.ts b/governance/xc_admin/packages/xc_admin_common/src/contracts/Contract.ts new file mode 100644 index 00000000..6241464c --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/contracts/Contract.ts @@ -0,0 +1,139 @@ +import { ChainId, Instruction } from "@pythnetwork/xc-governance-sdk"; +import { ethers } from "ethers"; + +export enum ContractType { + Oracle, + EvmPythUpgradable, + EvmWormholeReceiver, +} + +/** + * A unique identifier for a blockchain. Note that we cannot use ChainId for this, as ChainId currently reuses + * some ids across mainnet / testnet chains (e.g., ethereum goerli has the same id as ethereum mainnet). + */ +export type NetworkId = string; + +/** A unique identifier for message senders across all wormhole networks. */ +export interface WormholeAddress { + emitter: string; + chainId: ChainId; + // which network this sender is on + network: WormholeNetwork; +} +export type WormholeNetwork = "mainnet" | "testnet"; + +/** + * A Contract is the basic unit of on-chain state that is managed by xc_admin. + * Each contracts lives at a specific address of a specific network, and has a type + * representing which of several known contract types (evm target chain, wormhole receiver, etc) + * that it is. + * + * Contracts further expose a state representing values that can be modified by governance. + * The fields of the state object vary depending on what type of contract this is. + * Finally, contracts expose a sync method that generates the needed operations to bring the on-chain state + * in sync with a provided desired state. + */ +export interface Contract { + type: ContractType; + networkId: NetworkId; + /** The address of the contract. The address may be written in different formats for different networks. */ + getAddress(): string; + + /** Get the on-chain state of all governance-controllable fields of this contract. */ + getState(): Promise; + + /** Generate a set of operations that, if executed, will update the on-chain contract state to be `target`. */ + sync(target: State): Promise; +} + +/** + * An idempotent synchronization operation to update on-chain state. The operation may depend on + * external approvals or actions to complete, in which case the operation will pause and need to + * be resumed later. + */ +export interface SyncOp { + /** + * A unique identifier for this operation. The id represents the content of the operation (e.g., "sets the X + * field to Y on contract Z"), so can be used to identify the "same" operation across multiple runs of this program. + */ + id(): string; + /** + * Run this operation from a previous state (recorded in cache). The operation can modify cache + * to record progress, then returns true if the operation has completed. If this function returns false, + * it is waiting on an external operation to complete (e.g., a multisig transaction to be approved). + * Re-run this function again once that operation is completed to continue making progress. + * + * The caller of this function is responsible for preserving the contents of `cache` between calls to + * this function. + */ + run(cache: Record): Promise; +} + +export class SendGovernanceInstruction implements SyncOp { + private instruction: Instruction; + private sender: WormholeAddress; + // function to submit the signed VAA to the target chain contract + private submitVaa: (vaa: string) => Promise; + + constructor( + instruction: Instruction, + from: WormholeAddress, + submitVaa: (vaa: string) => Promise + ) { + this.instruction = instruction; + this.sender = from; + this.submitVaa = submitVaa; + } + + public id(): string { + // TODO: use a more understandable identifier (also this may not be unique) + return ethers.utils.sha256(this.instruction.serialize()); + } + + public async run(cache: Record): Promise { + // FIXME: this implementation is temporary. replace with something like the commented out code below. + if (cache["multisigTx"] === undefined) { + cache["multisigTx"] = "fooooo"; + return false; + } + + if (cache["vaa"] === undefined) { + return false; + } + + // VAA is guaranteed to be defined here + const vaa = cache["vaa"]; + + // assertVaaPayloadEquals(vaa, payload); + + return await this.submitVaa(vaa); + } + + /* + public async run(cache: Record): Promise { + if (cache["multisigTx"] === undefined) { + // Have not yet submitted this operation to the multisig. + const payload = this.instruction.serialize(); + const txKey = vault.sendWormholeInstruction(payload); + cache["multisigTx"] = txKey; + return false; + } + + if (cache["vaa"] === undefined) { + const vaa = await executeMultisigTxAndGetVaa(txKey, payloadHex); + if (vaa === undefined) { + return false; + } + cache["vaa"] = vaa; + } + + // VAA is guaranteed to be defined here + const vaa = cache["vaa"]; + + assertVaaPayloadEquals(vaa, payload); + + // await proxy.executeGovernanceInstruction("0x" + vaa); + await submitVaa(vaa); + } + */ +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/contracts/EvmPythUpgradable.ts b/governance/xc_admin/packages/xc_admin_common/src/contracts/EvmPythUpgradable.ts new file mode 100644 index 00000000..82a8f33c --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/contracts/EvmPythUpgradable.ts @@ -0,0 +1,98 @@ +import { + Contract, + ContractType, + NetworkId, + SendGovernanceInstruction, + SyncOp, + WormholeAddress, + WormholeNetwork, +} from "./Contract"; +import { + ChainId, + SetValidPeriodInstruction, +} from "@pythnetwork/xc-governance-sdk"; +import { ethers } from "ethers"; + +export class EvmPythUpgradable implements Contract { + public type = ContractType.EvmPythUpgradable; + public networkId; + private address; + + private contract: ethers.Contract; + + constructor( + networkId: NetworkId, + address: string, + contract: ethers.Contract + ) { + this.networkId = networkId; + this.address = address; + this.contract = contract; + } + + public getAddress() { + return this.address; + } + + // TODO: these getters will need the full PythUpgradable ABI + public async getAuthority(): Promise { + // FIXME: read from data sources + return { + emitter: "123454", + chainId: 1, + network: "mainnet", + }; + } + + // get the chainId that identifies this contract + public async getChainId(): Promise { + // FIXME: read from data sources + return 23; + } + + public async getState(): Promise { + const bytecodeSha = ethers.utils.sha256( + (await this.contract.provider.getCode(this.contract.address)) as string + ); + const validTimePeriod = + (await this.contract.getValidTimePeriod()) as bigint; + return { + bytecodeSha, + validTimePeriod: validTimePeriod.toString(), + }; + } + + public async sync(target: EvmPythUpgradableState): Promise { + const myState = await this.getState(); + const authority = await this.getAuthority(); + const myChainId = await this.getChainId(); + const whInstructions = []; + + if (myState.validTimePeriod !== target.validTimePeriod) { + whInstructions.push( + new SetValidPeriodInstruction(myChainId, BigInt(target.validTimePeriod)) + ); + } + + return whInstructions.map( + (value) => + new SendGovernanceInstruction( + value, + authority, + this.submitGovernanceVaa + ) + ); + } + + public async submitGovernanceVaa(vaa: string): Promise { + // FIXME: also needs the full PythUpgradable ABI + // await this.contract.executeGovernanceInstruction("0x" + vaa) + return true; + } +} + +export interface EvmPythUpgradableState { + bytecodeSha: string; + // bigint serialized as a string + validTimePeriod: string; +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/contracts/EvmWormholeReceiver.ts b/governance/xc_admin/packages/xc_admin_common/src/contracts/EvmWormholeReceiver.ts new file mode 100644 index 00000000..6799b3f9 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/contracts/EvmWormholeReceiver.ts @@ -0,0 +1,43 @@ +import { ethers } from "ethers"; +import { Contract, ContractType, NetworkId, SyncOp } from "./Contract"; + +export class EvmWormholeReceiver implements Contract { + public type = ContractType.EvmWormholeReceiver; + public networkId; + private address; + + private contract: ethers.Contract; + + constructor( + networkId: NetworkId, + address: string, + contract: ethers.Contract + ) { + this.networkId = networkId; + this.address = address; + this.contract = contract; + } + + public getAddress() { + return this.address; + } + + public async getState(): Promise { + const bytecodeSha = ethers.utils.sha256( + (await this.contract.provider.getCode(this.contract.address)) as string + ); + + return { + bytecodeSha, + }; + } + + public async sync(target: EvmWormholeReceiverState): Promise { + // TODO + return []; + } +} + +export interface EvmWormholeReceiverState { + bytecodeSha: string; +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/contracts/config.ts b/governance/xc_admin/packages/xc_admin_common/src/contracts/config.ts new file mode 100644 index 00000000..a7274d52 --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/contracts/config.ts @@ -0,0 +1,58 @@ +import { ethers } from "ethers"; +import PythAbi from "@pythnetwork/pyth-sdk-solidity/abis/IPyth.json"; +import { Contract, ContractType, NetworkId } from "./Contract"; +import { EvmPythUpgradable } from "./EvmPythUpgradable"; +import { EvmWormholeReceiver } from "./EvmWormholeReceiver"; + +export function getEvmProvider( + networkId: NetworkId, + networksConfig: any +): ethers.providers.Provider { + const networkConfig = networksConfig["evm"][networkId]!; + return ethers.getDefaultProvider(networkConfig.url); +} + +export function loadContractConfig( + contractsConfig: any, + networksConfig: any +): Contract[] { + const contracts = []; + for (const contractConfig of contractsConfig) { + contracts.push(fromConfig(contractConfig, networksConfig)); + } + return contracts; +} + +function fromConfig(contractConfig: any, networksConfig: any): Contract { + switch (contractConfig.type) { + case ContractType.EvmPythUpgradable: { + const ethersContract = new ethers.Contract( + contractConfig.address, + PythAbi, + getEvmProvider(contractConfig.networkId, networksConfig) + ); + + return new EvmPythUpgradable( + contractConfig.networkId, + contractConfig.address, + ethersContract + ); + } + case ContractType.EvmWormholeReceiver: { + const ethersContract = new ethers.Contract( + contractConfig.address, + // TODO: pass in an appropriate ABI here + [], + getEvmProvider(contractConfig.networkId, networksConfig) + ); + + return new EvmWormholeReceiver( + contractConfig.networkId, + contractConfig.address, + ethersContract + ); + } + default: + throw new Error(`unknown contract type: ${contractConfig.type}`); + } +} diff --git a/governance/xc_admin/packages/xc_admin_common/src/contracts/index.ts b/governance/xc_admin/packages/xc_admin_common/src/contracts/index.ts new file mode 100644 index 00000000..7888653b --- /dev/null +++ b/governance/xc_admin/packages/xc_admin_common/src/contracts/index.ts @@ -0,0 +1,4 @@ +export * from "./config"; +export * from "./Contract"; +export * from "./EvmPythUpgradable"; +export * from "./EvmWormholeReceiver"; diff --git a/governance/xc_admin/packages/xc_admin_common/src/index.ts b/governance/xc_admin/packages/xc_admin_common/src/index.ts index f61e656e..fd2d9f0d 100644 --- a/governance/xc_admin/packages/xc_admin_common/src/index.ts +++ b/governance/xc_admin/packages/xc_admin_common/src/index.ts @@ -9,3 +9,4 @@ export * from "./bpf_upgradable_loader"; export * from "./deterministic_oracle_accounts"; export * from "./cranks"; export * from "./message_buffer"; +export * from "./contracts"; diff --git a/governance/xc_admin/packages/xc_admin_frontend/next.config.js b/governance/xc_admin/packages/xc_admin_frontend/next.config.js index ab254dfc..d93bc112 100644 --- a/governance/xc_admin/packages/xc_admin_frontend/next.config.js +++ b/governance/xc_admin/packages/xc_admin_frontend/next.config.js @@ -5,6 +5,7 @@ const nextConfig = { externalDir: true, }, webpack(config) { + config.experiments = { asyncWebAssembly: true } config.resolve.fallback = { fs: false } const fileLoaderRule = config.module.rules.find( (rule) => rule.test && rule.test.test('.svg') diff --git a/package-lock.json b/package-lock.json index 947d16fd..ebd53d98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1335,9 +1335,11 @@ "@certusone/wormhole-sdk": "^0.9.8", "@coral-xyz/anchor": "^0.26.0", "@pythnetwork/client": "^2.17.0", + "@pythnetwork/pyth-sdk-solidity": "*", "@solana/buffer-layout": "^4.0.1", "@solana/web3.js": "^1.73.0", "@sqds/mesh": "^1.0.6", + "ethers": "^5.7.2", "lodash": "^4.17.21", "typescript": "^4.9.4" }, @@ -106547,12 +106549,14 @@ "@certusone/wormhole-sdk": "^0.9.8", "@coral-xyz/anchor": "^0.26.0", "@pythnetwork/client": "^2.17.0", + "@pythnetwork/pyth-sdk-solidity": "*", "@solana/buffer-layout": "^4.0.1", "@solana/web3.js": "^1.73.0", "@sqds/mesh": "^1.0.6", "@types/bn.js": "^5.1.1", "@types/jest": "^29.2.5", "@types/lodash": "^4.14.191", + "ethers": "^5.7.2", "jest": "^29.3.1", "lodash": "^4.17.21", "prettier": "^2.8.1", diff --git a/target_chains/cosmwasm/tools/src/deployer/injective.ts b/target_chains/cosmwasm/tools/src/deployer/injective.ts index 70ba49c3..0aa098d0 100644 --- a/target_chains/cosmwasm/tools/src/deployer/injective.ts +++ b/target_chains/cosmwasm/tools/src/deployer/injective.ts @@ -1,6 +1,6 @@ import { readFileSync } from "fs"; import { Bech32, toHex } from "@cosmjs/encoding"; -import { zeroPad } from "ethers/lib/utils.js"; +import { ethers } from "ethers"; import assert from "assert"; import { getNetworkInfo, Network } from "@injectivelabs/networks"; import { @@ -210,7 +210,7 @@ export class InjectiveDeployer implements Deployer { // Injective addresses are "human-readable", but for cross-chain registrations, we // want the "canonical" version function convert_injective_address_to_hex(human_addr: string) { - return "0x" + toHex(zeroPad(Bech32.decode(human_addr).data, 32)); + return "0x" + toHex(ethers.utils.zeroPad(Bech32.decode(human_addr).data, 32)); } // enter key of what to extract diff --git a/target_chains/cosmwasm/tools/src/deployer/terra.ts b/target_chains/cosmwasm/tools/src/deployer/terra.ts index 3154d603..7f7d7354 100644 --- a/target_chains/cosmwasm/tools/src/deployer/terra.ts +++ b/target_chains/cosmwasm/tools/src/deployer/terra.ts @@ -12,7 +12,7 @@ import { } from "@terra-money/terra.js"; import { readFileSync } from "fs"; import { Bech32, toHex } from "@cosmjs/encoding"; -import { zeroPad } from "ethers/lib/utils.js"; +import { ethers } from "ethers"; import assert from "assert"; import { ContractInfo, Deployer } from "."; @@ -179,7 +179,7 @@ export class TerraDeployer implements Deployer { // Terra addresses are "human-readable", but for cross-chain registrations, we // want the "canonical" version export function convert_terra_address_to_hex(human_addr: string) { - return "0x" + toHex(zeroPad(Bech32.decode(human_addr).data, 32)); + return "0x" + toHex(ethers.utils.zeroPad(Bech32.decode(human_addr).data, 32)); } // enter key of what to extract