Răsfoiți Sursa

[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>
Dev Kalra 1 an în urmă
părinte
comite
f3ad917c6b

+ 86 - 0
contract_manager/scripts/entropy-accept-admin-and-ownership.ts

@@ -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();

+ 81 - 2
contract_manager/src/contracts/evm.ts

@@ -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> {

+ 15 - 0
contract_manager/store/chains/EvmChains.yaml

@@ -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

+ 22 - 1
contract_manager/store/contracts/EvmEntropyContracts.yaml

@@ -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

+ 3 - 2
governance/xc_admin/packages/xc_admin_common/package.json

@@ -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"
   }
 }

+ 20 - 0
governance/xc_admin/packages/xc_admin_common/src/__tests__/BufferLayoutExt.test.ts

@@ -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();
+});

+ 29 - 0
governance/xc_admin/packages/xc_admin_common/src/__tests__/GovernancePayload.test.ts

@@ -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");
     }

+ 12 - 12
governance/xc_admin/packages/xc_admin_common/src/governance_payload/BufferLayoutExt.ts

@@ -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. */

+ 73 - 0
governance/xc_admin/packages/xc_admin_common/src/governance_payload/ExecuteAction.ts

@@ -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,
+    });
+  }
+}

+ 18 - 3
governance/xc_admin/packages/xc_admin_common/src/governance_payload/PythGovernanceAction.ts

@@ -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;
 }
 

+ 4 - 0
governance/xc_admin/packages/xc_admin_common/src/governance_payload/index.ts

@@ -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 - 0
package-lock.json

@@ -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",