import { readFileSync } from "fs"; import { Connection, Keypair, ParsedInstruction, PartiallyDecodedInstruction, PublicKey, SystemProgram, SYSVAR_CLOCK_PUBKEY, SYSVAR_RENT_PUBKEY, Transaction, } from "@solana/web3.js"; import * as bs58 from "bs58"; import { getPythClusterApiUrl, PythCluster, } from "@pythnetwork/client/lib/cluster"; import SquadsMesh from "@sqds/mesh"; import { AnchorProvider } from "@coral-xyz/anchor"; import { Wallet } from "@coral-xyz/anchor/dist/cjs/provider"; import NodeWallet from "@coral-xyz/anchor/dist/cjs/nodewallet"; import { executeProposal, MultisigVault, WORMHOLE_ADDRESS, WORMHOLE_API_ENDPOINT, } from "@pythnetwork/xc-admin-common"; import { createWormholeProgramInterface, deriveEmitterSequenceKey, deriveFeeCollectorKey, deriveWormholeBridgeDataKey, } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole"; import { KeyValueConfig, Storable } from "./base"; import { PriorityFeeConfig } from "@pythnetwork/solana-utils"; class InvalidTransactionError extends Error { constructor(message: string) { super(message); this.name = "InvalidTransactionError"; } } // A registry of solana RPC nodes for each cluster. export type SolanaRpcRegistry = (cluster: PythCluster) => string; export class SubmittedWormholeMessage { constructor( public emitter: PublicKey, public sequenceNumber: number, public cluster: PythCluster ) {} /** * Attempts to find the emitter and sequence number of a wormhole message from a transaction * Parses the transaction and looks for the wormhole postMessage instruction to find the emitter * Inspects the transaction logs to find the sequence number * @param signature signature of the transaction to inspect * @param cluster the cluster the transaction was submitted to * @param registry registry of RPC nodes to use for each solana network. Defaults to the Solana public RPCs if not provided. */ static async fromTransactionSignature( signature: string, cluster: PythCluster, registry: SolanaRpcRegistry = getPythClusterApiUrl ): Promise { const connection = new Connection(registry(cluster), "confirmed"); const txDetails = await connection.getParsedTransaction(signature); const sequenceLogPrefix = "Sequence: "; const txLog = txDetails?.meta?.logMessages?.find((s) => s.includes(sequenceLogPrefix) ); const sequenceNumber = Number( txLog?.substring( txLog.indexOf(sequenceLogPrefix) + sequenceLogPrefix.length ) ); const wormholeAddress = WORMHOLE_ADDRESS[cluster]; if (!wormholeAddress) throw new Error(`Invalid cluster ${cluster}`); let emitter: PublicKey | undefined = undefined; let allInstructions: (ParsedInstruction | PartiallyDecodedInstruction)[] = txDetails?.transaction?.message?.instructions || []; txDetails?.meta?.innerInstructions?.forEach((instruction) => { allInstructions = allInstructions.concat(instruction.instructions); }); allInstructions.forEach((instruction) => { if (!instruction.programId.equals(wormholeAddress)) return; // we assume RPC can not parse wormhole instructions and the type is not ParsedInstruction const wormholeInstruction = instruction as PartiallyDecodedInstruction; if (bs58.decode(wormholeInstruction.data)[0] !== 1) return; // 1 is wormhole postMessage Instruction discriminator emitter = wormholeInstruction.accounts[2]; }); if (!emitter) throw new InvalidTransactionError( "Could not find wormhole postMessage instruction" ); return new SubmittedWormholeMessage(emitter, sequenceNumber, cluster); } /** * Tries to fetch the VAA from the wormhole bridge API waiting for a certain amount of time * before giving up and throwing an error * @param waitingSeconds how long to wait before giving up */ async fetchVaa(waitingSeconds = 1): Promise { const rpcUrl = WORMHOLE_API_ENDPOINT[this.cluster]; const startTime = Date.now(); while (Date.now() - startTime < waitingSeconds * 1000) { const response = await fetch( `${rpcUrl}/v1/signed_vaa/1/${this.emitter.toBuffer().toString("hex")}/${ this.sequenceNumber }` ); if (response.status === 404) { await new Promise((resolve) => setTimeout(resolve, 1000)); continue; } const { vaaBytes } = await response.json(); return Buffer.from(vaaBytes, "base64"); } throw new Error("VAA not found, maybe too soon to fetch?"); } } function asPythCluster(cluster: string): PythCluster { const pythCluster = cluster as PythCluster; getPythClusterApiUrl(pythCluster); // throws if cluster is invalid return pythCluster; } /** * A simple emitter that can send messages to the wormhole bridge * This can be used instead of multisig as a simple way to send messages * and debug contracts deployed on testing networks * You need to set your pyth contract data source / governance source address to this emitter */ export class WormholeEmitter { cluster: PythCluster; wallet: Wallet; constructor(cluster: string, wallet: Wallet) { this.wallet = wallet; this.cluster = asPythCluster(cluster); } public getEmitter() { return this.wallet.publicKey; } /** * Send a wormhole message containing payload through wormhole. * @param payload the contents of the message * @param registry registry of RPC nodes to use for each solana network. Defaults to the Solana public RPCs if not provided. */ async sendMessage( payload: Buffer, registry: SolanaRpcRegistry = getPythClusterApiUrl ) { const provider = new AnchorProvider( new Connection(registry(this.cluster), "confirmed"), this.wallet, { commitment: "confirmed", preflightCommitment: "confirmed", } ); const wormholeAddress = WORMHOLE_ADDRESS[this.cluster]; if (!wormholeAddress) throw new Error(`Invalid cluster ${this.cluster}`); const kp = Keypair.generate(); const feeCollector = deriveFeeCollectorKey(wormholeAddress); const emitter = this.wallet.publicKey; const accounts = { bridge: deriveWormholeBridgeDataKey(wormholeAddress), message: kp.publicKey, emitter: emitter, sequence: deriveEmitterSequenceKey(emitter, wormholeAddress), payer: emitter, feeCollector, clock: SYSVAR_CLOCK_PUBKEY, rent: SYSVAR_RENT_PUBKEY, systemProgram: SystemProgram.programId, }; const wormholeProgram = createWormholeProgramInterface( wormholeAddress, provider ); const transaction = new Transaction(); transaction.add( SystemProgram.transfer({ fromPubkey: emitter, toPubkey: feeCollector, lamports: 1000, }) ); transaction.add( await wormholeProgram.methods .postMessage(0, payload, 0) .accounts(accounts) .instruction() ); const signature = await provider.sendAndConfirm(transaction, [kp]); return SubmittedWormholeMessage.fromTransactionSignature( signature, this.cluster ); } } export class WormholeMultisigProposal { constructor( public address: PublicKey, public squad: SquadsMesh, public cluster: PythCluster ) {} /** * Gets the current state of the proposal which can be "active", "draft", "executed", etc. */ async getState() { const proposal = await this.squad.getTransaction(this.address); // Converts the status object to a string e.g // { "active":{} } => "active" return Object.keys(proposal.status)[0]; } /** * Executes the proposal and returns the wormhole messages that were sent * The proposal must be already approved. */ async execute(): Promise { const proposal = await this.squad.getTransaction(this.address); const signatures = await executeProposal( proposal, this.squad, this.cluster, this.squad.connection.commitment, {} ); const msgs: SubmittedWormholeMessage[] = []; for (const signature of signatures) { try { msgs.push( await SubmittedWormholeMessage.fromTransactionSignature( signature, this.cluster ) ); } catch (e) { if (!(e instanceof InvalidTransactionError)) throw e; } } if (msgs.length > 0) return msgs; throw new Error("No transactions with wormhole messages found"); } } /** * A vault represents a pyth multisig governance realm which exists in solana mainnet or testnet. * It can be used for proposals to send wormhole messages to the wormhole bridge. */ export class Vault extends Storable { static type = "vault"; key: PublicKey; squad?: SquadsMesh; cluster: PythCluster; constructor(key: string, cluster: string) { super(); this.key = new PublicKey(key); this.cluster = asPythCluster(cluster); } getType(): string { return Vault.type; } static fromJson(parsed: { type: string; key: string; cluster: string; }): Vault { if (parsed.type !== Vault.type) throw new Error("Invalid type"); return new Vault(parsed.key, parsed.cluster); } getId(): string { return `${this.cluster}_${this.key.toString()}`; } toJson(): KeyValueConfig { return { key: this.key.toString(), cluster: this.cluster, type: Vault.type, }; } /** * Connects the vault to a wallet that can be used to submit proposals * The wallet should be a multisig signer of the vault * @param wallet * @param registry registry of RPC nodes to use for each solana network. Defaults to the Solana public RPCs if not provided. */ public connect( wallet: Wallet, registry: SolanaRpcRegistry = getPythClusterApiUrl ): void { this.squad = SquadsMesh.endpoint(registry(this.cluster), wallet); } getSquadOrThrow(): SquadsMesh { if (!this.squad) throw new Error("Please connect a wallet to the vault"); return this.squad; } /** * Gets the emitter address of the vault * @param registry registry of RPC nodes to use for each solana network. Defaults to the Solana public RPCs if not provided. */ public async getEmitter(registry: SolanaRpcRegistry = getPythClusterApiUrl) { const squad = SquadsMesh.endpoint( registry(this.cluster), new NodeWallet(Keypair.generate()) // dummy wallet ); return squad.getAuthorityPDA(this.key, 1); } /** * Gets the last sequence number of the vault emitter * This is used to determine the sequence number of the next wormhole message * Fetches the sequence number from the wormholescan API * @returns the last sequence number */ public async getLastSequenceNumber(): Promise { const rpcUrl = WORMHOLE_API_ENDPOINT[this.cluster]; const emitter = await this.getEmitter(); const response = await fetch( `${rpcUrl}/api/v1/vaas/1/${emitter.toBase58()}` ); const { data } = await response.json(); return data[0].sequence; } /** * Proposes sending an array of wormhole messages to the wormhole bridge * Requires a wallet to be connected to the vault * * @param payloads the payloads to send to the wormhole bridge * @param proposalAddress if specified, will continue an existing proposal */ public async proposeWormholeMessage( payloads: Buffer[], proposalAddress?: PublicKey, priorityFeeConfig: PriorityFeeConfig = {} ): Promise { const squad = this.getSquadOrThrow(); const multisigVault = new MultisigVault( squad.wallet as Wallet, this.cluster, squad, this.key ); const txAccount = await multisigVault.proposeWormholeMultipleMessagesWithPayer( payloads, squad.wallet.publicKey, proposalAddress, priorityFeeConfig ); return new WormholeMultisigProposal(txAccount, squad, this.cluster); } } /** * Loads a solana wallet from a file. The file should contain the secret key in array of integers format * This wallet can be used to connect to a vault and submit proposals * @param walletPath path to the wallet file */ export async function loadHotWallet(walletPath: string): Promise { return new NodeWallet( Keypair.fromSecretKey( Uint8Array.from(JSON.parse(readFileSync(walletPath, "ascii"))) ) ); }