diff --git a/cloud_functions/src/alarmMissingVaas.ts b/cloud_functions/src/alarmMissingVaas.ts index 716e97ec..35954155 100644 --- a/cloud_functions/src/alarmMissingVaas.ts +++ b/cloud_functions/src/alarmMissingVaas.ts @@ -117,7 +117,7 @@ export async function alarmMissingVaas(req: any, res: any) { console.log(`skipping over ${vaaKey} because it is governed`); continue; } - if (await isVAASigned(vaaKey)) { + if (await isVAASigned(getEnvironment(), vaaKey)) { console.log(`skipping over ${vaaKey} because it is signed`); continue; } @@ -249,7 +249,7 @@ async function getAndProcessReobsVAAs(): Promise> { if (data) { const vaas: ReobserveInfo[] = data.VAAs; vaas.forEach(async (vaa) => { - if (!(await isVAASigned(vaa.vaaKey))) { + if (!(await isVAASigned(getEnvironment(), vaa.vaaKey))) { console.log('keeping reobserved VAA in firestore', vaa.vaaKey); current.set(vaa.txhash, vaa); } else { diff --git a/cloud_functions/src/getReobserveVaas.ts b/cloud_functions/src/getReobserveVaas.ts index 64376b3d..2f237dcc 100644 --- a/cloud_functions/src/getReobserveVaas.ts +++ b/cloud_functions/src/getReobserveVaas.ts @@ -1,7 +1,9 @@ import { assertEnvironmentVariable, isVAASigned } from './utils'; import { ReobserveInfo } from './types'; import { Firestore } from 'firebase-admin/firestore'; -import axios from 'axios'; +import { CHAIN_ID_SOLANA } from '@certusone/wormhole-sdk'; +import { convertSolanaTxToAccts } from '@wormhole-foundation/wormhole-monitor-common'; +import { getEnvironment } from '@wormhole-foundation/wormhole-monitor-common'; const MAX_VAAS_TO_REOBSERVE = 25; @@ -27,11 +29,20 @@ export async function getReobserveVaas(req: any, res: any) { console.log('could not get missing VAAs', e); res.sendStatus(500); } - let reobs: ReobserveInfo[] = []; - reobsMap.forEach((vaa) => { - reobs.push(vaa); - }); - res.status(200).send(JSON.stringify(reobs)); + + let reobs: (ReobserveInfo[] | null)[] = []; + try { + const vaaArray = Array.from(reobsMap.values()); + // Process each VAA asynchronously and filter out any null results + reobs = (await Promise.all(vaaArray.map(processVaa))).filter((vaa) => vaa !== null); // Remove any VAA that failed conversion + } catch (e) { + console.error('error processing reobservations', e); + console.error('reobs', reobs); + res.sendStatus(500); + } + // Need to flatten the array of arrays before returning + const retVal = reobs.flat(); + res.status(200).send(JSON.stringify(retVal)); return; } @@ -44,6 +55,7 @@ async function getAndProcessReobsVAAs(): Promise> { let current = new Map(); let putBack: ReobserveInfo[] = []; let vaas: ReobserveInfo[] = []; + let realVaas: ReobserveInfo[] = []; try { const res = await firestore.runTransaction(async (t) => { @@ -59,6 +71,21 @@ async function getAndProcessReobsVAAs(): Promise> { } vaas = data.VAAs.slice(0, MAX_VAAS_TO_REOBSERVE); console.log('number of reobserved VAAs', vaas.length); + const MAX_SOLANA_VAAS_TO_REOBSERVE = 2; + // Can only process 2 Solana VAAs at a time due to rpc rate limits + // So we put the rest back in the collection + let solanaCount = 0; + for (const vaa of vaas) { + if (vaa.chain === CHAIN_ID_SOLANA) { + solanaCount++; + if (solanaCount > MAX_SOLANA_VAAS_TO_REOBSERVE) { + putBack.push(vaa); + continue; + } + } + realVaas.push(vaa); + } + console.log('number of real VAAs', realVaas.length); } t.update(collectionRef, { VAAs: putBack }); }); @@ -66,11 +93,32 @@ async function getAndProcessReobsVAAs(): Promise> { console.error('error getting reobserved VAAs', e); return current; } - for (const vaa of vaas) { - if (!(await isVAASigned(vaa.vaaKey))) { + for (const vaa of realVaas) { + if (!(await isVAASigned(getEnvironment(), vaa.vaaKey))) { current.set(vaa.txhash, vaa); } } console.log('number of reobservable VAAs that are not signed', current.size); return current; } + +async function processVaa(vaa: ReobserveInfo): Promise { + let vaas: ReobserveInfo[] = []; + + if (vaa.chain === CHAIN_ID_SOLANA) { + const origTxHash = vaa.txhash; + const convertedTxHash: string[] = await convertSolanaTxToAccts(origTxHash); + console.log(`Converted solana txHash ${origTxHash} to account ${convertedTxHash}`); + + if (convertedTxHash.length === 0) { + console.error(`Failed to convert solana txHash ${origTxHash} to an account.`); + return null; // Indicate failure to convert + } + for (const account of convertedTxHash) { + vaas.push({ ...vaa, txhash: account }); // Return a new object with the updated txhash + } + } else { + vaas.push(vaa); // Return the original object for non-Solana chains + } + return vaas; +} diff --git a/cloud_functions/src/utils.ts b/cloud_functions/src/utils.ts index 9e05cd69..b1dc000e 100644 --- a/cloud_functions/src/utils.ts +++ b/cloud_functions/src/utils.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { PagerDutyInfo, SlackInfo } from './types'; +import { Environment } from '@wormhole-foundation/wormhole-monitor-common'; export async function sleep(timeout: number) { return new Promise((resolve) => setTimeout(resolve, timeout)); @@ -86,14 +87,14 @@ export async function formatAndSendToSlack(info: SlackInfo): Promise { return responseData; } -export async function isVAASigned(vaaKey: string): Promise { - const url: string = WormholescanRPC + 'v1/signed_vaa/' + vaaKey; +export async function isVAASigned(env: Environment, vaaKey: string): Promise { + const url: string = WormholescanRPC + 'v1/signed_vaa/' + vaaKey + '?network=' + env.toUpperCase(); try { const response = await axios.get(url); // curl -X 'GET' \ // 'https://api.wormholescan.io/v1/signed_vaa/1/ec7372995d5cc8732397fb0ad35c0121e0eaa90d26f828a534cab54391b3a4f5/319118' \ // -H 'accept: application/json' - // This function will return true if the get returns 200 + // This function will return true if the GET returns 200 // Otherwise, it will return false if (response.status === 200) { return true; diff --git a/common/src/consts.ts b/common/src/consts.ts index ccb8e58e..b76e1e71 100644 --- a/common/src/consts.ts +++ b/common/src/consts.ts @@ -191,7 +191,7 @@ export const CHAIN_INFO_MAP: { [key in Environment]: { [key: string]: CHAIN_INFO evm: false, chainId: CHAIN_ID_SOLANA, endpointUrl: process.env.REACT_APP_SOLANA_RPC || 'https://api.mainnet-beta.solana.com', - explorerStem: `https://solscan.io`, + explorerStem: `https://solana.fm`, }, 2: { name: 'eth', diff --git a/common/src/explorer.ts b/common/src/explorer.ts index ebddd65e..20b11515 100644 --- a/common/src/explorer.ts +++ b/common/src/explorer.ts @@ -36,7 +36,7 @@ import { CHAIN_INFO_MAP, Environment } from './consts'; export const explorerBlock = (network: Environment, chainId: ChainId, block: string) => network === 'mainnet' ? chainId === CHAIN_ID_SOLANA - ? `https://solscan.io/block/${block}` + ? `https://solana.fm/block/${block}` : chainId === CHAIN_ID_ETH ? `https://etherscan.io/block/${block}` : chainId === CHAIN_ID_TERRA @@ -135,7 +135,7 @@ export const explorerBlock = (network: Environment, chainId: ChainId, block: str export const explorerTx = (network: Environment, chainId: ChainId, tx: string) => network === 'mainnet' ? chainId === CHAIN_ID_SOLANA - ? `https://solscan.io/account/${tx}` + ? `https://solana.fm/tx/${tx}` : chainId === CHAIN_ID_ETH ? `https://etherscan.io/tx/${tx}` : chainId === CHAIN_ID_TERRA diff --git a/common/src/index.ts b/common/src/index.ts index 0c01a0b8..7470c445 100644 --- a/common/src/index.ts +++ b/common/src/index.ts @@ -1,4 +1,5 @@ export * from './arrays'; export * from './consts'; -export * from './utils'; export * from './explorer'; +export * from './solana'; +export * from './utils'; diff --git a/common/src/solana.ts b/common/src/solana.ts new file mode 100644 index 00000000..830f56c6 --- /dev/null +++ b/common/src/solana.ts @@ -0,0 +1,62 @@ +import { + CompiledInstruction, + Message, + MessageCompiledInstruction, + MessageV0, +} from '@solana/web3.js'; +import { decode } from 'bs58'; +import { Connection } from '@solana/web3.js'; +import { RPCS_BY_CHAIN } from '@certusone/wormhole-sdk/lib/cjs/relayer'; +import { CONTRACTS } from '@certusone/wormhole-sdk'; + +export const isLegacyMessage = (message: Message | MessageV0): message is Message => { + return message.version === 'legacy'; +}; + +export const normalizeCompileInstruction = ( + instruction: CompiledInstruction | MessageCompiledInstruction +): MessageCompiledInstruction => { + if ('accounts' in instruction) { + return { + accountKeyIndexes: instruction.accounts, + data: decode(instruction.data), + programIdIndex: instruction.programIdIndex, + }; + } else { + return instruction; + } +}; + +export async function convertSolanaTxToAccts(txHash: string): Promise { + const POST_MESSAGE_IX_ID = 0x01; + let accounts: string[] = []; + const connection = new Connection(RPCS_BY_CHAIN.MAINNET.solana!, 'finalized'); + const txs = await connection.getTransactions([txHash], { + maxSupportedTransactionVersion: 0, + }); + for (const tx of txs) { + if (!tx) { + continue; + } + const message = tx.transaction.message; + const accountKeys = isLegacyMessage(message) ? message.accountKeys : message.staticAccountKeys; + const programIdIndex = accountKeys.findIndex( + (i) => i.toBase58() === CONTRACTS.MAINNET.solana.core + ); + const instructions = message.compiledInstructions; + const innerInstructions = + tx.meta?.innerInstructions?.flatMap((i) => i.instructions.map(normalizeCompileInstruction)) || + []; + const whInstructions = innerInstructions + .concat(instructions) + .filter((i) => i.programIdIndex === programIdIndex); + for (const instruction of whInstructions) { + // skip if not postMessage instruction + const instructionId = instruction.data; + if (instructionId[0] !== POST_MESSAGE_IX_ID) continue; + + accounts.push(accountKeys[instruction.accountKeyIndexes[1]].toBase58()); + } + } + return accounts; +} diff --git a/watcher/scripts/solanaMissedMessageAccounts.ts b/watcher/scripts/solanaMissedMessageAccounts.ts index c2502924..3f218aa2 100644 --- a/watcher/scripts/solanaMissedMessageAccounts.ts +++ b/watcher/scripts/solanaMissedMessageAccounts.ts @@ -6,7 +6,10 @@ import { Connection } from '@solana/web3.js'; import axios from 'axios'; import ora from 'ora'; import { RPCS_BY_CHAIN } from '../src/consts'; -import { isLegacyMessage, normalizeCompileInstruction } from '../src/utils/solana'; +import { + isLegacyMessage, + normalizeCompileInstruction, +} from '@wormhole-foundation/wormhole-monitor-common/src/solana'; // This script finds the message accounts which correspond to solana misses diff --git a/watcher/src/utils/solana.ts b/watcher/src/utils/solana.ts deleted file mode 100644 index e54f85e9..00000000 --- a/watcher/src/utils/solana.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - CompiledInstruction, - Message, - MessageCompiledInstruction, - MessageV0, -} from '@solana/web3.js'; -import { decode } from 'bs58'; - -export const isLegacyMessage = (message: Message | MessageV0): message is Message => { - return message.version === 'legacy'; -}; - -export const normalizeCompileInstruction = ( - instruction: CompiledInstruction | MessageCompiledInstruction -): MessageCompiledInstruction => { - if ('accounts' in instruction) { - return { - accountKeyIndexes: instruction.accounts, - data: decode(instruction.data), - programIdIndex: instruction.programIdIndex, - }; - } else { - return instruction; - } -}; diff --git a/watcher/src/watchers/SolanaWatcher.ts b/watcher/src/watchers/SolanaWatcher.ts index f60663b1..72064121 100644 --- a/watcher/src/watchers/SolanaWatcher.ts +++ b/watcher/src/watchers/SolanaWatcher.ts @@ -13,7 +13,10 @@ import { z } from 'zod'; import { RPCS_BY_CHAIN } from '../consts'; import { VaasByBlock } from '../databases/types'; import { makeBlockKey, makeVaaKey } from '../databases/utils'; -import { isLegacyMessage, normalizeCompileInstruction } from '../utils/solana'; +import { + isLegacyMessage, + normalizeCompileInstruction, +} from '@wormhole-foundation/wormhole-monitor-common/src/solana'; import { Watcher } from './Watcher'; import { Environment } from '@wormhole-foundation/wormhole-monitor-common';