Эх сурвалжийг харах

xc-admin/MultisigParser for wormhole instructions (#470)

* Checkpoint

* Refactor

* Unrecognized instruction -> unrecognized program

* Shorten tests

* Rename test

* Rename WormholeInstruction to WormholeMultisigInstruction

* Refactor into constructor and builder method

* Rename file
guibescos 2 жил өмнө
parent
commit
d1f5e5955a

+ 6 - 1
xc-admin/package-lock.json

@@ -3749,7 +3749,8 @@
     },
     "node_modules/@pythnetwork/client": {
       "version": "2.9.0",
-      "license": "Apache-2.0",
+      "resolved": "https://registry.npmjs.org/@pythnetwork/client/-/client-2.9.0.tgz",
+      "integrity": "sha512-2CyDmTwPWW+JCQgRKUpwMR/31oiLWH6I3GA0h2ZXIcbt/hWxcr5TXyKlWuyi+l+jh73WWh88gq8NXLoIgRcvkA==",
       "dependencies": {
         "buffer": "^6.0.1"
       },
@@ -12758,6 +12759,7 @@
       "license": "ISC",
       "dependencies": {
         "@certusone/wormhole-sdk": "^0.9.8",
+        "@pythnetwork/client": "^2.9.0",
         "@solana/buffer-layout": "^4.0.1",
         "@solana/web3.js": "^1.73.0",
         "@sqds/mesh": "^1.0.6",
@@ -15330,6 +15332,8 @@
     },
     "@pythnetwork/client": {
       "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/@pythnetwork/client/-/client-2.9.0.tgz",
+      "integrity": "sha512-2CyDmTwPWW+JCQgRKUpwMR/31oiLWH6I3GA0h2ZXIcbt/hWxcr5TXyKlWuyi+l+jh73WWh88gq8NXLoIgRcvkA==",
       "requires": {
         "buffer": "^6.0.1"
       },
@@ -21188,6 +21192,7 @@
       "version": "file:packages/xc-admin-common",
       "requires": {
         "@certusone/wormhole-sdk": "^0.9.8",
+        "@pythnetwork/client": "*",
         "@solana/buffer-layout": "^4.0.1",
         "@solana/web3.js": "^1.73.0",
         "@sqds/mesh": "^1.0.6",

+ 1 - 0
xc-admin/packages/xc-admin-common/package.json

@@ -20,6 +20,7 @@
   },
   "dependencies": {
     "@certusone/wormhole-sdk": "^0.9.8",
+    "@pythnetwork/client": "^2.9.0",
     "@solana/buffer-layout": "^4.0.1",
     "@solana/web3.js": "^1.73.0",
     "@sqds/mesh": "^1.0.6",

+ 365 - 0
xc-admin/packages/xc-admin-common/src/__tests__/WormholeMultisigInstruction.test.ts

@@ -0,0 +1,365 @@
+import { ChainName } from "@certusone/wormhole-sdk";
+import { createWormholeProgramInterface } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole";
+import { AnchorProvider, Wallet } from "@project-serum/anchor";
+import {
+  getPythClusterApiUrl,
+  PythCluster,
+} from "@pythnetwork/client/lib/cluster";
+import {
+  Connection,
+  Keypair,
+  PublicKey,
+  SystemProgram,
+  TransactionInstruction,
+} from "@solana/web3.js";
+import {
+  MultisigInstructionProgram,
+  MultisigParser,
+  WORMHOLE_ADDRESS,
+} from "..";
+import {
+  encodeExecutePostedVaa,
+  ExecutePostedVaaArgs,
+} from "../governance_payload/ExecutePostedVaa";
+import { WormholeMultisigInstruction } from "../multisig_transaction/WormholeMultisigInstruction";
+
+test("Wormhole multisig instruction parse: send message without governance payload", (done) => {
+  jest.setTimeout(60000);
+
+  const cluster: PythCluster = "devnet";
+  const wormholeProgram = createWormholeProgramInterface(
+    WORMHOLE_ADDRESS[cluster]!,
+    new AnchorProvider(
+      new Connection(getPythClusterApiUrl(cluster)),
+      new Wallet(new Keypair()),
+      AnchorProvider.defaultOptions()
+    )
+  );
+  const parser = new MultisigParser(cluster);
+
+  wormholeProgram.methods
+    .postMessage(1, Buffer.from([0]), 1)
+    .accounts({
+      bridge: PublicKey.unique(),
+      message: PublicKey.unique(),
+      emitter: PublicKey.unique(),
+      sequence: PublicKey.unique(),
+      feeCollector: PublicKey.unique(),
+      clock: PublicKey.unique(),
+    })
+    .instruction()
+    .then((instruction) => {
+      const parsedInstruction = parser.parseInstruction(instruction);
+      expect(
+        parsedInstruction instanceof WormholeMultisigInstruction
+      ).toBeTruthy();
+      if (parsedInstruction instanceof WormholeMultisigInstruction) {
+        expect(parsedInstruction.program).toBe(
+          MultisigInstructionProgram.WormholeBridge
+        );
+        expect(parsedInstruction.name).toBe("postMessage");
+        expect(
+          parsedInstruction.accounts.named["bridge"].pubkey.equals(
+            instruction.keys[0].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["bridge"].isSigner).toBe(
+          instruction.keys[0].isSigner
+        );
+        expect(parsedInstruction.accounts.named["bridge"].isWritable).toBe(
+          instruction.keys[0].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["message"].pubkey.equals(
+            instruction.keys[1].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["message"].isSigner).toBe(
+          instruction.keys[1].isSigner
+        );
+        expect(parsedInstruction.accounts.named["message"].isWritable).toBe(
+          instruction.keys[1].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["emitter"].pubkey.equals(
+            instruction.keys[2].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["emitter"].isSigner).toBe(
+          instruction.keys[2].isSigner
+        );
+        expect(parsedInstruction.accounts.named["emitter"].isWritable).toBe(
+          instruction.keys[2].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["sequence"].pubkey.equals(
+            instruction.keys[3].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["sequence"].isSigner).toBe(
+          instruction.keys[3].isSigner
+        );
+        expect(parsedInstruction.accounts.named["sequence"].isWritable).toBe(
+          instruction.keys[3].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["payer"].pubkey.equals(
+            instruction.keys[4].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["payer"].isSigner).toBe(
+          instruction.keys[4].isSigner
+        );
+        expect(parsedInstruction.accounts.named["payer"].isWritable).toBe(
+          instruction.keys[4].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["feeCollector"].pubkey.equals(
+            instruction.keys[5].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["feeCollector"].isSigner).toBe(
+          instruction.keys[5].isSigner
+        );
+        expect(
+          parsedInstruction.accounts.named["feeCollector"].isWritable
+        ).toBe(instruction.keys[5].isWritable);
+        expect(
+          parsedInstruction.accounts.named["clock"].pubkey.equals(
+            instruction.keys[6].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["clock"].isSigner).toBe(
+          instruction.keys[6].isSigner
+        );
+        expect(parsedInstruction.accounts.named["clock"].isWritable).toBe(
+          instruction.keys[6].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["rent"].pubkey.equals(
+            instruction.keys[7].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["rent"].isSigner).toBe(
+          instruction.keys[7].isSigner
+        );
+        expect(parsedInstruction.accounts.named["rent"].isWritable).toBe(
+          instruction.keys[7].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["systemProgram"].pubkey.equals(
+            instruction.keys[8].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["systemProgram"].isSigner).toBe(
+          instruction.keys[8].isSigner
+        );
+        expect(
+          parsedInstruction.accounts.named["systemProgram"].isWritable
+        ).toBe(instruction.keys[8].isWritable);
+        expect(parsedInstruction.accounts.remaining.length).toBe(0);
+
+        expect(parsedInstruction.args.nonce).toBe(1);
+        expect(parsedInstruction.args.payload.equals(Buffer.from([0])));
+        expect(parsedInstruction.args.consistencyLevel).toBe(1);
+        expect(parsedInstruction.args.targetChain).toBeUndefined();
+        done();
+      } else {
+        done("Not instance of WormholeInstruction");
+      }
+    });
+});
+
+test("Wormhole multisig instruction parse: send message with governance payload", (done) => {
+  jest.setTimeout(60000);
+
+  const cluster: PythCluster = "devnet";
+  const wormholeProgram = createWormholeProgramInterface(
+    WORMHOLE_ADDRESS[cluster]!,
+    new AnchorProvider(
+      new Connection(getPythClusterApiUrl(cluster)),
+      new Wallet(new Keypair()),
+      AnchorProvider.defaultOptions()
+    )
+  );
+  const parser = new MultisigParser(cluster);
+
+  const executePostedVaaArgs: ExecutePostedVaaArgs = {
+    targetChainId: "pythnet" as ChainName,
+    instructions: [
+      SystemProgram.transfer({
+        fromPubkey: PublicKey.unique(),
+        toPubkey: PublicKey.unique(),
+        lamports: 890880,
+      }),
+    ],
+  };
+
+  wormholeProgram.methods
+    .postMessage(0, encodeExecutePostedVaa(executePostedVaaArgs), 0)
+    .accounts({
+      bridge: PublicKey.unique(),
+      message: PublicKey.unique(),
+      emitter: PublicKey.unique(),
+      sequence: PublicKey.unique(),
+      feeCollector: PublicKey.unique(),
+      clock: PublicKey.unique(),
+    })
+    .instruction()
+    .then((instruction) => {
+      const parsedInstruction = parser.parseInstruction(instruction);
+      if (parsedInstruction instanceof WormholeMultisigInstruction) {
+        expect(
+          parsedInstruction instanceof WormholeMultisigInstruction
+        ).toBeTruthy();
+        expect(parsedInstruction.program).toBe(
+          MultisigInstructionProgram.WormholeBridge
+        );
+        expect(parsedInstruction.name).toBe("postMessage");
+        expect(
+          parsedInstruction.accounts.named["bridge"].pubkey.equals(
+            instruction.keys[0].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["bridge"].isSigner).toBe(
+          instruction.keys[0].isSigner
+        );
+        expect(parsedInstruction.accounts.named["bridge"].isWritable).toBe(
+          instruction.keys[0].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["message"].pubkey.equals(
+            instruction.keys[1].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["message"].isSigner).toBe(
+          instruction.keys[1].isSigner
+        );
+        expect(parsedInstruction.accounts.named["message"].isWritable).toBe(
+          instruction.keys[1].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["emitter"].pubkey.equals(
+            instruction.keys[2].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["emitter"].isSigner).toBe(
+          instruction.keys[2].isSigner
+        );
+        expect(parsedInstruction.accounts.named["emitter"].isWritable).toBe(
+          instruction.keys[2].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["sequence"].pubkey.equals(
+            instruction.keys[3].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["sequence"].isSigner).toBe(
+          instruction.keys[3].isSigner
+        );
+        expect(parsedInstruction.accounts.named["sequence"].isWritable).toBe(
+          instruction.keys[3].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["payer"].pubkey.equals(
+            instruction.keys[4].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["payer"].isSigner).toBe(
+          instruction.keys[4].isSigner
+        );
+        expect(parsedInstruction.accounts.named["payer"].isWritable).toBe(
+          instruction.keys[4].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["feeCollector"].pubkey.equals(
+            instruction.keys[5].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["feeCollector"].isSigner).toBe(
+          instruction.keys[5].isSigner
+        );
+        expect(
+          parsedInstruction.accounts.named["feeCollector"].isWritable
+        ).toBe(instruction.keys[5].isWritable);
+        expect(
+          parsedInstruction.accounts.named["clock"].pubkey.equals(
+            instruction.keys[6].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["clock"].isSigner).toBe(
+          instruction.keys[6].isSigner
+        );
+        expect(parsedInstruction.accounts.named["clock"].isWritable).toBe(
+          instruction.keys[6].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["rent"].pubkey.equals(
+            instruction.keys[7].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["rent"].isSigner).toBe(
+          instruction.keys[7].isSigner
+        );
+        expect(parsedInstruction.accounts.named["rent"].isWritable).toBe(
+          instruction.keys[7].isWritable
+        );
+        expect(
+          parsedInstruction.accounts.named["systemProgram"].pubkey.equals(
+            instruction.keys[8].pubkey
+          )
+        ).toBeTruthy();
+        expect(parsedInstruction.accounts.named["systemProgram"].isSigner).toBe(
+          instruction.keys[8].isSigner
+        );
+        expect(
+          parsedInstruction.accounts.named["systemProgram"].isWritable
+        ).toBe(instruction.keys[8].isWritable);
+        expect(parsedInstruction.accounts.remaining.length).toBe(0);
+
+        expect(parsedInstruction.args.nonce).toBe(0);
+        expect(
+          parsedInstruction.args.payload.equals(
+            encodeExecutePostedVaa(executePostedVaaArgs)
+          )
+        );
+        expect(parsedInstruction.args.consistencyLevel).toBe(0);
+
+        expect(parsedInstruction.args.governanceName).toBe("ExecutePostedVaa");
+
+        expect(parsedInstruction.args.governanceArgs.targetChainId).toBe(
+          "pythnet"
+        );
+
+        (
+          parsedInstruction.args.governanceArgs
+            .instructions as TransactionInstruction[]
+        ).forEach((instruction, i) => {
+          expect(
+            instruction.programId.equals(
+              executePostedVaaArgs.instructions[i].programId
+            )
+          );
+          expect(
+            instruction.data.equals(executePostedVaaArgs.instructions[i].data)
+          );
+          instruction.keys.forEach((account, j) => {
+            expect(
+              account.pubkey.equals(
+                executePostedVaaArgs.instructions[i].keys[j].pubkey
+              )
+            ).toBeTruthy();
+            expect(account.isSigner).toBe(
+              executePostedVaaArgs.instructions[i].keys[j].isSigner
+            );
+            expect(account.isWritable).toBe(
+              executePostedVaaArgs.instructions[i].keys[j].isWritable
+            );
+          });
+        });
+        done();
+      } else {
+        done("Not instance of WormholeInstruction");
+      }
+    });
+});

+ 17 - 0
xc-admin/packages/xc-admin-common/src/governance_payload/index.ts

@@ -5,6 +5,10 @@ import {
   toChainName,
 } from "@certusone/wormhole-sdk";
 import * as BufferLayout from "@solana/buffer-layout";
+import {
+  decodeExecutePostedVaa,
+  ExecutePostedVaaArgs,
+} from "./ExecutePostedVaa";
 
 export const ExecutorAction = {
   ExecutePostedVaa: 0,
@@ -130,4 +134,17 @@ export function verifyHeader(
   return governanceHeader;
 }
 
+export function decodeGovernancePayload(data: Buffer): {
+  name: string;
+  args: ExecutePostedVaaArgs;
+} {
+  const header = decodeHeader(data);
+  switch (header.action) {
+    case "ExecutePostedVaa":
+      return { name: "ExecutePostedVaa", args: decodeExecutePostedVaa(data) };
+    default:
+      throw "Not supported";
+  }
+}
+
 export { decodeExecutePostedVaa } from "./ExecutePostedVaa";

+ 2 - 0
xc-admin/packages/xc-admin-common/src/index.ts

@@ -1,3 +1,5 @@
 export * from "./multisig";
 export * from "./propose";
 export * from "./governance_payload";
+export * from "./wormhole";
+export * from "./multisig_transaction";

+ 67 - 0
xc-admin/packages/xc-admin-common/src/multisig_transaction/WormholeMultisigInstruction.ts

@@ -0,0 +1,67 @@
+import { createReadOnlyWormholeProgramInterface } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole";
+import { WormholeInstructionCoder } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole/coder/instruction";
+import { getPythClusterApiUrl } from "@pythnetwork/client/lib/cluster";
+import { Connection, TransactionInstruction } from "@solana/web3.js";
+import { MultisigInstruction, MultisigInstructionProgram } from ".";
+import { decodeGovernancePayload } from "../governance_payload";
+import { AnchorAccounts, resolveAccountNames } from "./anchor";
+
+export class WormholeMultisigInstruction implements MultisigInstruction {
+  readonly program = MultisigInstructionProgram.WormholeBridge;
+  readonly name: string;
+  readonly args: { [key: string]: any };
+  readonly accounts: AnchorAccounts;
+
+  constructor(
+    name: string,
+    args: { [key: string]: any },
+    accounts: AnchorAccounts
+  ) {
+    this.name = name;
+    this.args = args;
+    this.accounts = accounts;
+  }
+
+  static fromTransactionInstruction(
+    instruction: TransactionInstruction
+  ): WormholeMultisigInstruction {
+    const wormholeProgram = createReadOnlyWormholeProgramInterface(
+      instruction.programId,
+      new Connection(getPythClusterApiUrl("devnet")) // Hack to get a decoder, this connection won't actually be used
+    );
+
+    const deserializedData = (
+      wormholeProgram.coder.instruction as WormholeInstructionCoder
+    ).decode(instruction.data);
+
+    if (deserializedData) {
+      let result = new WormholeMultisigInstruction(
+        deserializedData.name,
+        deserializedData.data,
+        resolveAccountNames(
+          wormholeProgram.idl,
+          deserializedData.name,
+          instruction
+        )
+      );
+
+      if (result.name === "postMessage") {
+        try {
+          const decoded = decodeGovernancePayload(result.args.payload);
+          result.args.governanceName = decoded.name;
+          result.args.governanceArgs = decoded.args;
+        } catch {
+          result.args.governanceName = "Unrecognized governance message";
+          result.args.governanceArgs = {};
+        }
+      }
+      return result;
+    } else {
+      return new WormholeMultisigInstruction(
+        "Unrecognized instruction",
+        {},
+        { named: {}, remaining: instruction.keys }
+      );
+    }
+  }
+}

+ 30 - 0
xc-admin/packages/xc-admin-common/src/multisig_transaction/anchor.ts

@@ -0,0 +1,30 @@
+import { Idl } from "@coral-xyz/anchor";
+import { AccountMeta, TransactionInstruction } from "@solana/web3.js";
+
+type NamedAccounts = Record<string, AccountMeta>;
+type RemainingAccounts = AccountMeta[];
+export type AnchorAccounts = {
+  named: NamedAccounts;
+  remaining: RemainingAccounts;
+};
+
+export function resolveAccountNames(
+  idl: Idl,
+  name: string,
+  instruction: TransactionInstruction
+): { named: NamedAccounts; remaining: RemainingAccounts } {
+  const ix = idl.instructions.find((ix) => ix.name == name);
+  if (!ix) {
+    throw Error("Instruction name not found");
+  }
+  const named: NamedAccounts = {};
+  const remaining: RemainingAccounts = [];
+  instruction.keys.map((account, idx) => {
+    if (idx < ix.accounts.length) {
+      named[ix.accounts[idx].name] = account;
+    } else {
+      remaining.push(account);
+    }
+  });
+  return { named, remaining };
+}

+ 59 - 0
xc-admin/packages/xc-admin-common/src/multisig_transaction/index.ts

@@ -0,0 +1,59 @@
+import {
+  getPythProgramKeyForCluster,
+  PythCluster,
+} from "@pythnetwork/client/lib/cluster";
+import { PublicKey, TransactionInstruction } from "@solana/web3.js";
+import { WORMHOLE_ADDRESS } from "../wormhole";
+import { WormholeMultisigInstruction } from "./WormholeMultisigInstruction";
+
+export enum MultisigInstructionProgram {
+  PythOracle,
+  WormholeBridge,
+  UnrecognizedProgram,
+}
+
+export interface MultisigInstruction {
+  readonly program: MultisigInstructionProgram;
+}
+
+export class UnrecognizedProgram implements MultisigInstruction {
+  readonly program = MultisigInstructionProgram.UnrecognizedProgram;
+  readonly instruction: TransactionInstruction;
+
+  constructor(instruction: TransactionInstruction) {
+    this.instruction = instruction;
+  }
+
+  static fromTransactionInstruction(
+    instruction: TransactionInstruction
+  ): UnrecognizedProgram {
+    return new UnrecognizedProgram(instruction);
+  }
+}
+
+export class PythMultisigInstruction implements MultisigInstruction {
+  readonly program = MultisigInstructionProgram.PythOracle;
+}
+
+export class MultisigParser {
+  readonly pythOracleAddress: PublicKey;
+  readonly wormholeBridgeAddress: PublicKey | undefined;
+
+  constructor(cluster: PythCluster) {
+    this.pythOracleAddress = getPythProgramKeyForCluster(cluster);
+    this.wormholeBridgeAddress = WORMHOLE_ADDRESS[cluster];
+  }
+
+  parseInstruction(instruction: TransactionInstruction): MultisigInstruction {
+    if (
+      this.wormholeBridgeAddress &&
+      instruction.programId.equals(this.wormholeBridgeAddress)
+    ) {
+      return WormholeMultisigInstruction.fromTransactionInstruction(
+        instruction
+      );
+    } else {
+      return UnrecognizedProgram.fromTransactionInstruction(instruction);
+    }
+  }
+}

+ 10 - 0
xc-admin/packages/xc-admin-common/src/wormhole.ts

@@ -0,0 +1,10 @@
+import { PythCluster } from "@pythnetwork/client/lib/cluster";
+import { PublicKey } from "@solana/web3.js";
+
+export const WORMHOLE_ADDRESS: Record<PythCluster, PublicKey | undefined> = {
+  "mainnet-beta": new PublicKey("worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth"),
+  pythtest: new PublicKey("EUrRARh92Cdc54xrDn6qzaqjA77NRrCcfbr8kPwoTL4z"),
+  devnet: new PublicKey("3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5"),
+  pythnet: new PublicKey("H3fxXJ86ADW2PNuDDmZJg6mzTtPxkYCpNuQUTgmJ7AjU"),
+  testnet: undefined,
+};