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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"test": "vitest"
},
"dependencies": {
"@slack/web-api": "^7.8.0",
"@vlad-yakovlev/telegram-md": "^2.0.0",
"abitype": "^1.0.8",
"date-fns": "^4.1.0",
Expand Down
21 changes: 14 additions & 7 deletions src/SafeWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,20 @@ class SafeWatcher {
!MULTISEND_CALL_ONLY.has(detailed.to.toLowerCase() as Address) &&
detailed.operation !== 0;

await this.#notificationSender?.notify({
type: isMalicious ? "malicious" : "created",
chainPrefix: this.#prefix,
safe: this.#safe,
tx: detailed,
pending,
});
try {
await this.#notificationSender?.notify({
type: isMalicious ? "malicious" : "created",
chainPrefix: this.#prefix,
safe: this.#safe,
tx: detailed,
pending,
});
} catch (error) {
this.#logger?.error(
{ txHash: tx.safeTxHash, error },
"failed to send notification",
);
}
}

async #processTxUpdate(
Expand Down
16 changes: 12 additions & 4 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,21 @@ export const Schema = z.object({
*/
pollInterval: z.number().int().positive().default(20),
/**
* Telegram bot token
* Telegram bot token for notifications (optional)
*/
telegramBotToken: z.string(),
telegramBotToken: z.string().optional(),
/**
* Telegram channel ID
* Telegram channel ID for notifications (optional)
*/
telegramChannelId: z.string(),
telegramChannelId: z.string().optional(),
/**
* Slack Bot token for notifications (optional)
*/
slackBotToken: z.string().url().optional(),
/**
* Slack channel ID for notifications (optional)
*/
slackChannelId: z.string().optional(),
/**
* Prefixed safe addresses to watch, e.g. `eth:0x11111`
*/
Expand Down
14 changes: 13 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,27 @@ import { setTimeout } from "node:timers/promises";
import { loadConfig } from "./config/index.js";
import Healthcheck from "./Healthcheck.js";
import logger from "./logger.js";
import { NotificationSender, Telegram } from "./notifications/index.js";
import { NotificationSender, Slack, Telegram } from "./notifications/index.js";
import SafeWatcher from "./SafeWatcher.js";

async function run() {
const config = await loadConfig();

const sender = new NotificationSender();
// add Telegram notifier if configured
if (config.telegramBotTokenBotToken && config.telegramChannelIdl) {
await sender.addNotifier(new Telegram(config));
logger.info("Added notifier Telegram");
}

await sender.addNotifier(new Telegram(config));

// add Slack notifier if configured
if (config.slackBotToken && config.slackChannelIdl) {
await sender.addNotifier(new Slack(config));
logger.info("Added notifier Slack");
}

const safes = config.safeAddresses.map(async (safe, i) => {
await setTimeout(1000 * i);
return new SafeWatcher({
Expand Down
127 changes: 127 additions & 0 deletions src/notifications/Slack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { WebClient } from "@slack/web-api";

import logger from "../logger.js";
import type { Event, INotifier } from "../types.js";

export interface SlackOptions {
safeURL: string;
slackBotToken: string;
slackChannelId: string;
}

interface SlackMessage {
blocks: object[];
text: string;
}

export class Slack implements INotifier {
readonly #apiToken: string;
readonly #channelId: string;
readonly #safeURL: string;

constructor(opts: SlackOptions) {
this.#apiToken = opts.slackBotToken;
this.#channelId = opts.slackChannelId;
this.#safeURL = opts.safeURL;
}

public async send(event: Event): Promise<void> {
const message: SlackMessage = this.#formatMessage(event);
await this.#sendToSlack(message);
}

#formatMessage(event: Event): SlackMessage {
const { type, chainPrefix, safe, tx } = event;

const blocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Transaction ${type}*\nChain: ${chainPrefix}\nSafe: ${safe}\nTx Hash: \`${tx.safeTxHash}\`\nNonce: \`${tx.nonce}\``,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Signatures*: ${tx.confirmations.length}/${tx.confirmationsRequired}`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Proposer*: ${this.#formatSigner(tx.proposer)}`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Signers*: ${tx.confirmations.map(this.#formatSigner).join(", ")}`,
},
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View Transaction",
},
url: `${this.#safeURL}/${chainPrefix}:${safe}/transactions/queue`,
},
],
},
];

// Add alert for malicious transactions
if (type === "malicious") {
blocks.unshift({
type: "section",
text: {
type: "mrkdwn",
text: "🚨 *ALERT! ACTION REQUIRED: MALICIOUS TRANSACTION DETECTED!* 🚨",
},
});
}

const message: SlackMessage = {
blocks,
text: `Transaction ${type} [${tx.confirmations.length}/${tx.confirmationsRequired}] with safeTxHash ${tx.safeTxHash}`,
};
return message;
}

#formatSigner(signer: { address: string; name?: string }): string {
return signer.name ? `*${signer.name}*` : `\`${signer.address}\``;
}

async #sendToSlack(message: SlackMessage): Promise<void> {
if (!this.#apiToken && !this.#channelId) {
logger.warn("slack not configured");
return;
}

const webClient = new WebClient(this.#apiToken);

try {
const response = await webClient.chat.postMessage({
channel: this.#channelId,
text: message.text,
blocks: message.blocks,
});

if (response.ok) {
logger.debug("slack message sent successfully");
} else {
const err = await response.text();
throw new Error(`${response.statusText}: ${err}`);
}
} catch (err) {
logger.error({ err, message }, "cannot send to slack");
}
}
}
1 change: 1 addition & 0 deletions src/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./NotificationSender.js";
export * from "./Slack.js";
export * from "./Telegram.js";
9 changes: 5 additions & 4 deletions src/safe/AltAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,10 @@ const CHAIN_IDS: Record<string, number> = {
eth: 1,
gor: 5,
oeth: 10,
sep: 11155111,
};

function normalizeLisited(tx: ListedTx): ListedSafeTx {
function normalizeListed(tx: ListedTx): ListedSafeTx {
const { safeTxHash } = parseTxId(tx.id);
return {
safeTxHash,
Expand All @@ -142,7 +143,7 @@ function normalizeDetailed(tx: Transaction): SafeTx<Address> {
return {
safeTxHash,
nonce: tx.detailedExecutionInfo.nonce,
to: tx.txInfo.to.value,
to: tx.txData.to.value,
operation: tx.txData.operation,
proposer: tx.detailedExecutionInfo.confirmations?.[0].signer.value ?? "0x0",
confirmations:
Expand All @@ -166,12 +167,12 @@ export class AltAPI extends BaseApi implements ISafeAPI {
break;
}
} while (url);
return results.map(normalizeLisited);
return results.map(normalizeListed);
}

public async fetchLatest(): Promise<ListedSafeTx[]> {
const data = await this.#fetchList();
return (data.results.map(tx => tx.transaction) ?? []).map(normalizeLisited);
return (data.results.map(tx => tx.transaction) ?? []).map(normalizeListed);
}

public async fetchDetailed(safeTxHash: Hash): Promise<SafeTx<Address>> {
Expand Down
3 changes: 2 additions & 1 deletion src/safe/ClassicAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const APIS: Record<string, string> = {
eth: "https://safe-transaction-mainnet.safe.global",
gor: "https://safe-transaction-goerli.safe.global",
oeth: "https://safe-transaction-optimism.safe.global",
sep: "https://safe-transaction-sepolia.safe.global",
};

export class ClassicAPI extends BaseApi implements ISafeAPI {
Expand Down Expand Up @@ -148,7 +149,7 @@ export class ClassicAPI extends BaseApi implements ISafeAPI {
}

private get apiURL(): string {
const api = APIS[this.prefix];
let api = APIS[this.prefix];
if (!api) {
throw new Error(`no API URL for chain '${this.prefix}'`);
}
Expand Down
Loading