[xc-admin] Contract management tool (#885)

* cleanup

* blah

* gr

* stuff

* hm

* hmm

* wtf

* ah fix this

* ok finally it does something

* ok

* hrm

* hrm

* blah

* blah
This commit is contained in:
Jayant Krishnamurthy 2023-06-15 07:17:20 -07:00 committed by GitHub
parent e1377e5627
commit 25c1ac2c33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 535 additions and 5 deletions

View File

@ -16,7 +16,8 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"format": "prettier --write \"src/**/*.ts\"" "format": "prettier --write \"src/**/*.ts\"",
"cli": "npm run build && node lib/cli.js"
}, },
"dependencies": { "dependencies": {
"@coral-xyz/anchor": "^0.26.0", "@coral-xyz/anchor": "^0.26.0",

View File

@ -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 <network-id>", "Find contracts on the given network")
.option("-a, --address <address>", "Find contracts with the given address")
.option("-t, --type <type-id>", "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<string, any> {
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<string, any>) {
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 <network-id>", "Find contracts on the given network")
.option("-a, --address <address>", "Find contracts with the given address")
.option("-t, --type <type-id>", "Find contracts of the given type")
.argument("<fields...>", "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();

View File

@ -23,9 +23,12 @@
"@certusone/wormhole-sdk": "^0.9.8", "@certusone/wormhole-sdk": "^0.9.8",
"@coral-xyz/anchor": "^0.26.0", "@coral-xyz/anchor": "^0.26.0",
"@pythnetwork/client": "^2.17.0", "@pythnetwork/client": "^2.17.0",
"@pythnetwork/pyth-sdk-solidity": "*",
"@pythnetwork/xc-governance-sdk": "*",
"@solana/buffer-layout": "^4.0.1", "@solana/buffer-layout": "^4.0.1",
"@solana/web3.js": "^1.73.0", "@solana/web3.js": "^1.73.0",
"@sqds/mesh": "^1.0.6", "@sqds/mesh": "^1.0.6",
"ethers": "^5.7.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },

View File

@ -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<State> {
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<State>;
/** Generate a set of operations that, if executed, will update the on-chain contract state to be `target`. */
sync(target: State): Promise<SyncOp[]>;
}
/**
* 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<string, any>): Promise<boolean>;
}
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<boolean>;
constructor(
instruction: Instruction,
from: WormholeAddress,
submitVaa: (vaa: string) => Promise<boolean>
) {
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<string, any>): Promise<boolean> {
// 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<string,any>): Promise<boolean> {
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);
}
*/
}

View File

@ -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<EvmPythUpgradableState> {
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<WormholeAddress> {
// FIXME: read from data sources
return {
emitter: "123454",
chainId: 1,
network: "mainnet",
};
}
// get the chainId that identifies this contract
public async getChainId(): Promise<ChainId> {
// FIXME: read from data sources
return 23;
}
public async getState(): Promise<EvmPythUpgradableState> {
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<SyncOp[]> {
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<boolean> {
// 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;
}

View File

@ -0,0 +1,43 @@
import { ethers } from "ethers";
import { Contract, ContractType, NetworkId, SyncOp } from "./Contract";
export class EvmWormholeReceiver implements Contract<EvmWormholeReceiverState> {
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<EvmWormholeReceiverState> {
const bytecodeSha = ethers.utils.sha256(
(await this.contract.provider.getCode(this.contract.address)) as string
);
return {
bytecodeSha,
};
}
public async sync(target: EvmWormholeReceiverState): Promise<SyncOp[]> {
// TODO
return [];
}
}
export interface EvmWormholeReceiverState {
bytecodeSha: string;
}

View File

@ -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<any>[] {
const contracts = [];
for (const contractConfig of contractsConfig) {
contracts.push(fromConfig(contractConfig, networksConfig));
}
return contracts;
}
function fromConfig(contractConfig: any, networksConfig: any): Contract<any> {
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}`);
}
}

View File

@ -0,0 +1,4 @@
export * from "./config";
export * from "./Contract";
export * from "./EvmPythUpgradable";
export * from "./EvmWormholeReceiver";

View File

@ -9,3 +9,4 @@ export * from "./bpf_upgradable_loader";
export * from "./deterministic_oracle_accounts"; export * from "./deterministic_oracle_accounts";
export * from "./cranks"; export * from "./cranks";
export * from "./message_buffer"; export * from "./message_buffer";
export * from "./contracts";

View File

@ -5,6 +5,7 @@ const nextConfig = {
externalDir: true, externalDir: true,
}, },
webpack(config) { webpack(config) {
config.experiments = { asyncWebAssembly: true }
config.resolve.fallback = { fs: false } config.resolve.fallback = { fs: false }
const fileLoaderRule = config.module.rules.find( const fileLoaderRule = config.module.rules.find(
(rule) => rule.test && rule.test.test('.svg') (rule) => rule.test && rule.test.test('.svg')

4
package-lock.json generated
View File

@ -1335,9 +1335,11 @@
"@certusone/wormhole-sdk": "^0.9.8", "@certusone/wormhole-sdk": "^0.9.8",
"@coral-xyz/anchor": "^0.26.0", "@coral-xyz/anchor": "^0.26.0",
"@pythnetwork/client": "^2.17.0", "@pythnetwork/client": "^2.17.0",
"@pythnetwork/pyth-sdk-solidity": "*",
"@solana/buffer-layout": "^4.0.1", "@solana/buffer-layout": "^4.0.1",
"@solana/web3.js": "^1.73.0", "@solana/web3.js": "^1.73.0",
"@sqds/mesh": "^1.0.6", "@sqds/mesh": "^1.0.6",
"ethers": "^5.7.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },
@ -106547,12 +106549,14 @@
"@certusone/wormhole-sdk": "^0.9.8", "@certusone/wormhole-sdk": "^0.9.8",
"@coral-xyz/anchor": "^0.26.0", "@coral-xyz/anchor": "^0.26.0",
"@pythnetwork/client": "^2.17.0", "@pythnetwork/client": "^2.17.0",
"@pythnetwork/pyth-sdk-solidity": "*",
"@solana/buffer-layout": "^4.0.1", "@solana/buffer-layout": "^4.0.1",
"@solana/web3.js": "^1.73.0", "@solana/web3.js": "^1.73.0",
"@sqds/mesh": "^1.0.6", "@sqds/mesh": "^1.0.6",
"@types/bn.js": "^5.1.1", "@types/bn.js": "^5.1.1",
"@types/jest": "^29.2.5", "@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"ethers": "^5.7.2",
"jest": "^29.3.1", "jest": "^29.3.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"prettier": "^2.8.1", "prettier": "^2.8.1",

View File

@ -1,6 +1,6 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { Bech32, toHex } from "@cosmjs/encoding"; import { Bech32, toHex } from "@cosmjs/encoding";
import { zeroPad } from "ethers/lib/utils.js"; import { ethers } from "ethers";
import assert from "assert"; import assert from "assert";
import { getNetworkInfo, Network } from "@injectivelabs/networks"; import { getNetworkInfo, Network } from "@injectivelabs/networks";
import { import {
@ -210,7 +210,7 @@ export class InjectiveDeployer implements Deployer {
// Injective addresses are "human-readable", but for cross-chain registrations, we // Injective addresses are "human-readable", but for cross-chain registrations, we
// want the "canonical" version // want the "canonical" version
function convert_injective_address_to_hex(human_addr: string) { 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 // enter key of what to extract

View File

@ -12,7 +12,7 @@ import {
} from "@terra-money/terra.js"; } from "@terra-money/terra.js";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { Bech32, toHex } from "@cosmjs/encoding"; import { Bech32, toHex } from "@cosmjs/encoding";
import { zeroPad } from "ethers/lib/utils.js"; import { ethers } from "ethers";
import assert from "assert"; import assert from "assert";
import { ContractInfo, Deployer } from "."; import { ContractInfo, Deployer } from ".";
@ -179,7 +179,7 @@ export class TerraDeployer implements Deployer {
// Terra addresses are "human-readable", but for cross-chain registrations, we // Terra addresses are "human-readable", but for cross-chain registrations, we
// want the "canonical" version // want the "canonical" version
export function convert_terra_address_to_hex(human_addr: string) { 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 // enter key of what to extract