diff --git a/xc-admin/package-lock.json b/xc-admin/package-lock.json index 5ebb4ebd..d13e1158 100644 --- a/xc-admin/package-lock.json +++ b/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", diff --git a/xc-admin/packages/xc-admin-common/package.json b/xc-admin/packages/xc-admin-common/package.json index 54e8ce1f..2ad26eb7 100644 --- a/xc-admin/packages/xc-admin-common/package.json +++ b/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", diff --git a/xc-admin/packages/xc-admin-common/src/__tests__/WormholeMultisigInstruction.test.ts b/xc-admin/packages/xc-admin-common/src/__tests__/WormholeMultisigInstruction.test.ts new file mode 100644 index 00000000..6cc8344f --- /dev/null +++ b/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"); + } + }); +}); diff --git a/xc-admin/packages/xc-admin-common/src/governance_payload/index.ts b/xc-admin/packages/xc-admin-common/src/governance_payload/index.ts index 70431078..085f70c5 100644 --- a/xc-admin/packages/xc-admin-common/src/governance_payload/index.ts +++ b/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"; diff --git a/xc-admin/packages/xc-admin-common/src/index.ts b/xc-admin/packages/xc-admin-common/src/index.ts index 35fd45b2..ac11a3b3 100644 --- a/xc-admin/packages/xc-admin-common/src/index.ts +++ b/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"; diff --git a/xc-admin/packages/xc-admin-common/src/multisig_transaction/WormholeMultisigInstruction.ts b/xc-admin/packages/xc-admin-common/src/multisig_transaction/WormholeMultisigInstruction.ts new file mode 100644 index 00000000..6af000f7 --- /dev/null +++ b/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 } + ); + } + } +} diff --git a/xc-admin/packages/xc-admin-common/src/multisig_transaction/anchor.ts b/xc-admin/packages/xc-admin-common/src/multisig_transaction/anchor.ts new file mode 100644 index 00000000..af332a38 --- /dev/null +++ b/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; +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 }; +} diff --git a/xc-admin/packages/xc-admin-common/src/multisig_transaction/index.ts b/xc-admin/packages/xc-admin-common/src/multisig_transaction/index.ts new file mode 100644 index 00000000..d861074c --- /dev/null +++ b/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); + } + } +} diff --git a/xc-admin/packages/xc-admin-common/src/wormhole.ts b/xc-admin/packages/xc-admin-common/src/wormhole.ts new file mode 100644 index 00000000..6be1a469 --- /dev/null +++ b/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 = { + "mainnet-beta": new PublicKey("worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth"), + pythtest: new PublicKey("EUrRARh92Cdc54xrDn6qzaqjA77NRrCcfbr8kPwoTL4z"), + devnet: new PublicKey("3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5"), + pythnet: new PublicKey("H3fxXJ86ADW2PNuDDmZJg6mzTtPxkYCpNuQUTgmJ7AjU"), + testnet: undefined, +};