diff --git a/src/index.ts b/src/index.ts index 4074d1b..3c7b19b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,14 @@ -import assert from "assert/strict"; - -// Types import type { HttpFunction, Request, Response, } from "@google-cloud/functions-framework"; -import { EventType } from "./types.js"; +import assert from "assert/strict"; import parseTransactionReceipts from "./parse-transaction-receipts"; import sendDiscordNotification from "./send-discord-notification"; import sendTelegramNotification from "./send-telegram-notification"; -import { isFromQuicknode, hasAuthToken } from "./validate-request-origin"; +import { EventType } from "./types.js"; +import { hasAuthToken, isFromQuicknode } from "./validate-request-origin"; export const watchdogNotifier: HttpFunction = async ( req: Request, @@ -34,13 +32,27 @@ export const watchdogNotifier: HttpFunction = async ( for (const parsedEvent of parsedEvents) { switch (parsedEvent.event.eventName) { case EventType.ProposalCreated: - await sendDiscordNotification(parsedEvent.event, parsedEvent.txHash); - await sendTelegramNotification(parsedEvent.event, parsedEvent.txHash); + assert(parsedEvent.timelockId, "Timelock ID is missing"); + + await sendDiscordNotification( + parsedEvent.event, + parsedEvent.timelockId, + parsedEvent.txHash, + ); + + await sendTelegramNotification( + parsedEvent.event, + parsedEvent.timelockId, + parsedEvent.txHash, + ); + break; + case EventType.MedianUpdated: // Acts a health check/heartbeat for the service, as it's a frequently emitted event console.info("[HealthCheck]: Block", parsedEvent.block); break; + default: assert( false, diff --git a/src/parse-transaction-receipts.ts b/src/parse-transaction-receipts.ts index d869a2b..24a2e3e 100644 --- a/src/parse-transaction-receipts.ts +++ b/src/parse-transaction-receipts.ts @@ -5,13 +5,14 @@ import { decodeEventLog } from "viem"; // Internal import GovernorABI from "./governor-abi.js"; +import SortedOraclesABI from "./sorted-oracles-abi.js"; import { EventType, HealthCheckEvent, ProposalCreatedEvent } from "./types.js"; -import hasLogs from "./utils/has-logs.js"; import getEventByTopic from "./utils/get-event-by-topic.js"; +import getProposaltimelockId from "./utils/get-time-lock-id.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"; -import SortedOraclesABI from "./sorted-oracles-abi.js"; /** * Parse request body containing raw transaction receipts @@ -21,6 +22,7 @@ export default function parseTransactionReceipts( ): { block?: number; event: ProposalCreatedEvent | HealthCheckEvent; + timelockId?: string; txHash: string; }[] { const result = []; @@ -58,6 +60,7 @@ export default function parseTransactionReceipts( // It can happen that a single transaction fires multiple events, // some of which we are not interested in continue; + case EventType.ProposalCreated: { const event = decodeEventLog({ abi: GovernorABI, @@ -72,10 +75,12 @@ export default function parseTransactionReceipts( result.push({ event, + timelockId: getProposaltimelockId(event), txHash: log.transactionHash, }); break; } + case EventType.MedianUpdated: { const event = decodeEventLog({ abi: SortedOraclesABI, @@ -95,6 +100,7 @@ export default function parseTransactionReceipts( }); break; } + default: assert( false, diff --git a/src/send-discord-notification.ts b/src/send-discord-notification.ts index ee7a234..5336483 100644 --- a/src/send-discord-notification.ts +++ b/src/send-discord-notification.ts @@ -5,6 +5,7 @@ import type { ProposalCreatedEvent } from "./types"; export default async function sendDiscordNotification( event: ProposalCreatedEvent, + timelockId: string, txHash: string, ) { const { title, description } = JSON.parse(event.args.description) as { @@ -28,6 +29,10 @@ export default async function sendDiscordNotification( name: "Transaction", value: `https://celoscan.io/tx/${txHash}`, }) + .addFields({ + name: "Timelock ID", + value: timelockId, + }) .setColor(0xa6e5f6); const discordWebhookClient = new WebhookClient({ diff --git a/src/send-telegram-notification.ts b/src/send-telegram-notification.ts index bd38043..f54a9b1 100644 --- a/src/send-telegram-notification.ts +++ b/src/send-telegram-notification.ts @@ -4,6 +4,7 @@ import { ProposalCreatedEvent } from "./types"; export default async function sendTelegramNotification( event: ProposalCreatedEvent, + timelockId: string, txHash: string, ) { const botToken = await getSecret(config.TELEGRAM_BOT_TOKEN_SECRET_ID); @@ -19,6 +20,7 @@ export default async function sendTelegramNotification( Proposer: `https://celoscan.io/address/${event.args.proposer}`, Event: event.eventName, Transaction: `https://celoscan.io/tx/${txHash}`, + "Timelock ID": timelockId, Description: description, }; diff --git a/src/types.ts b/src/types.ts index 47390d4..0c5934e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,7 +50,6 @@ export interface ProposalCreatedEvent { export interface HealthCheckEvent { eventName: EventType.MedianUpdated; - block: number; args: { token: `0x${string}`; value: bigint; diff --git a/src/utils/get-time-lock-id.ts b/src/utils/get-time-lock-id.ts new file mode 100644 index 0000000..67e730f --- /dev/null +++ b/src/utils/get-time-lock-id.ts @@ -0,0 +1,27 @@ +import { encodeAbiParameters, keccak256, parseAbiParameters } from "viem"; + +import { ProposalCreatedEvent } from "../types"; + +/** + * Given a ProposalCreatedEvent, calculate the corresponding timelock operation ID. + * Governance Watchdogs need the timelock operation ID to veto queued proposals. + * + * The governor proposal ID and the timelock operation ID are not the same, which can + * be confusing. They use different hashing mechanisms to calculate their respective IDs: + * - Timelock Controller Operation IDs: https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/58fa0f81c4036f1a3b616fdffad2fd27e5d5ce21/contracts/governance/TimelockControllerUpgradeable.sol#L218 + * - Governor Proposal IDs: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/0a25c1940ca220686588c4af3ec526f725fe2582/contracts/governance/Governor.sol#L139 + */ +export default function getProposalTimeLockId( + event: ProposalCreatedEvent, +): string { + const { targets, values, calldatas, description } = event.args; + const descriptionHash = keccak256(Buffer.from(description)); + + return keccak256( + encodeAbiParameters( + parseAbiParameters("address[], uint256[], bytes[], uint256, bytes32"), + // _timelockIds[proposalId] = _timelock.hashOperationBatch(targets, values, calldatas, 0, descriptionHash); + [targets, values, calldatas, 0n, descriptionHash], + ), + ); +}