[contract_manager] Support for entropy and evm executor (#1251)

* Add EvmExecute structures for governance

* Add ExecuteAction file

* uint 256 (#1250)

* Add in value field in ExecuteAction

* Add value arg in contract manager

* add tests for evm execute (#1252)

* add tests for evm execute

* add tests for buffer layout

* remove unneccessary test

* accept admin and ownership payload

* rename to add entropy

* update comment

* address comments

* minor rename

---------

Co-authored-by: Amin Moghaddam <amin@pyth.network>
This commit is contained in:
Dev Kalra 2024-01-29 17:40:53 +05:30 committed by GitHub
parent e1db4aad65
commit f3ad917c6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 365 additions and 20 deletions

View File

@ -0,0 +1,86 @@
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { DefaultStore, EvmChain, loadHotWallet } from "../src";
const parser = yargs(hideBin(process.argv))
.usage(
"Creates governance proposal to accept pending admin or ownership transfer for Pyth entropy contracts.\n" +
"Usage: $0 --chain <chain_1> --chain <chain_2> --ops-key-path <ops_key_path>"
)
.options({
testnet: {
type: "boolean",
default: false,
desc: "Accept for testnet contracts instead of mainnet",
},
"all-chains": {
type: "boolean",
default: false,
desc: "Accept for contract on all chains. Use with --testnet flag to accept for all testnet contracts",
},
chain: {
type: "array",
string: true,
desc: "Accept for contract on given chains",
},
"ops-key-path": {
type: "string",
demandOption: true,
desc: "Path to the private key of the proposer to use for the operations multisig governance proposal",
},
});
async function main() {
const argv = await parser.argv;
const selectedChains: EvmChain[] = [];
if (argv.allChains && argv.chain)
throw new Error("Cannot use both --all-chains and --chain");
if (!argv.allChains && !argv.chain)
throw new Error("Must use either --all-chains or --chain");
for (const chain of Object.values(DefaultStore.chains)) {
if (!(chain instanceof EvmChain)) continue;
if (
(argv.allChains && chain.isMainnet() !== argv.testnet) ||
argv.chain?.includes(chain.getId())
)
selectedChains.push(chain);
}
if (argv.chain && selectedChains.length !== argv.chain.length)
throw new Error(
`Some chains were not found ${selectedChains
.map((chain) => chain.getId())
.toString()}`
);
for (const chain of selectedChains) {
if (chain.isMainnet() != selectedChains[0].isMainnet())
throw new Error("All chains must be either mainnet or testnet");
}
const vault =
DefaultStore.vaults[
"mainnet-beta_FVQyHcooAtThJ83XFrNnv74BcinbRH3bRmfFamAHBfuj"
];
const payloads: Buffer[] = [];
for (const contract of Object.values(DefaultStore.entropy_contracts)) {
if (selectedChains.includes(contract.chain)) {
console.log("Creating payload for chain: ", contract.chain.getId());
const pendingOwner = await contract.getPendingOwner();
const adminPayload = contract.generateAcceptAdminPayload(pendingOwner);
const ownerPayload =
contract.generateAcceptOwnershipPayload(pendingOwner);
payloads.push(adminPayload, ownerPayload);
}
}
console.log("Using vault at for proposal", vault.getId());
const wallet = await loadHotWallet(argv["ops-key-path"]);
console.log("Using wallet ", wallet.publicKey.toBase58());
await vault.connect(wallet);
const proposal = await vault.proposeWormholeMessage(payloads);
console.log("Proposal address", proposal.address.toBase58());
}
main();

View File

@ -3,11 +3,54 @@ import PythInterfaceAbi from "@pythnetwork/pyth-sdk-solidity/abis/IPyth.json";
import EntropyAbi from "@pythnetwork/entropy-sdk-solidity/abis/IEntropy.json";
import { PriceFeedContract, PrivateKey, Storable } from "../base";
import { Chain, EvmChain } from "../chains";
import { DataSource } from "xc_admin_common";
import { DataSource, EvmExecute } from "xc_admin_common";
import { WormholeContract } from "./wormhole";
// Just to make sure tx gas limit is enough
const GAS_ESTIMATE_MULTIPLIER = 2;
const EXTENDED_ENTROPY_ABI = [
{
inputs: [],
name: "acceptOwnership",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [],
name: "acceptAdmin",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [],
name: "owner",
outputs: [
{
internalType: "address",
name: "",
type: "address",
},
],
stateMutability: "view",
type: "function",
},
{
inputs: [],
name: "pendingOwner",
outputs: [
{
internalType: "address",
name: "",
type: "address",
},
],
stateMutability: "view",
type: "function",
},
...EntropyAbi,
] as any; // eslint-disable-line @typescript-eslint/no-explicit-any
const EXTENDED_PYTH_ABI = [
{
inputs: [],
@ -321,6 +364,42 @@ export class EvmEntropyContract extends Storable {
return new EvmEntropyContract(chain, parsed.address);
}
// Generate a payload for the given executor address and calldata.
// `executor` and `calldata` should be hex strings.
generateExecutorPayload(executor: string, calldata: string) {
return new EvmExecute(
this.chain.wormholeChainName,
executor.replace("0x", ""),
this.address.replace("0x", ""),
0n,
Buffer.from(calldata.replace("0x", ""), "hex")
).encode();
}
// Generates a payload for the newAdmin to call acceptAdmin on the entropy contracts
generateAcceptAdminPayload(newAdmin: string): Buffer {
const contract = this.getContract();
const data = contract.methods.acceptAdmin().encodeABI();
return this.generateExecutorPayload(newAdmin, data);
}
// Generates a payload for newOwner to call acceptOwnership on the entropy contracts
generateAcceptOwnershipPayload(newOwner: string): Buffer {
const contract = this.getContract();
const data = contract.methods.acceptOwnership().encodeABI();
return this.generateExecutorPayload(newOwner, data);
}
getOwner(): string {
const contract = this.getContract();
return contract.methods.owner().call();
}
getPendingOwner(): string {
const contract = this.getContract();
return contract.methods.pendingOwner().call();
}
toJson() {
return {
chain: this.chain.getId(),
@ -331,7 +410,7 @@ export class EvmEntropyContract extends Storable {
getContract() {
const web3 = new Web3(this.chain.getRpcUrl());
return new web3.eth.Contract(EntropyAbi as any, this.address); // eslint-disable-line @typescript-eslint/no-explicit-any
return new web3.eth.Contract(EXTENDED_ENTROPY_ABI, this.address);
}
async getDefaultProvider(): Promise<string> {

View File

@ -428,3 +428,18 @@
rpcUrl: https://rpc.ankr.com/filecoin
networkId: 314
type: EvmChain
- id: lightlink_pegasus_testnet
mainnet: false
rpcUrl: https://replicator.pegasus.lightlink.io/rpc/v1
networkId: 1891
type: EvmChain
- id: sei_evm_devnet
mainnet: false
rpcUrl: https://evm-devnet.seinetwork.io
networkId: 713715
type: EvmChain
- id: fantom_sonic_testnet
mainnet: false
rpcUrl: https://rpc.sonic.fantom.network/
networkId: 64165
type: EvmChain

View File

@ -1,3 +1,24 @@
- chain: lightlink_pegasus_testnet
address: "0x8250f4aF4B972684F7b336503E2D6dFeDeB1487a"
type: EvmEntropyContract
- chain: chiliz_spicy
address: "0xD458261E832415CFd3BAE5E416FdF3230ce6F134"
type: EvmEntropyContract
- chain: conflux_espace_testnet
address: "0xdF21D137Aadc95588205586636710ca2890538d5"
type: EvmEntropyContract
- chain: mode_testnet
address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603"
type: EvmEntropyContract
- chain: arbitrum_sepolia
address: "0x549Ebba8036Ab746611B4fFA1423eb0A4Df61440"
type: EvmEntropyContract
- chain: base_goerli
address: "0x4374e5a8b9C22271E9EB878A2AA31DE97DF15DAF"
type: EvmPriceFeedContract
type: EvmEntropyContract
- chain: fantom_sonic_testnet
address: "0xb27e5ca259702f209a29225d0eDdC131039C9933"
type: EvmEntropyContract
- chain: blast_s2_testnet
address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603"
type: EvmEntropyContract

View File

@ -26,6 +26,7 @@
"@solana/buffer-layout": "^4.0.1",
"@solana/web3.js": "^1.73.0",
"@sqds/mesh": "^1.0.6",
"bigint-buffer": "^1.1.5",
"ethers": "^5.7.2",
"lodash": "^4.17.21",
"typescript": "^4.9.4"
@ -34,9 +35,9 @@
"@types/bn.js": "^5.1.1",
"@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191",
"fast-check": "^3.10.0",
"jest": "^29.3.1",
"prettier": "^2.8.1",
"ts-jest": "^29.0.3",
"fast-check": "^3.10.0"
"ts-jest": "^29.0.3"
}
}

View File

@ -0,0 +1,20 @@
import fc from "fast-check";
import { u64be } from "../governance_payload/BufferLayoutExt";
test("Buffer layout extension fc tests", (done) => {
const u64 = u64be();
fc.assert(
fc.property(fc.bigUintN(64), (bi) => {
let encodedUint8Array = new Uint8Array(8);
u64.encode(bi, encodedUint8Array);
let buffer = Buffer.alloc(8);
buffer.writeBigUInt64BE(bi);
const decodedBI = u64.decode(buffer);
return buffer.equals(encodedUint8Array) && bi === decodedBI;
})
);
done();
});

View File

@ -10,6 +10,8 @@ import {
PythGovernanceAction,
decodeGovernancePayload,
EvmSetWormholeAddress,
EvmExecutorAction,
EvmExecute,
} from "..";
import * as fc from "fast-check";
import { ChainName, CHAINS } from "../chains";
@ -66,6 +68,14 @@ test("GovernancePayload ser/de", (done) => {
expect(governanceHeader?.targetChainId).toBe("solana");
expect(governanceHeader?.action).toBe("SetFee");
// Valid header 3
expectedGovernanceHeader = new PythGovernanceHeader("solana", "Execute");
buffer = expectedGovernanceHeader.encode();
expect(buffer.equals(Buffer.from([80, 84, 71, 77, 2, 0, 0, 1]))).toBeTruthy();
governanceHeader = PythGovernanceHeader.decode(buffer);
expect(governanceHeader?.targetChainId).toBe("solana");
expect(governanceHeader?.action).toBe("Execute");
// Wrong magic number
expect(
PythGovernanceHeader.decode(
@ -157,6 +167,7 @@ function governanceHeaderArb(): Arbitrary<PythGovernanceHeader> {
const actions = [
...Object.keys(ExecutorAction),
...Object.keys(TargetAction),
...Object.keys(EvmExecutorAction),
] as ActionName[];
const actionArb = fc.constantFrom(...actions);
const targetChainIdArb = fc.constantFrom(
@ -260,6 +271,24 @@ function governanceActionArb(): Arbitrary<PythGovernanceAction> {
return hexBytesArb({ minLength: 20, maxLength: 20 }).map((address) => {
return new EvmSetWormholeAddress(header.targetChainId, address);
});
} else if (header.action === "Execute") {
return fc
.record({
executerAddress: hexBytesArb({ minLength: 20, maxLength: 20 }),
callAddress: hexBytesArb({ minLength: 20, maxLength: 20 }),
value: fc.bigUintN(256),
callData: bufferArb(),
})
.map(
({ executerAddress, callAddress, value, callData }) =>
new EvmExecute(
header.targetChainId,
executerAddress,
callAddress,
value,
callData
)
);
} else {
throw new Error("Unsupported action type");
}

View File

@ -1,25 +1,20 @@
import { Layout } from "@solana/buffer-layout";
import { toBigIntBE, toBufferBE } from "bigint-buffer";
export class UInt64BE extends Layout<bigint> {
export class UIntBE extends Layout<bigint> {
// span is the number of bytes to read
constructor(span: number, property?: string) {
super(span, property);
}
// Note: we can not use read/writeBigUInt64BE because it is not supported in the browsers
override decode(b: Uint8Array, offset?: number): bigint {
let o = offset ?? 0;
const buffer = Buffer.from(b.slice(o, o + this.span));
const hi32 = buffer.readUInt32BE();
const lo32 = buffer.readUInt32BE(4);
return BigInt(lo32) + (BigInt(hi32) << BigInt(32));
return toBigIntBE(buffer);
}
override encode(src: bigint, b: Uint8Array, offset?: number): number {
const buffer = Buffer.alloc(this.span);
const hi32 = Number(src >> BigInt(32));
const lo32 = Number(src & BigInt(0xffffffff));
buffer.writeUInt32BE(hi32, 0);
buffer.writeUInt32BE(lo32, 4);
const buffer = toBufferBE(src, this.span);
b.set(buffer, offset);
return this.span;
}
@ -45,8 +40,13 @@ export class HexBytes extends Layout<string> {
}
/** A big-endian u64, returned as a bigint. */
export function u64be(property?: string | undefined): UInt64BE {
return new UInt64BE(8, property);
export function u64be(property?: string | undefined): UIntBE {
return new UIntBE(8, property);
}
/** A big-endian u256, returned as a bigint. */
export function u256be(property?: string | undefined): UIntBE {
return new UIntBE(32, property);
}
/** An array of numBytes bytes, returned as a hexadecimal string. */

View File

@ -0,0 +1,73 @@
import { PythGovernanceActionImpl } from "./PythGovernanceAction";
import * as BufferLayout from "@solana/buffer-layout";
import * as BufferLayoutExt from "./BufferLayoutExt";
import { ChainName } from "../chains";
/** Executes an action from the executor contract via the specified executorAddress, callAddress, value, and calldata */
export class EvmExecute extends PythGovernanceActionImpl {
static layout: BufferLayout.Structure<
Readonly<{
executorAddress: string;
callAddress: string;
value: bigint;
calldata: Uint8Array;
}>
> = BufferLayout.struct([
BufferLayoutExt.hexBytes(20, "executorAddress"),
BufferLayoutExt.hexBytes(20, "callAddress"),
BufferLayoutExt.u256be("value"),
BufferLayout.blob(new BufferLayout.GreedyCount(), "calldata"),
]);
constructor(
targetChainId: ChainName,
readonly executorAddress: string,
readonly callAddress: string,
readonly value: bigint,
readonly calldata: Buffer
) {
super(targetChainId, "Execute");
}
static decode(data: Buffer): EvmExecute | undefined {
const decoded = PythGovernanceActionImpl.decodeWithPayload(
data,
"Execute",
this.layout
);
if (!decoded) return undefined;
return new EvmExecute(
decoded[0].targetChainId,
decoded[1].executorAddress,
decoded[1].callAddress,
decoded[1].value,
Buffer.from(decoded[1].calldata)
);
}
encode(): Buffer {
// encodeWithPayload creates a buffer using layout.span but EvmExecute.layout span is -1
// because the calldata length is unknown. So we create a layout with a known calldata length
// and use that for encoding
const layout_with_known_span: BufferLayout.Structure<
Readonly<{
executorAddress: string;
callAddress: string;
value: bigint;
calldata: Uint8Array;
}>
> = BufferLayout.struct([
BufferLayoutExt.hexBytes(20, "executorAddress"),
BufferLayoutExt.hexBytes(20, "callAddress"),
BufferLayoutExt.u256be("value"),
BufferLayout.blob(this.calldata.length, "calldata"),
]);
return super.encodeWithPayload(layout_with_known_span, {
executorAddress: this.executorAddress,
callAddress: this.callAddress,
value: this.value,
calldata: this.calldata,
});
}
}

View File

@ -17,6 +17,10 @@ export const TargetAction = {
SetWormholeAddress: 6,
} as const;
export const EvmExecutorAction = {
Execute: 0,
} as const;
/** Helper to get the ActionName from a (moduleId, actionId) tuple*/
export function toActionName(
deserialized: Readonly<{ moduleId: number; actionId: number }>
@ -40,13 +44,19 @@ export function toActionName(
case 6:
return "SetWormholeAddress";
}
} else if (
deserialized.moduleId == MODULE_EVM_EXECUTOR &&
deserialized.actionId == 0
) {
return "Execute";
}
return undefined;
}
export declare type ActionName =
| keyof typeof ExecutorAction
| keyof typeof TargetAction;
| keyof typeof TargetAction
| keyof typeof EvmExecutorAction;
/** Governance header that should be in every Pyth crosschain governance message*/
export class PythGovernanceHeader {
@ -109,9 +119,12 @@ export class PythGovernanceHeader {
if (this.action in ExecutorAction) {
module = MODULE_EXECUTOR;
action = ExecutorAction[this.action as keyof typeof ExecutorAction];
} else {
} else if (this.action in TargetAction) {
module = MODULE_TARGET;
action = TargetAction[this.action as keyof typeof TargetAction];
} else {
module = MODULE_EVM_EXECUTOR;
action = EvmExecutorAction[this.action as keyof typeof EvmExecutorAction];
}
if (toChainId(this.targetChainId) === undefined)
throw new Error(`Invalid chain id ${this.targetChainId}`);
@ -131,10 +144,12 @@ export class PythGovernanceHeader {
export const MAGIC_NUMBER = 0x4d475450;
export const MODULE_EXECUTOR = 0;
export const MODULE_TARGET = 1;
export const MODULES = [MODULE_EXECUTOR, MODULE_TARGET];
export const MODULE_EVM_EXECUTOR = 2;
export const MODULES = [MODULE_EXECUTOR, MODULE_TARGET, MODULE_EVM_EXECUTOR];
export interface PythGovernanceAction {
readonly targetChainId: ChainName;
encode(): Buffer;
}

View File

@ -16,6 +16,7 @@ import { SetDataSources } from "./SetDataSources";
import { SetValidPeriod } from "./SetValidPeriod";
import { SetFee } from "./SetFee";
import { EvmSetWormholeAddress } from "./SetWormholeAddress";
import { EvmExecute } from "./ExecuteAction";
/** Decode a governance payload */
export function decodeGovernancePayload(
@ -53,6 +54,8 @@ export function decodeGovernancePayload(
return RequestGovernanceDataSourceTransfer.decode(data);
case "SetWormholeAddress":
return EvmSetWormholeAddress.decode(data);
case "Execute":
return EvmExecute.decode(data);
default:
return undefined;
}
@ -67,3 +70,4 @@ export * from "./SetDataSources";
export * from "./SetValidPeriod";
export * from "./SetFee";
export * from "./SetWormholeAddress";
export * from "./ExecuteAction";

2
package-lock.json generated
View File

@ -1918,6 +1918,7 @@
"@solana/buffer-layout": "^4.0.1",
"@solana/web3.js": "^1.73.0",
"@sqds/mesh": "^1.0.6",
"bigint-buffer": "^1.1.5",
"ethers": "^5.7.2",
"lodash": "^4.17.21",
"typescript": "^4.9.4"
@ -104649,6 +104650,7 @@
"@types/bn.js": "^5.1.1",
"@types/jest": "^29.2.5",
"@types/lodash": "^4.14.191",
"bigint-buffer": "*",
"ethers": "^5.7.2",
"fast-check": "^3.10.0",
"jest": "^29.3.1",