diff --git a/infra/quicknode.tf b/infra/quicknode.tf index 97fcb6d..c82c9ad 100644 --- a/infra/quicknode.tf +++ b/infra/quicknode.tf @@ -20,6 +20,18 @@ resource "quicknode_notification" "notification" { enabled = true } +# Creates a new QuickAlert event listener for `MedianUpdated` events on our SortedOracles contract, +# which is used as a health check event to ensure quicknode alerts are firing. +resource "quicknode_notification" "healthcheck" { + name = "Healthcheck event" + network = "celo-mainnet" + + # Decoded version: `tx_logs_address == '0xefb84935239dacdecf7c5ba76d8de40b077b7b33' && tx_logs_topic0 == '0xa9981ebfc3b766a742486e898f54959b050a66006dbce1a4155c1f84a08bcf41' && tx_logs_topic1 == '0x000000000000000000000000765de816845861e75a25fca122bb6898b8b1282a'` + expression = "dHhfbG9nc19hZGRyZXNzID09ICcweGVmYjg0OTM1MjM5ZGFjZGVjZjdjNWJhNzZkOGRlNDBiMDc3YjdiMzMnICYmIHR4X2xvZ3NfdG9waWMwID09ICcweGE5OTgxZWJmYzNiNzY2YTc0MjQ4NmU4OThmNTQ5NTliMDUwYTY2MDA2ZGJjZTFhNDE1NWMxZjg0YTA4YmNmNDEnICYmIHR4X2xvZ3NfdG9waWMxID09ICcweDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDc2NWRlODE2ODQ1ODYxZTc1YTI1ZmNhMTIyYmI2ODk4YjhiMTI4MmEn" + destination_ids = [resource.quicknode_destination.destination.id] + enabled = true +} + # Creates a new QuickAlert destination that forwards all received `ProposalCreated` transaction receipts to our Cloud Function resource "quicknode_destination" "destination" { name = "Cloud Function" diff --git a/package.json b/package.json index 205683b..35d120a 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "prestart": "npm run build", "start": "NODE_ENV=development functions-framework --target=watchdogNotifier", "test": "curl -H \"Content-Type: application/json\" -d @src/proposal-created.fixture.json localhost:8080", - "test-in-prod": "./test-deployed-function.sh" + "test-in-prod": "./test-deployed-function.sh", + "test:healthcheck": "curl -H \"Content-Type: application/json\" -d @src/health-check.fixture.json localhost:8080" }, "dependencies": { "@google-cloud/functions-framework": "^3.4.0", diff --git a/src/health-check.fixture.json b/src/health-check.fixture.json new file mode 100644 index 0000000..031f030 --- /dev/null +++ b/src/health-check.fixture.json @@ -0,0 +1,48 @@ +[ + { + "blockHash": "0x3c3de1a0ae100d33d9a7863ddc59cdf6ec628c42bebf0854e18c462a7e2f6b90", + "blockNumber": "0x1990dc7", + "contractAddress": "", + "cumulativeGasUsed": "0x8e7542", + "effectiveGasPrice": "0x1bf08eb00", + "from": "0xfe9925e6ae9c4cd50ae471b90766aaef37ad307e", + "gasUsed": "0x4a836", + "logs": [ + { + "address": "0xefb84935239dacdecf7c5ba76d8de40b077b7b33", + "blockHash": "0x3c3de1a0ae100d33d9a7863ddc59cdf6ec628c42bebf0854e18c462a7e2f6b90", + "blockNumber": "0x1990dc7", + "data": "0x00000000000000000000000000000000000000000000000000000000669f37b9000000000000000000000000000000000000000000007ae21ecba8aa70ef8750", + "logIndex": "0xc5", + "removed": false, + "topics": [ + "0x7cebb17173a9ed273d2b7538f64395c0ebf352ff743f1cf8ce66b437a6144213", + "0x000000000000000000000000765de816845861e75a25fca122bb6898b8b1282a", + "0x000000000000000000000000fe9925e6ae9c4cd50ae471b90766aaef37ad307e" + ], + "transactionHash": "0x1091e1d6babb1711b30d6328f9d9755cc4e7d5004f9ef88c9526b1642f5f35e1", + "transactionIndex": "0x3" + }, + { + "address": "0xefb84935239dacdecf7c5ba76d8de40b077b7b33", + "blockHash": "0x3c3de1a0ae100d33d9a7863ddc59cdf6ec628c42bebf0854e18c462a7e2f6b90", + "blockNumber": "0x1990dc7", + "data": "0x000000000000000000000000000000000000000000007aef59d1fabdfb4cfc30", + "logIndex": "0xc6", + "removed": false, + "topics": [ + "0xa9981ebfc3b766a742486e898f54959b050a66006dbce1a4155c1f84a08bcf41", + "0x000000000000000000000000765de816845861e75a25fca122bb6898b8b1282a" + ], + "transactionHash": "0x1091e1d6babb1711b30d6328f9d9755cc4e7d5004f9ef88c9526b1642f5f35e1", + "transactionIndex": "0x3" + } + ], + "logsBloom": "0x00800000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000004000000000000000020000000000000000000000000400000008400000000100000000000000000000000000000000000000000401000000000000800000000000000000000000000000000000000800000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000", + "status": "0x1", + "to": "0xefb84935239dacdecf7c5ba76d8de40b077b7b33", + "transactionHash": "0x1091e1d6babb1711b30d6328f9d9755cc4e7d5004f9ef88c9526b1642f5f35e1", + "transactionIndex": "0x3", + "type": "0x0" + } +] diff --git a/src/index.ts b/src/index.ts index c6348a8..0b198a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,8 +16,18 @@ export const watchdogNotifier: HttpFunction = async ( const parsedEvents = parseTransactionReceipts(req.body); for (const parsedEvent of parsedEvents) { - await sendDiscordNotification(parsedEvent.event, parsedEvent.txHash); - await sendTelegramNotification(parsedEvent.event, parsedEvent.txHash); + switch (parsedEvent.event.eventName) { + case "ProposalCreated": + await sendDiscordNotification(parsedEvent.event, parsedEvent.txHash); + await sendTelegramNotification(parsedEvent.event, parsedEvent.txHash); + break; + case "MedianUpdated": + // Acts a health check/heartbeat for the service, as it's a frequently emitted event + console.info("[HealthCheck]: Block", parsedEvent.block); + break; + default: + throw new Error("Unknown event type", parsedEvent.event); + } } res.status(200).send("Event successfully processed"); diff --git a/src/parse-transaction-receipts.ts b/src/parse-transaction-receipts.ts index c5092de..57125fa 100644 --- a/src/parse-transaction-receipts.ts +++ b/src/parse-transaction-receipts.ts @@ -3,31 +3,38 @@ import { decodeEventLog } from "viem"; // Internal import GovernorABI from "./governor-abi.js"; -import { ProposalCreatedEvent } from "./types.js"; +import { HealthCheckEvent, ProposalCreatedEvent } from "./types.js"; import hasLogs from "./utils/has-logs.js"; +import isHealthCheckEvent from "./utils/is-health-check-event.js"; import isProposalCreatedEvent from "./utils/is-proposal-created-event.js"; import isTransactionReceipt from "./utils/is-transaction-receipt.js"; - -// For debugging with SortedOracles: -// import SortedOraclesABI from "./sorted-oracles-abi.js"; +import SortedOraclesABI from "./sorted-oracles-abi.js"; /** * Parse request body containing raw transaction receipts */ export default function parseTransactionReceipts( matchedTransactionReceipts: unknown, -): { event: ProposalCreatedEvent; txHash: string }[] { +): { + block?: number; + event: ProposalCreatedEvent | HealthCheckEvent; + txHash: string; +}[] { const result = []; if (!Array.isArray(matchedTransactionReceipts)) { throw new Error( - `Request body is not an array of transaction receipts but was: ${JSON.stringify(matchedTransactionReceipts)}`, + `Request body is not an array of transaction receipts but was: ${JSON.stringify( + matchedTransactionReceipts, + )}`, ); } for (const receipt of matchedTransactionReceipts) { if (!isTransactionReceipt(receipt)) { throw new Error( - `'receipt' is not of type 'TransactionReceipt': ${JSON.stringify(receipt)}`, + `'receipt' is not of type 'TransactionReceipt': ${JSON.stringify( + receipt, + )}`, ); } @@ -42,24 +49,44 @@ export default function parseTransactionReceipts( throw new Error("No topics found in log"); } - const event = decodeEventLog({ - // For debugging with SortedOracles: - // abi: SortedOraclesABI, - abi: GovernorABI, - data: log.data as `0x${string}`, - topics: log.topics as [ - signature: `0x${string}`, - ...args: `0x${string}`[], - ], - }); + try { + const event = decodeEventLog({ + abi: GovernorABI, + data: log.data as `0x${string}`, + topics: log.topics as [ + signature: `0x${string}`, + ...args: `0x${string}`[], + ], + }); - if (!isProposalCreatedEvent(event)) { - throw new Error( - `Event is not a ProposalCreatedEvent: ${JSON.stringify(event)}`, - ); - } + if (isProposalCreatedEvent(event)) { + result.push({ + event, + txHash: log.transactionHash, + }); + } + // eslint-disable-next-line no-empty + } catch {} + + try { + const event = decodeEventLog({ + abi: SortedOraclesABI, + data: log.data as `0x${string}`, + topics: log.topics as [ + signature: `0x${string}`, + ...args: `0x${string}`[], + ], + }); - result.push({ event, txHash: log.transactionHash }); + if (isHealthCheckEvent(event)) { + result.push({ + block: Number(receipt.blockNumber), + event, + txHash: log.transactionHash, + }); + } + // eslint-disable-next-line no-empty + } catch {} } } diff --git a/src/sorted-oracle-report.fixture.json b/src/sorted-oracle-report.fixture.json deleted file mode 100644 index 16b296b..0000000 --- a/src/sorted-oracle-report.fixture.json +++ /dev/null @@ -1,35 +0,0 @@ -[ - { - "blockHash": "0xacfb9a345bf578e4ed4f6a1aa213e041165afb7c9b65554233f4bd430c9d5cd7", - "blockNumber": "0x100fd41", - "contractAddress": "", - "cumulativeGasUsed": "0x16098f", - "effectiveGasPrice": "0x5c2c1d4c1", - "from": "0xECcd1e9439094D025Ac7D08d16B0BFE0DA3BEA53", - - "gasUsed": "0x159947", - "logs": [ - { - "address": "0xefB84935239dAcdecF7c5bA76d8dE40b077B7b33", - "blockHash": "0x4ba208f58db5fbbd65ce8dbfc5e65d42aa40444bfb7467a8c902d35ac79a152f", - "blockNumber": "0x100fd41", - "data": "0x000000000000000000000000000000000000000000000000000000006687ba8b00000000000000000000000000000000000000000000c396ea22e327bc7f1ff0", - "logIndex": "0x0", - "removed": false, - "topics": [ - "0x7cebb17173a9ed273d2b7538f64395c0ebf352ff743f1cf8ce66b437a6144213", - "0x000000000000000000000000206b25ea01e188ee243131afde526ba6e131a016", - "0x00000000000000000000000055de75fd0c2b37987757172fef7ba2ea935d284d" - ], - "transactionHash": "0x8dbe52bd851440b5d08bafbcfdce1890a9cb6015c45f07ab4f479e522f617975", - "transactionIndex": "0x1" - } - ], - "logsBloom": "0x002400080001000004200000802000000006010000000014000004004000004080000100000000420000000000001000028000080800000000010500010040008000004004000002000000080004002001000000002000021000010100000000000200000000280000800000000000000080002000000008000000100200000000000000000000008002000000000200100000004000002800008040000000001002000010000000000000100000000000080000800000000000000000000000010000020000000000000000000000040000000000000090000000000040000000012000000000a0000004008000080200020000000040000000600000004000", - "status": "0x1", - "to": "0xefB84935239dAcdecF7c5bA76d8dE40b077B7b33", - "transactionHash": "0x8dbe52bd851440b5d08bafbcfdce1890a9cb6015c45f07ab4f479e522f617975", - "transactionIndex": "0x1", - "type": "0x2" - } -] diff --git a/src/types.ts b/src/types.ts index 2ca4fd0..24ca96f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,3 +41,12 @@ export interface ProposalCreatedEvent { version: number; }; } + +export interface HealthCheckEvent { + eventName: "MedianUpdated"; + block: number; + args: { + token: `0x${string}`; + value: bigint; + }; +} diff --git a/src/utils/is-health-check-event.ts b/src/utils/is-health-check-event.ts new file mode 100644 index 0000000..8431065 --- /dev/null +++ b/src/utils/is-health-check-event.ts @@ -0,0 +1,21 @@ +import type { DecodeEventLogReturnType } from "viem"; +import type SortedOraclesABI from "../sorted-oracles-abi.js"; +import type { HealthCheckEvent } from "../types.js"; + +export default function isHealthCheckEvent( + event: DecodeEventLogReturnType | null | undefined, +): event is HealthCheckEvent { + if ( + event === null || + event === undefined || + typeof event !== "object" || + !("args" in event) + ) { + return false; + } + return ( + event.eventName === "MedianUpdated" && + "token" in event.args && + "value" in event.args + ); +}