Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .trunk/trunk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ runtimes:
lint:
enabled:
- actionlint@1.7.8
- checkov@3.2.493
- checkov@3.2.495
- eslint@9.39.1
- git-diff-check
- gitleaks@8.29.0
- markdownlint@0.45.0
- markdownlint@0.46.0
- osv-scanner@2.2.4
- pre-commit-hooks@5.0.0
- prettier@3.6.2
- prettier@3.7.3
- shellcheck@0.11.0
- shfmt@3.6.0
- taplo@0.10.0
Expand Down
47 changes: 31 additions & 16 deletions onchain-event-handler/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion onchain-event-handler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"vitest": "^4.0.10"
},
"overrides": {
"js-yaml": "^4.1.1"
"js-yaml": "^4.1.1",
"express": "^4.22.0"
}
}
61 changes: 33 additions & 28 deletions onchain-event-handler/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,8 @@ import keccak from "keccak";
import { celo, mainnet } from "viem/chains";
import safeAbi from "../safe-abi.json";
import config from "./config";
import { logger } from "./logger";
import type {
EventName,
EventSignature,
MultisigAddress,
MultisigKey,
} from "./types";
import type { EventName, EventSignature, MultisigKey } from "./types";
import { QuickNodeNetwork } from "./types";

/**
* Security event names that should be routed to the alerts channel
Expand Down Expand Up @@ -86,11 +81,11 @@ const { securityEvents } = extractEventSignatures();
export const SECURITY_EVENTS: EventName[] = securityEvents;

/**
* Mapping of multisig addresses to their keys
* Loaded from MULTISIG_CONFIG JSON environment variable passed by Terraform
* Chain-aware mapping of address+chain to multisig key
* This prevents collisions when the same address exists on multiple chains
*/
export const MULTISIGS: Record<MultisigAddress, MultisigKey> = (() => {
const multisigs: Record<MultisigAddress, MultisigKey> = {};
export const MULTISIGS_BY_CHAIN: Record<string, MultisigKey> = (() => {
const multisigsByChain: Record<string, MultisigKey> = {};

// Load multisig config from JSON environment variable
const multisigConfigJson = config.MULTISIG_CONFIG;
Expand All @@ -101,34 +96,44 @@ export const MULTISIGS: Record<MultisigAddress, MultisigKey> = (() => {
{ address: string; name: string; chain: string }
>;

// Build mapping: address -> key (the key from the config map)
// Build mapping: address:chain -> key
for (const [key, multisigConfigItem] of Object.entries(multisigConfig)) {
const normalizedAddress = multisigConfigItem.address.toLowerCase();
multisigs[normalizedAddress] = key;
const chain = multisigConfigItem.chain.toLowerCase();
const compositeKey = `${normalizedAddress}:${chain}`;
multisigsByChain[compositeKey] = key;
}
} catch (error) {
throw new Error(
`Failed to parse MULTISIG_CONFIG: ${error instanceof Error ? error.message : String(error)}`,
);
}

// Validate that we have at least one multisig configured
if (Object.keys(multisigs).length === 0) {
// In local development, allow empty config
if (process.env.NODE_ENV !== "production") {
logger.warn(
"No multisig addresses configured. Using empty config for local development.",
);
return multisigs;
}
throw new Error(
"No multisig addresses configured. Check MULTISIG_CONFIG environment variable.",
);
}

return multisigs;
return multisigsByChain;
})();

/**
* Map QuickNode network names to our chain names
* @param quicknodeNetwork - QuickNode network identifier (e.g., "celo-mainnet", "ethereum-mainnet")
* @returns Chain name (e.g., "celo", "ethereum") or null if not found
*/
export function mapQuickNodeNetworkToChain(
quicknodeNetwork: string | QuickNodeNetwork,
): string | null {
// Convert enum to string if needed, then normalize
const networkString =
typeof quicknodeNetwork === "string" ? quicknodeNetwork : quicknodeNetwork;
const normalized = networkString.toLowerCase();

// Map QuickNode network names to our chain names
const networkMap: Record<string, string> = {
[QuickNodeNetwork.CELO_MAINNET]: "celo",
[QuickNodeNetwork.ETHEREUM_MAINNET]: "ethereum",
};

return networkMap[normalized] || null;
}

/**
* Chain configuration mapping chain names to their properties
* Uses viem chain definitions where possible
Expand Down
4 changes: 2 additions & 2 deletions onchain-event-handler/src/health-check.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Response } from "@google-cloud/functions-framework";
import config from "./config";
import { MULTISIGS } from "./constants";
import { MULTISIGS_BY_CHAIN } from "./constants";

/**
* Health check endpoint handler
Expand Down Expand Up @@ -42,7 +42,7 @@ export function handleHealthCheck(res: Response): void {

// Check multisig config parsing
try {
const multisigCount = Object.keys(MULTISIGS).length;
const multisigCount = Object.keys(MULTISIGS_BY_CHAIN).length;
checks.multisigs = {
status: multisigCount > 0 ? "ok" : "warning",
message:
Expand Down
10 changes: 7 additions & 3 deletions onchain-event-handler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,21 @@ export const processQuicknodeWebhook = async (
return;
}

const webhookData = payloadValidation.payload.result;
const webhookPayload = payloadValidation.payload;
const webhookData = webhookPayload.result;
const network = webhookPayload.network;

logger.info("Processing webhook", {
logCount: webhookData.length,
network: network || "unknown",
});

// 4. Build context needed for processing
// We need this context BEFORE processing to correctly skip ExecutionSuccess duplicates
const context = buildEventContext(webhookData);

// 5. Process events with complete context
const results = await processEvents(webhookData, context);
// 5. Process events with complete context and network info
const results = await processEvents(webhookData, context, network);

logger.info("Webhook processing completed", {
processed: results.length,
Expand Down
42 changes: 37 additions & 5 deletions onchain-event-handler/src/process-events.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { EventContext } from "./build-event-context";
import { mapQuickNodeNetworkToChain } from "./constants";
import { formatDiscordMessage, sendToDiscord } from "./discord";
import { logger } from "./logger";
import type { ProcessedEvent, QuickNodeWebhookPayload } from "./types";
import type {
ProcessedEvent,
QuickNodeNetwork,
QuickNodeWebhookPayload,
} from "./types";
import { getMultisigKey, getWebhookUrl, isSecurityEvent } from "./utils";

/**
Expand All @@ -10,11 +15,13 @@ import { getMultisigKey, getWebhookUrl, isSecurityEvent } from "./utils";
*
* @param logs - Array of decoded log entries from QuickNode webhook
* @param context - Event context built from first pass
* @param network - QuickNode network identifier (e.g., "celo-mainnet", "ethereum-mainnet")
* @returns Array of successfully processed events
*/
export async function processEvents(
logs: QuickNodeWebhookPayload["result"],
context: EventContext,
network?: QuickNodeNetwork,
): Promise<ProcessedEvent[]> {
const { txHashMap, hasSafeMultiSigTx } = context;

Expand All @@ -38,7 +45,7 @@ export async function processEvents(
const results = await Promise.all(
logsToProcess.map(async (logEntry) => {
try {
return await processEvent(logEntry, txHashMap);
return await processEvent(logEntry, txHashMap, network);
} catch (error) {
logger.error("Error processing log", {
error:
Expand Down Expand Up @@ -92,11 +99,13 @@ function validateLog(log: QuickNodeWebhookPayload["result"][0]): {
*
* @param logEntry - The decoded log entry from QuickNode webhook
* @param txHashMap - Map of transactionHash -> Safe txHash for linking transactions
* @param network - QuickNode network identifier (e.g., "celo-mainnet", "ethereum-mainnet")
* @returns ProcessedEvent if successful, null if event should be skipped
*/
async function processEvent(
logEntry: QuickNodeWebhookPayload["result"][0],
txHashMap: Map<string, string>,
network?: QuickNodeNetwork,
): Promise<ProcessedEvent | null> {
// 1. Validate required fields
const validation = validateLog(logEntry);
Expand All @@ -113,13 +122,36 @@ async function processEvent(
// 2. Get event name from decoded log
const eventName = logEntry.name!;

// 3. Identify multisig
// 3. Identify multisig with chain-aware lookup
const multisigAddress = logEntry.address!.toLowerCase();
const multisigKey = getMultisigKey(multisigAddress);

// Determine chain from QuickNode network identifier
if (!network) {
logger.warn("Missing network information in webhook payload", {
address: multisigAddress,
transactionHash: logEntry.transactionHash,
});
return null;
}

const chain = mapQuickNodeNetworkToChain(network);
if (!chain) {
logger.warn("Unknown QuickNode network", {
network,
address: multisigAddress,
transactionHash: logEntry.transactionHash,
});
return null;
}

// Get multisig key using chain-aware lookup
const multisigKey = getMultisigKey(multisigAddress, chain);

if (!multisigKey) {
logger.warn("Unknown multisig address", {
logger.warn("Unknown multisig address for chain", {
address: multisigAddress,
chain,
transactionHash: logEntry.transactionHash,
});
return null;
}
Expand Down
10 changes: 10 additions & 0 deletions onchain-event-handler/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface QuickNodeDecodedLog {
}

export interface QuickNodeWebhookPayload {
network?: QuickNodeNetwork; // QuickNode network identifier (e.g., "celo-mainnet", "ethereum-mainnet")
result: QuickNodeDecodedLog[];
}

Expand Down Expand Up @@ -45,3 +46,12 @@ export type EventSignature = string;
export type EventName = string;
export type MultisigKey = string;
export type MultisigAddress = string;

/**
* QuickNode network identifiers
* Enum of QuickNode networks we currently have multisigs configured for
*/
export enum QuickNodeNetwork {
CELO_MAINNET = "celo-mainnet",
ETHEREUM_MAINNET = "ethereum-mainnet",
}
18 changes: 11 additions & 7 deletions onchain-event-handler/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { celo, mainnet } from "viem/chains";
import config from "./config";
import {
DEFAULT_TOKEN_DECIMALS,
MULTISIGS,
MULTISIGS_BY_CHAIN,
SECURITY_EVENTS,
getChainConfig,
} from "./constants";
Expand All @@ -23,11 +23,16 @@ const VIEM_CHAINS: Record<string, typeof celo | typeof mainnet> = {
};

/**
* Get multisig key from contract address
* Get multisig key from contract address and chain
* @param address - The multisig contract address
* @param chain - Chain name (e.g., "celo", "ethereum")
* @returns The multisig key, or null if not found
*/
export function getMultisigKey(address: string): string | null {
export function getMultisigKey(address: string, chain: string): string | null {
const normalizedAddress = address.toLowerCase();
return MULTISIGS[normalizedAddress] || null;
const normalizedChain = chain.toLowerCase();
const compositeKey = `${normalizedAddress}:${normalizedChain}`;
return MULTISIGS_BY_CHAIN[compositeKey] || null;
}

/**
Expand Down Expand Up @@ -206,9 +211,8 @@ export async function decodeEventData(
if (formatter) {
// Special handling for SafeMultiSigTransaction which needs chainName
if (eventName === "SafeMultiSigTransaction") {
const { formatSafeMultiSigTransactionEvent } = await import(
"./event-formatters/transaction-formatters"
);
const { formatSafeMultiSigTransactionEvent } =
await import("./event-formatters/transaction-formatters");
return formatSafeMultiSigTransactionEvent(
log,
chainTokenConfig,
Expand Down
Loading