From 26f6be4a2f5b83d11c56035561c9c0c0ace561fc Mon Sep 17 00:00:00 2001 From: Evan Gray Date: Fri, 14 Mar 2025 13:17:01 -0400 Subject: [PATCH] cloud_functions: getQuorumHeight --- cloud_functions/scripts/deploy.sh | 1 + cloud_functions/src/getQuorumHeight.ts | 125 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 cloud_functions/src/getQuorumHeight.ts diff --git a/cloud_functions/scripts/deploy.sh b/cloud_functions/scripts/deploy.sh index a215cac9..50ed4b6c 100755 --- a/cloud_functions/scripts/deploy.sh +++ b/cloud_functions/scripts/deploy.sh @@ -133,6 +133,7 @@ gcloud functions --project "$GCP_PROJECT" deploy compute-missing-vaas --entry-po gcloud functions --project "$GCP_PROJECT" deploy compute-ntt-rate-limits --entry-point computeNTTRateLimits --gen2 --runtime nodejs22 --trigger-http --allow-unauthenticated --timeout 300 --memory 512MB --region europe-west3 --set-env-vars NETWORK=$NETWORK,FUNCTION=computeNTTRateLimits gcloud functions --project "$GCP_PROJECT" deploy compute-total-supply-and-locked --entry-point computeTotalSupplyAndLocked --gen2 --runtime nodejs22 --trigger-http --allow-unauthenticated --timeout 300 --memory 512MB --region europe-west3 --set-env-vars NETWORK=$NETWORK,FUNCTION=computeTotalSupplyAndLocked gcloud functions --project "$GCP_PROJECT" deploy get-ntt-rate-limits --entry-point getNTTRateLimits --gen2 --runtime nodejs22 --trigger-http --allow-unauthenticated --timeout 300 --memory 512MB --region europe-west3 --set-env-vars NETWORK=$NETWORK,FUNCTION=getNTTRateLimits +gcloud functions --project "$GCP_PROJECT" deploy get-quorum-height --entry-point getQuorumHeight --gen2 --runtime nodejs22 --trigger-http --allow-unauthenticated --timeout 300 --memory 512MB --region europe-west3 --set-env-vars CLOUD_FUNCTIONS_REFRESH_TIME_INTERVAL=$CLOUD_FUNCTIONS_REFRESH_TIME_INTERVAL,NETWORK=$NETWORK,FUNCTION=getQuorumHeight gcloud functions --project "$GCP_PROJECT" deploy get-total-supply-and-locked --entry-point getTotalSupplyAndLocked --gen2 --runtime nodejs22 --trigger-http --allow-unauthenticated --timeout 300 --memory 512MB --region europe-west3 --set-env-vars NETWORK=$NETWORK,FUNCTION=getTotalSupplyAndLocked gcloud functions --project "$GCP_PROJECT" deploy latest-blocks --entry-point getLatestBlocks --gen2 --runtime nodejs22 --trigger-http --allow-unauthenticated --timeout 300 --memory 512MB --region europe-west3 --set-env-vars CLOUD_FUNCTIONS_REFRESH_TIME_INTERVAL=$CLOUD_FUNCTIONS_REFRESH_TIME_INTERVAL,FIRESTORE_LATEST_COLLECTION=$FIRESTORE_LATEST_COLLECTION,NETWORK=$NETWORK,FUNCTION=getLatestBlocks gcloud functions --project "$GCP_PROJECT" deploy latest-tvltvm --entry-point getLatestTvlTvm --gen2 --runtime nodejs22 --trigger-http --allow-unauthenticated --timeout 300 --memory 512MB --region europe-west3 --set-env-vars CLOUD_FUNCTIONS_REFRESH_TIME_INTERVAL=$CLOUD_FUNCTIONS_REFRESH_TIME_INTERVAL,FIRESTORE_LATEST_TVLTVM_COLLECTION=$FIRESTORE_LATEST_TVLTVM_COLLECTION,NETWORK=$NETWORK,FUNCTION=getLatestTvlTvm diff --git a/cloud_functions/src/getQuorumHeight.ts b/cloud_functions/src/getQuorumHeight.ts new file mode 100644 index 00000000..8095a446 --- /dev/null +++ b/cloud_functions/src/getQuorumHeight.ts @@ -0,0 +1,125 @@ +import { + assertEnvironmentVariable, + STANDBY_GUARDIANS, +} from '@wormhole-foundation/wormhole-monitor-common'; +import { Firestore } from 'firebase-admin/firestore'; + +// TODO: pulled from getLastHeartbeats.ts, could be shared. +interface Heartbeat { + nodeName: string; + counter: string; + timestamp: string; + networks: HeartbeatNetwork[]; + version: string; + guardianAddr: string; + bootTimestamp: string; + features: string[]; + p2pNodeAddr?: string; +} + +interface HeartbeatNetwork { + id: number; + height: string; + contractAddress: string; + errorCount: string; + safeHeight: string; + finalizedHeight: string; +} + +const isTestnet = assertEnvironmentVariable('NETWORK') === 'TESTNET'; + +export function getQuorumCount(numGuardians: number): number { + return isTestnet ? 1 : Math.floor((numGuardians * 2) / 3 + 1); +} + +async function getHeartbeats_() { + const heartbeats: Heartbeat[] = []; + const firestoreDb = new Firestore(); + try { + const collectionRef = firestoreDb.collection('heartbeats'); + const snapshot = await collectionRef.get(); + snapshot.docs.forEach((doc) => { + heartbeats.push(doc.data() as Heartbeat); + }); + } catch (e) { + console.error(e); + } + return heartbeats; +} + +let cache = { heartbeats: [] as Heartbeat[], lastUpdated: Date.now() }; +// default refresh interval = 15 sec +const REFRESH_TIME_INTERVAL = + Number(process.env.CLOUD_FUNCTIONS_REFRESH_TIME_INTERVAL) || 1000 * 15; + +export async function getQuorumHeight(req: any, res: any) { + res.set('Access-Control-Allow-Origin', '*'); + if (req.method === 'OPTIONS') { + // Send response to OPTIONS requests + res.set('Access-Control-Allow-Methods', 'GET'); + res.set('Access-Control-Allow-Headers', 'Content-Type'); + res.set('Access-Control-Max-Age', '3600'); + res.status(204).send(''); + return; + } + const { chainId: chainIdStr } = req.query; + const chainId = Number(chainIdStr); + if (Number.isNaN(chainId)) { + res.status(400).send(`Invalid chainId`); + return; + } + let heartbeats: Heartbeat[] = []; + try { + if (cache.heartbeats.length === 0 || Date.now() - cache.lastUpdated > REFRESH_TIME_INTERVAL) { + if (cache.heartbeats.length === 0) { + console.log(`cache is empty, setting cache.heartbeats ${new Date()}`); + } else { + console.log(`cache is older than ${REFRESH_TIME_INTERVAL} ms, refreshing ${new Date()}`); + } + let prevDate = Date.now(); + heartbeats = await getHeartbeats_(); + let timeDiff = Date.now() - prevDate; + console.log('After getHeartbeats_=', timeDiff); + cache.heartbeats = heartbeats; + cache.lastUpdated = Date.now(); + } else { + console.log(`cache is still valid, not refreshing ${new Date()}`); + heartbeats = cache.heartbeats; + } + if (heartbeats.length) { + const latestHeights: bigint[] = []; + const safeHeights: bigint[] = []; + const finalizedHeights: bigint[] = []; + for (const heartbeat of heartbeats) { + // filter out standby guardians + if ( + !STANDBY_GUARDIANS.find( + (g) => g.pubkey.toLowerCase() === heartbeat.guardianAddr.toLowerCase() + ) + ) { + const network = heartbeat.networks.find((n) => n.id === chainId); + latestHeights.push(BigInt(network?.height || '0')); + safeHeights.push(BigInt(network?.safeHeight || '0')); + finalizedHeights.push(BigInt(network?.finalizedHeight || '0')); + } + } + latestHeights.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0)); + safeHeights.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0)); + finalizedHeights.sort((a, b) => (a > b ? -1 : a < b ? 1 : 0)); + const quorumIdx = getQuorumCount(latestHeights.length) - 1; + res.status(200).send( + JSON.stringify({ + latest: latestHeights[quorumIdx].toString(), + safe: safeHeights[quorumIdx].toString(), + finalized: finalizedHeights[quorumIdx].toString(), + }) + ); + } else { + console.log('no heartbeats'); + res.sendStatus(500); + } + } catch (e) { + console.log(e); + res.sendStatus(500); + } +}