Skip to content
Closed
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
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
4 changes: 4 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export const Schema = z.object({
* Telegram channel ID
*/
telegramChannelId: z.string(),
/**
* Slack webhook URL for notifications (optional)
*/
slackWebhookUrl: z.string().url().optional(),
/**
* Prefixed safe addresses to watch, e.g. `eth:0x11111`
*/
Expand Down
13 changes: 12 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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() {
Expand All @@ -12,6 +12,17 @@ async function run() {
const sender = new NotificationSender();
await sender.addNotifier(new Telegram(config));

// add Slack notifier if configured
if (config.slackWebhookUrl) {
await sender.addNotifier(
new Slack({
webhookUrl: config.slackWebhookUrl,
safeURL: config.safeURL,
}),
);
console.log("Added notifier");
}

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

export interface SlackOptions {
webhookUrl: string;
safeURL: string;
}

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

constructor(opts: SlackOptions) {
this.#webhookUrl = opts.webhookUrl;
this.#safeURL = opts.safeURL;
}

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

#formatMessage(event: Event): object {
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!* 🚨",
},
});
}

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

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

async #sendToSlack(message: object): Promise<void> {
if (!this.#webhookUrl) {
logger.warn("slack webhook not configured");
return;
}

try {
const response = await fetch(this.#webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});

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