├── .dockerignore ├── .github └── workflows │ ├── lint_pr.yml │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .nvmrc ├── .releaserc.json ├── .yarnrc.yml ├── Dockerfile ├── LICENSE ├── README.md ├── config.example.yaml ├── esbuild.mjs ├── package.json ├── src ├── Healthcheck.ts ├── SafeWatcher.ts ├── config │ ├── index.ts │ ├── loadConfig.ts │ ├── parsePrefixedAddress.ts │ └── schema.ts ├── index.ts ├── logger.ts ├── notifications │ ├── NotificationSender.ts │ ├── Telegram.ts │ └── index.ts ├── safe │ ├── AltAPI.ts │ ├── BaseApi.ts │ ├── ClassicAPI.ts │ ├── SafeApiWrapper.ts │ ├── constants.ts │ ├── index.ts │ ├── schema.ts │ └── types.ts ├── types.ts └── utils │ ├── fetchRetry.ts │ ├── index.ts │ └── sleep.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .git 3 | .husky 4 | .vscode 5 | node_modules 6 | cache 7 | *.log 8 | .env -------------------------------------------------------------------------------- /.github/workflows/lint_pr.yml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - reopened 8 | - edited 9 | - synchronize 10 | 11 | jobs: 12 | main: 13 | name: Validate PR title 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: amannn/action-semantic-pull-request@v4 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Check PR 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, synchronize] 6 | 7 | env: 8 | HUSKY: 0 9 | CI: true 10 | 11 | jobs: 12 | checks: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | submodules: true 20 | 21 | - name: Use Latest Corepack 22 | run: | 23 | echo "Before: corepack version => $(corepack --version || echo 'not installed')" 24 | npm install -g corepack@latest 25 | echo "After : corepack version => $(corepack --version)" 26 | corepack enable 27 | 28 | - uses: actions/setup-node@v4 29 | with: 30 | cache: "yarn" 31 | node-version-file: ".nvmrc" 32 | 33 | - name: Perform checks 34 | run: | 35 | yarn install --immutable 36 | yarn typecheck:ci 37 | yarn lint:ci 38 | yarn prettier:ci 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | - "next" 8 | workflow_dispatch: 9 | 10 | env: 11 | HUSKY: 0 12 | CI: true 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | submodules: true 23 | 24 | - name: Use Latest Corepack 25 | run: | 26 | echo "Before: corepack version => $(corepack --version || echo 'not installed')" 27 | npm install -g corepack@latest 28 | echo "After : corepack version => $(corepack --version)" 29 | corepack enable 30 | 31 | - uses: actions/setup-node@v4 32 | with: 33 | cache: "yarn" 34 | node-version-file: ".nvmrc" 35 | 36 | - name: Instal node modules 37 | run: | 38 | yarn install --immutable 39 | 40 | - name: Build 41 | run: yarn build 42 | 43 | - name: Login to GitHub Container Registry 44 | uses: docker/login-action@v3 45 | with: 46 | registry: ghcr.io 47 | username: ${{ github.actor }} 48 | password: ${{ github.token }} 49 | 50 | - name: Semantic Release 51 | uses: cycjimmy/semantic-release-action@v4 52 | with: 53 | extra_plugins: | 54 | @codedependant/semantic-release-docker 55 | env: 56 | GITHUB_TOKEN: ${{ github.token }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .history/ 2 | .idea/ 3 | node_modules/ 4 | cache/ 5 | build/ 6 | dist/ 7 | typechain/ 8 | artifacts/ 9 | forge-out/ 10 | keys/* 11 | coverage* 12 | .DS_Store 13 | .pnp.* 14 | .yarn/* 15 | .vscode 16 | !.yarn/patches 17 | !.yarn/plugins 18 | !.yarn/releases 19 | !.yarn/sdks 20 | !.yarn/versions 21 | 22 | .env 23 | /.env.local 24 | config.local.yaml 25 | 26 | /logs/*.json 27 | *.log 28 | *.tsbuildinfo 29 | .eslintcache 30 | .eslint.local.json 31 | *.generated.ts 32 | config.yaml 33 | # Optimistic output 34 | /output -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | yarn commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint-staged 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | { 4 | "name": "main" 5 | }, 6 | { 7 | "name": "next", 8 | "channel": "next", 9 | "prerelease": "next" 10 | } 11 | ], 12 | "plugins": [ 13 | "@semantic-release/commit-analyzer", 14 | "@semantic-release/release-notes-generator", 15 | [ 16 | "@semantic-release/github", 17 | { 18 | "successComment": false, 19 | "failTitle": false 20 | } 21 | ], 22 | [ 23 | "@codedependant/semantic-release-docker", 24 | { 25 | "dockerTags": [ 26 | "{{#if prerelease.[0]}}{{prerelease.[0]}}{{else}}latest{{/if}}", 27 | "{{version}}" 28 | ], 29 | "dockerArgs": { 30 | "PACKAGE_VERSION": "{{version}}" 31 | }, 32 | "dockerImage": "safe-watcher", 33 | "dockerRegistry": "ghcr.io", 34 | "dockerProject": "gearbox-protocol", 35 | "dockerBuildQuiet": false, 36 | "dockerLogin": false 37 | } 38 | ] 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/nodejs22-debian12 2 | 3 | USER 1000:1000 4 | 5 | WORKDIR /app 6 | 7 | ENV NODE_ENV=production 8 | 9 | ARG PACKAGE_VERSION 10 | LABEL org.opencontainers.image.version="${PACKAGE_VERSION}" 11 | 12 | COPY dist /app 13 | 14 | CMD ["/app/index.mjs"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Gearbox Protocol 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Safe Watcher 2 | 3 | This repository contains a bot that monitors one or more Safe addresses for critical activities throughout the entire transaction lifecycle: 4 | 5 | - **Adding Transactions:** Detects when new transactions are created in the Safe. 6 | - **Signing Transactions:** Monitors who signs the transactions and how many signatures have been collected. 7 | - **Executing Transactions:** Sends alerts when a transaction is executed. 8 | 9 | Additionally, the bot watches for any suspicious `delegateCall`. If a `delegateCall` is directed to an address other than the trusted `MultiSend` contract, the bot immediately flags it, helping to prevent attacks similar to the Bybit hack. 10 | 11 | Real-time alerts are sent to a configured Telegram channel for immediate notification. 12 | 13 | ## Usage 14 | 15 | To get started, create a `config.yaml` file with your settings. Refer to [config.example.yaml](config.example.yaml) and [schema.ts](src/config/schema.ts) for guidance. 16 | 17 | Run the Docker container with your config file mounted: 18 | 19 | ```bash 20 | docker run -v $(pwd)/config.yaml:/app/config.yaml ghcr.io/gearbox-protocol/safe-watcher:latest 21 | ``` 22 | 23 | ## Configuration 24 | 25 | 1. Create a `config.yaml` file in your local directory. You can look at [config.example.yaml](config.example.yaml) and [schema.ts](src/config/schema.ts) for details. Here is an example structure: 26 | 27 | ```yaml 28 | telegramBotToken: "xxxx" 29 | telegramChannelId: "-1111" 30 | safeAddresses: 31 | - "eth:0x11111" 32 | signers: 33 | "0x22222": "alice" 34 | "0x33333": "bob" 35 | ``` 36 | 37 | - **telegramBotToken:** Your Telegram Bot API token (instructions below). 38 | - **telegramChannelId:** The ID of the channel or group where alerts will be posted. 39 | - **safeAddresses:** One or more Safe addresses to monitor, prefixed by the network identifier (e.g., `eth:` for the Ethereum mainnet). 40 | - **signers:** A mapping of addresses to descriptive names (useful for labeling owners in alerts). 41 | 42 | 2. Ensure that `config.yaml` is in the same directory where you plan to run the Docker container. 43 | 44 | ### Getting a Telegram Bot Token 45 | 46 | 1. Open the Telegram app and start a chat with `@BotFather`. 47 | 2. Type `/start` if you haven't used BotFather before. 48 | 3. Send the command `/newbot` and follow the prompts: 49 | - Provide a name for your bot (display name). 50 | - Provide a username for your bot (it must end in "bot", e.g., `MySafeNotifierBot`). 51 | 4. Once the bot is created, BotFather will provide you with an HTTP API token (e.g., `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`). 52 | 5. Copy this token and use it as the value for `telegramBotToken` in your `config.yaml`. 53 | 54 | ### Getting a Telegram Channel ID 55 | 56 | 1. Create a new channel or group in Telegram. 57 | 2. Invite your bot to the channel. 58 | 3. Send a message to the channel. 59 | 4. Use getUpdates (Testing Locally or Anywhere You Can Send HTTP Requests) 60 | 61 | - Make a call to the [getUpdates](https://core.telegram.org/bots/api#getupdates) endpoint of the Bot API using your bot token. For example: https://api.telegram.org/bot/getUpdates 62 | 63 | The response is a JSON that contains “messages,” “channel_posts,” “callback_query,” or similar objects, depending on how your bot receives interactions. 64 | 65 | 5. Parse the JSON 66 | 67 | - Look for the `"chat"` object inside each message (e.g., `"message"` or `"edited_message"`). 68 | - The `"chat": { "id": ... }` field is the chat ID. For example, a response might look like: 69 | 70 | ```json 71 | { 72 | "ok": true, 73 | "result": [ 74 | { 75 | "update_id": 123456789, 76 | "message": { 77 | "message_id": 12, 78 | "from": { 79 | ... 80 | }, 81 | "chat": { 82 | "id": 987654321, 83 | "first_name": "John", 84 | "type": "private" 85 | }, 86 | "date": 1643212345, 87 | "text": "Hello" 88 | } 89 | } 90 | ] 91 | } 92 | ``` 93 | 94 | In this snippet, 987654321 is the telegramChannelId. 95 | . 96 | 97 | ### Running via Docker 98 | 99 | Run the Docker container with your config file mounted using the following command: 100 | 101 | ```bash 102 | docker run -v $(pwd)/config.yaml:/app/config.yaml ghcr.io/gearbox-protocol/safe-watcher:latest 103 | ``` 104 | 105 | The bot will start and immediately begin monitoring the specified Safe addresses. Any relevant changes or suspicious `delegateCall` attempts will be sent to your Telegram channel. 106 | 107 | ## License 108 | 109 | This project is distributed under the MIT License. 110 | 111 | ## Disclaimer 112 | 113 | This software is provided "as is," without warranties or guarantees of any kind. Use it at your own risk. The maintainers and contributors are not liable for any damages or losses arising from its use. Always exercise caution and follow best security practices when managing crypto assets. 114 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | telegramBotToken: "xxxx" 2 | telegramChannelId: "-1111" 3 | safeAddresses: 4 | - "eth:0x11111" 5 | signers: 6 | "0x22222": "alice" 7 | "0x33333": "bob" 8 | -------------------------------------------------------------------------------- /esbuild.mjs: -------------------------------------------------------------------------------- 1 | import { build } from "esbuild"; 2 | 3 | await build({ 4 | entryPoints: ["src/index.ts"], 5 | outdir: "dist", 6 | outExtension: { 7 | ".js": ".mjs", 8 | }, 9 | bundle: true, 10 | format: "esm", 11 | platform: "node", 12 | target: "node22", 13 | banner: { 14 | js: ` 15 | import { createRequire } from 'module'; 16 | import { fileURLToPath } from 'url'; 17 | import path from 'path'; 18 | const require = createRequire(import.meta.url); 19 | const __filename = fileURLToPath(import.meta.url); 20 | const __dirname = path.dirname(__filename); 21 | `, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gearbox-protocol/safe-watcher", 3 | "version": "1.0.0", 4 | "author": "doomsower <12031673+doomsower@users.noreply.github.com>", 5 | "license": "MIT", 6 | "private": true, 7 | "type": "module", 8 | "module": "dist/index.mjs", 9 | "scripts": { 10 | "clean": "rm -rf dist", 11 | "build": "node esbuild.mjs", 12 | "start": "touch .env && sh -c 'tsx --env-file .env src/index.ts \"$@\" | pino-pretty --colorize' --", 13 | "prepare": "husky", 14 | "prettier": "prettier --write .", 15 | "prettier:ci": "npx prettier --check .", 16 | "lint": "eslint \"**/*.ts\" --fix", 17 | "lint:ci": "eslint \"**/*.ts\"", 18 | "typecheck:ci": "tsc --noEmit", 19 | "test": "vitest" 20 | }, 21 | "dependencies": { 22 | "@vlad-yakovlev/telegram-md": "^2.0.0", 23 | "abitype": "^1.0.8", 24 | "date-fns": "^4.1.0", 25 | "nanoid": "^5.0.9", 26 | "pino": "^9.5.0", 27 | "viem": "^2.21.55", 28 | "yaml": "^2.7.0", 29 | "zod": "^3.24.2", 30 | "zod-config": "^0.1.3" 31 | }, 32 | "devDependencies": { 33 | "@commitlint/cli": "^19.0.3", 34 | "@commitlint/config-conventional": "^19.0.3", 35 | "@gearbox-protocol/eslint-config": "^2.0.0-next.2", 36 | "@gearbox-protocol/prettier-config": "^2.0.0-next.0", 37 | "@types/node": "^22.13.5", 38 | "esbuild": "^0.24.0", 39 | "eslint": "^8.57.0", 40 | "husky": "^9.0.11", 41 | "lint-staged": "^15.2.2", 42 | "msw": "^2.2.2", 43 | "pino-pretty": "^13.0.0", 44 | "prettier": "^3.2.5", 45 | "tsx": "^4.7.1", 46 | "typescript": "^5.3.3", 47 | "vitest": "^2.1.8" 48 | }, 49 | "commitlint": { 50 | "extends": [ 51 | "@commitlint/config-conventional" 52 | ] 53 | }, 54 | "eslintConfig": { 55 | "root": true, 56 | "extends": [ 57 | "@gearbox-protocol/eslint-config" 58 | ] 59 | }, 60 | "prettier": "@gearbox-protocol/prettier-config", 61 | "lint-staged": { 62 | "*.ts": [ 63 | "eslint --fix", 64 | "prettier --write" 65 | ], 66 | "*.{json,md}": "prettier --write" 67 | }, 68 | "packageManager": "yarn@4.6.0" 69 | } 70 | -------------------------------------------------------------------------------- /src/Healthcheck.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:http"; 2 | 3 | import { formatDuration, intervalToDuration } from "date-fns"; 4 | import { customAlphabet } from "nanoid"; 5 | 6 | import logger from "./logger.js"; 7 | 8 | const nanoid = customAlphabet("1234567890abcdef", 8); 9 | 10 | class Healthcheck { 11 | #id = nanoid(); 12 | #version?: string; 13 | #start = Math.round(new Date().valueOf() / 1000); 14 | 15 | public async run(): Promise { 16 | this.#version = process.env.PACKAGE_VERSION || "dev"; 17 | const server = createServer(async (req, res) => { 18 | // Routing 19 | if (req.url === "/") { 20 | res.writeHead(200, { "Content-Type": "application/json" }); 21 | res.end( 22 | JSON.stringify({ 23 | uptime: formatDuration( 24 | intervalToDuration({ start: this.#start, end: new Date() }), 25 | ), 26 | }), 27 | ); 28 | } else if (req.url === "/metrics") { 29 | try { 30 | res.writeHead(200, { "Content-Type": "text/plain" }); 31 | res.end(this.#metrics()); 32 | } catch (ex) { 33 | res.writeHead(500, { "Content-Type": "text/plain" }); 34 | res.end("error"); 35 | } 36 | } else { 37 | res.writeHead(404, { "Content-Type": "text/plain" }); 38 | res.end("not found"); 39 | } 40 | }); 41 | 42 | server.listen(4000, () => { 43 | logger.info("started healthcheck"); 44 | }); 45 | } 46 | 47 | /** 48 | * Returns metrics in prometheus format 49 | * https://prometheus.io/docs/concepts/data_model/ 50 | */ 51 | #metrics(): string { 52 | const labels = Object.entries({ 53 | instance_id: this.#id, 54 | version: this.#version, 55 | }) 56 | .map(([k, v]) => `${k}="${v}"`) 57 | .join(", "); 58 | return `# HELP service_up Simple binary flag to indicate being alive 59 | # TYPE service_up gauge 60 | service_up{${labels}} 1 61 | 62 | # HELP start_time Start time, in unixtime 63 | # TYPE start_time gauge 64 | start_time{${labels}} ${this.#start} 65 | `; 66 | } 67 | } 68 | export default Healthcheck; 69 | -------------------------------------------------------------------------------- /src/SafeWatcher.ts: -------------------------------------------------------------------------------- 1 | import type { Logger } from "pino"; 2 | import type { Address, Hash } from "viem"; 3 | 4 | import type { PrefixedAddress } from "./config/index.js"; 5 | import { parsePrefixedAddress } from "./config/index.js"; 6 | import logger from "./logger.js"; 7 | import type { 8 | ISafeAPI, 9 | ListedSafeTx, 10 | SafeAPIMode, 11 | SafeTx, 12 | Signer, 13 | } from "./safe/index.js"; 14 | import { MULTISEND_CALL_ONLY, SafeApiWrapper } from "./safe/index.js"; 15 | import type { INotificationSender } from "./types.js"; 16 | 17 | interface SafeWatcherOptions { 18 | safe: PrefixedAddress; 19 | signers?: Partial>; 20 | notifier?: INotificationSender; 21 | api?: SafeAPIMode; 22 | } 23 | 24 | class SafeWatcher { 25 | readonly #prefix: string; 26 | readonly #safe: Address; 27 | readonly #notificationSender?: INotificationSender; 28 | readonly #logger: Logger; 29 | readonly #api: ISafeAPI; 30 | readonly #txs: Map = new Map(); 31 | readonly #signers: Partial>; 32 | 33 | #interval?: NodeJS.Timeout; 34 | 35 | constructor(opts: SafeWatcherOptions) { 36 | const [prefix, address] = parsePrefixedAddress(opts.safe); 37 | this.#logger = logger.child({ chain: prefix, address }); 38 | this.#prefix = prefix; 39 | this.#safe = address; 40 | this.#notificationSender = opts.notifier; 41 | this.#signers = opts.signers ?? {}; 42 | this.#api = new SafeApiWrapper(opts.safe, opts.api); 43 | } 44 | 45 | public async start(pollInterval: number): Promise { 46 | const txs = await this.#api.fetchAll(); 47 | for (const tx of txs) { 48 | this.#txs.set(tx.safeTxHash, tx); 49 | } 50 | if (pollInterval > 0) { 51 | this.#interval = setInterval(() => { 52 | this.poll().catch(e => { 53 | this.#logger.error(e); 54 | }); 55 | }, pollInterval); 56 | } 57 | this.#logger.info({ txs: txs.length }, "started watcher"); 58 | } 59 | 60 | public stop(): void { 61 | if (this.#interval) { 62 | clearInterval(this.#interval); 63 | } 64 | } 65 | 66 | private async poll(): Promise { 67 | // assume that all updates fit into one page 68 | const txs = await this.#api.fetchLatest(); 69 | const pendingTxs = txs 70 | .filter(tx => tx.isExecuted === false) 71 | .sort((a, b) => a.nonce - b.nonce); 72 | // const hasNewOrExecuted = txs.some( 73 | // tx => 74 | // !this.#txs.has(tx.safeTxHash) || 75 | // (tx.isExecuted && !this.#txs.get(tx.safeTxHash)?.isExecuted), 76 | // ); 77 | 78 | // If there are new pending txs, request report for them together 79 | // if (hasNewOrExecuted && pendingTxs.length > 0) { 80 | // this.#logger.debug( 81 | // `safe has ${pendingTxs.length} pending txs, some of them new, generating compound report`, 82 | // ); 83 | // await this.anvilManagerAPI.requestSafeReport( 84 | // this.#chain.network, 85 | // pendingTxs, 86 | // ); 87 | // } 88 | // const pendingReport = this.anvilManagerAPI.reportURL( 89 | // this.#chain.network, 90 | // pendingTxs, 91 | // ); 92 | 93 | for (const tx of txs) { 94 | try { 95 | const old = this.#txs.get(tx.safeTxHash); 96 | if (old) { 97 | await this.#processTxUpdate(tx, old, pendingTxs); 98 | } else { 99 | await this.#processNewTx(tx, pendingTxs); 100 | } 101 | } catch (e) { 102 | this.#logger.error(e); 103 | } 104 | } 105 | } 106 | 107 | async #processNewTx( 108 | tx: ListedSafeTx, 109 | pending: ListedSafeTx[], 110 | ): Promise { 111 | this.#logger?.info( 112 | { tx: tx.safeTxHash, nonce: tx.nonce }, 113 | "detected new tx", 114 | ); 115 | this.#txs.set(tx.safeTxHash, tx); 116 | 117 | // await this.anvilManagerAPI.requestSafeReport(this.#chain.network, [ 118 | // tx.safeTxHash, 119 | // ]); 120 | const detailed = await this.#fetchDetailed(tx.safeTxHash); 121 | 122 | const isMalicious = 123 | !MULTISEND_CALL_ONLY.has(detailed.to.toLowerCase() as Address) && 124 | detailed.operation !== 0; 125 | 126 | await this.#notificationSender?.notify({ 127 | type: isMalicious ? "malicious" : "created", 128 | chainPrefix: this.#prefix, 129 | safe: this.#safe, 130 | tx: detailed, 131 | pending, 132 | }); 133 | } 134 | 135 | async #processTxUpdate( 136 | tx: ListedSafeTx, 137 | old: ListedSafeTx, 138 | pending: ListedSafeTx[], 139 | ): Promise { 140 | this.#txs.set(tx.safeTxHash, tx); 141 | if ( 142 | old.isExecuted === tx.isExecuted && 143 | old.confirmations === tx.confirmations 144 | ) { 145 | return; 146 | } 147 | this.#logger?.info( 148 | { tx: tx.safeTxHash, nonce: tx.nonce, isExecuted: tx.isExecuted }, 149 | "detected updated tx", 150 | ); 151 | 152 | const detailed = await this.#fetchDetailed(tx.safeTxHash); 153 | 154 | await this.#notificationSender?.notify({ 155 | type: tx.isExecuted ? "executed" : "updated", 156 | chainPrefix: this.#prefix, 157 | safe: this.#safe, 158 | tx: detailed, 159 | pending, 160 | }); 161 | } 162 | 163 | async #fetchDetailed(safeTxHash: Hash): Promise> { 164 | const tx = await this.#api.fetchDetailed(safeTxHash); 165 | return { 166 | ...tx, 167 | proposer: { address: tx.proposer, name: this.#signers[tx.proposer] }, 168 | confirmations: tx.confirmations.map(c => ({ 169 | address: c, 170 | name: this.#signers[c], 171 | })), 172 | }; 173 | } 174 | } 175 | 176 | export default SafeWatcher; 177 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export { loadConfig } from "./loadConfig.js"; 2 | export { parsePrefixedAddress } from "./parsePrefixedAddress.js"; 3 | export { PrefixedAddress, Schema } from "./schema.js"; 4 | -------------------------------------------------------------------------------- /src/config/loadConfig.ts: -------------------------------------------------------------------------------- 1 | import { loadConfig as load } from "zod-config"; 2 | import { envAdapter } from "zod-config/env-adapter"; 3 | import { yamlAdapter } from "zod-config/yaml-adapter"; 4 | 5 | import { Schema } from "./schema.js"; 6 | 7 | export async function loadConfig(): Promise { 8 | let path = "config.yaml"; 9 | const cIndex = process.argv.indexOf("--config"); 10 | if (cIndex > 0) { 11 | path = process.argv[cIndex + 1] || path; 12 | } 13 | return load({ 14 | schema: Schema, 15 | adapters: [yamlAdapter({ path }), envAdapter()], 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/config/parsePrefixedAddress.ts: -------------------------------------------------------------------------------- 1 | import type { Address } from "viem"; 2 | import { isAddress } from "viem"; 3 | 4 | import type { PrefixedAddress } from "./schema.js"; 5 | 6 | export function parsePrefixedAddress( 7 | addr: PrefixedAddress, 8 | ): [prefix: string, address: Address] { 9 | const [prefix, address] = addr.split(":"); 10 | if (!isAddress(address)) { 11 | throw new Error(`invalid prefixed safe address '${addr}'`); 12 | } 13 | return [prefix, address]; 14 | } 15 | -------------------------------------------------------------------------------- /src/config/schema.ts: -------------------------------------------------------------------------------- 1 | import { Address } from "abitype/zod"; 2 | import { z } from "zod"; 3 | 4 | import { SafeAPIMode } from "../safe/schema.js"; 5 | 6 | export type PrefixedAddress = `${string}:0x${string}`; 7 | 8 | export const PrefixedAddress = z.string().transform((val, ctx) => { 9 | const regex = /^[a-zA-Z0-9]+:0x[a-fA-F0-9]{40}$/; 10 | 11 | if (!regex.test(val)) { 12 | ctx.addIssue({ 13 | code: z.ZodIssueCode.custom, 14 | message: `Invalid Prefixed Safe Address ${val}`, 15 | }); 16 | } 17 | 18 | return val as PrefixedAddress; 19 | }); 20 | 21 | export const Schema = z.object({ 22 | /** 23 | * URL of the Safe web app to generate links in notifications 24 | */ 25 | safeURL: z.string().url().default("https://app.safe.global"), 26 | /** 27 | * Polling interval in seconds 28 | */ 29 | pollInterval: z.number().int().positive().default(20), 30 | /** 31 | * Telegram bot token 32 | */ 33 | telegramBotToken: z.string(), 34 | /** 35 | * Telegram channel ID 36 | */ 37 | telegramChannelId: z.string(), 38 | /** 39 | * Prefixed safe addresses to watch, e.g. `eth:0x11111` 40 | */ 41 | safeAddresses: z.array(PrefixedAddress).min(1), 42 | /** 43 | * Mapping of signer address to human-readable name 44 | */ 45 | signers: z.record(Address, z.string().min(1)).default({}), 46 | /** 47 | * Which safe API to use 48 | */ 49 | api: SafeAPIMode.default("fallback"), 50 | }); 51 | 52 | export type Schema = z.infer; 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from "node:timers/promises"; 2 | 3 | import { loadConfig } from "./config/index.js"; 4 | import Healthcheck from "./Healthcheck.js"; 5 | import logger from "./logger.js"; 6 | import { NotificationSender, Telegram } from "./notifications/index.js"; 7 | import SafeWatcher from "./SafeWatcher.js"; 8 | 9 | async function run() { 10 | const config = await loadConfig(); 11 | 12 | const sender = new NotificationSender(); 13 | await sender.addNotifier(new Telegram(config)); 14 | 15 | const safes = config.safeAddresses.map(async (safe, i) => { 16 | await setTimeout(1000 * i); 17 | return new SafeWatcher({ 18 | safe, 19 | signers: config.signers, 20 | notifier: sender, 21 | }).start(config.pollInterval * 1000); 22 | }); 23 | const healthcheck = new Healthcheck(); 24 | 25 | await healthcheck.run(); 26 | await Promise.all(safes); 27 | } 28 | 29 | run().catch(e => { 30 | logger.error(e); 31 | process.exit(1); 32 | }); 33 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { pino } from "pino"; 2 | 3 | const logger = pino({ 4 | level: 5 | process.env.NODE_ENV === "test" 6 | ? "silent" 7 | : process.env.LOG_LEVEL ?? "debug", 8 | base: {}, 9 | formatters: { 10 | level: label => { 11 | return { 12 | level: label, 13 | }; 14 | }, 15 | }, 16 | // fluent-bit (which is used in our ecs setup with loki) cannot handle unix epoch in millis out of the box 17 | timestamp: () => `,"time":${Date.now() / 1000.0}`, 18 | }); 19 | 20 | export default logger; 21 | -------------------------------------------------------------------------------- /src/notifications/NotificationSender.ts: -------------------------------------------------------------------------------- 1 | import logger from "../logger.js"; 2 | import type { Event, INotificationSender, INotifier } from "../types.js"; 3 | 4 | export class NotificationSender implements INotificationSender { 5 | readonly #notifiers: INotifier[] = []; 6 | 7 | public async addNotifier(notifier: INotifier): Promise { 8 | this.#notifiers.push(notifier); 9 | } 10 | 11 | public async notify(event: Event): Promise { 12 | logger.debug({ event }, "notifying"); 13 | try { 14 | await Promise.allSettled(this.#notifiers.map(n => n.send(event))); 15 | } catch (e) { 16 | logger.error(e); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/notifications/Telegram.ts: -------------------------------------------------------------------------------- 1 | import type { Markdown } from "@vlad-yakovlev/telegram-md"; 2 | import { md } from "@vlad-yakovlev/telegram-md"; 3 | 4 | import logger from "../logger.js"; 5 | import type { Signer } from "../safe/index.js"; 6 | import type { Event, EventType, INotifier } from "../types.js"; 7 | 8 | const ACTIONS: Record = { 9 | created: "created", 10 | updated: "updated", 11 | executed: "executed", 12 | malicious: "ALERT! ACTION REQUIRED: MALICIOUS TRANSACTION DETECTED!", 13 | }; 14 | 15 | const NETWORKS: Record = { 16 | arb1: "Arbitrum", 17 | eth: "Eth Mainnet", 18 | gor: "Eth Goerli", 19 | oeth: "Optimism", 20 | }; 21 | 22 | export interface TelegramOptions { 23 | safeURL: string; 24 | telegramBotToken: string; 25 | telegramChannelId: string; 26 | } 27 | 28 | export class Telegram implements INotifier { 29 | readonly #botToken: string; 30 | readonly #channelId: string; 31 | readonly #safeURL: string; 32 | 33 | constructor(opts: TelegramOptions) { 34 | this.#botToken = opts.telegramBotToken; 35 | this.#channelId = opts.telegramChannelId; 36 | this.#safeURL = opts.safeURL; 37 | } 38 | 39 | public async send(event: Event): Promise { 40 | const msg = this.#getMessage(event); 41 | await this.#sendToTelegram(msg.toString()); 42 | } 43 | 44 | #getMessage(event: Event): Markdown { 45 | const { type, chainPrefix, safe, tx } = event; 46 | 47 | const link = md.link( 48 | "🔗 transaction", 49 | `${this.#safeURL}/${chainPrefix}:${safe}/transactions/queue`, 50 | ); 51 | // const report = md.link( 52 | // "📄 tx report", 53 | // this.anvilManagerAPI.reportURL(this.#chain.network, [tx.safeTxHash]), 54 | // ); 55 | const proposer = md`Proposed by: ${printSigner(tx.proposer)}`; 56 | let confirmations = md.join(tx.confirmations.map(printSigner), ", "); 57 | confirmations = md`Signed by: ${confirmations}`; 58 | 59 | const msg = md`${ACTIONS[type]} ${NETWORKS[chainPrefix]} multisig [${tx.confirmations.length}/${tx.confirmationsRequired}] with safeTxHash ${md.inlineCode(tx.safeTxHash)} and nonce ${md.inlineCode(tx.nonce)}`; 60 | 61 | const components = [msg, proposer, confirmations]; 62 | const links = [link /* , report */]; 63 | // if (pendingReport) { 64 | // links.push(md.link("📄 pending report", pendingReport)); 65 | // } 66 | components.push(md.join(links, " ‖ ")); 67 | 68 | return md.join(components, "\n\n"); 69 | } 70 | 71 | async #sendToTelegram(text: string): Promise { 72 | if (!this.#botToken || !this.#channelId) { 73 | logger.warn("telegram messages not configured"); 74 | return; 75 | } 76 | const url = `https://api.telegram.org/bot${this.#botToken}/sendMessage`; 77 | 78 | try { 79 | const response = await fetch(url, { 80 | method: "POST", 81 | headers: { "Content-Type": "application/json" }, 82 | body: JSON.stringify({ 83 | chat_id: this.#channelId, 84 | parse_mode: "MarkdownV2", 85 | text, 86 | }), 87 | }); 88 | 89 | if (response.ok) { 90 | logger.debug("telegram sent successfully"); 91 | } else { 92 | const err = await response.text(); 93 | throw new Error(`${response.statusText}: ${err}`); 94 | } 95 | } catch (err) { 96 | logger.error({ err, text }, "cannot send to telegram"); 97 | } 98 | } 99 | } 100 | 101 | function printSigner({ address, name }: Signer): Markdown { 102 | return name ? md.bold(name) : md.inlineCode(address); 103 | } 104 | -------------------------------------------------------------------------------- /src/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./NotificationSender.js"; 2 | export * from "./Telegram.js"; 3 | -------------------------------------------------------------------------------- /src/safe/AltAPI.ts: -------------------------------------------------------------------------------- 1 | import type { Address, Hash, Hex } from "viem"; 2 | 3 | import { BaseApi } from "./BaseApi.js"; 4 | import type { ISafeAPI, ListedSafeTx, SafeTx } from "./types.js"; 5 | 6 | type TxID = `multisig_${Address}_${Hash}`; 7 | 8 | type TxStatus = 9 | | "AWAITING_CONFIRMATIONS" 10 | | "AWAITING_EXECUTION" 11 | | "SUCCESS" 12 | | "FAILED" 13 | | "CANCELLED"; 14 | 15 | interface Transaction { 16 | safeAddress: Address; 17 | txId: TxID; 18 | executedAt: null | number; 19 | txStatus: TxStatus; 20 | txInfo: TxInfo; 21 | txData: TxData; 22 | txHash: null | Hash; 23 | detailedExecutionInfo: DetailedExecutionInfo; 24 | // safeAppInfo: SafeAppInfo; 25 | note: null | string; 26 | } 27 | 28 | interface TxInfo { 29 | type: string; 30 | humanDescription: null | string; 31 | to: AddressInfo; 32 | dataSize: string; 33 | value: string; 34 | methodName: string; 35 | actionCount: number; 36 | isCancellation: boolean; 37 | } 38 | 39 | interface AddressInfo { 40 | value: Address; 41 | name: string; 42 | logoUri: string; 43 | } 44 | 45 | interface TxData { 46 | hexData: Hex; 47 | // dataDecoded: DataDecoded; 48 | to: AddressInfo; 49 | value: string; 50 | operation: number; 51 | trustedDelegateCallTarget: boolean; 52 | addressInfoIndex: Record; 53 | } 54 | 55 | interface DetailedExecutionInfo { 56 | type: string; 57 | submittedAt: number; 58 | nonce: number; 59 | safeTxGas: string; 60 | baseGas: string; 61 | gasPrice: string; 62 | gasToken: string; 63 | refundReceiver: AddressInfo; 64 | safeTxHash: Hash; 65 | executor: null | string; 66 | signers: AddressInfo[]; 67 | confirmationsRequired: number; 68 | confirmations: Confirmation[]; 69 | rejectors: any[]; 70 | gasTokenInfo: null | any; 71 | trusted: boolean; 72 | proposer: AddressInfo; 73 | proposedByDelegate: null | any; 74 | } 75 | 76 | interface Confirmation { 77 | signer: AddressInfo; 78 | signature: Hex; 79 | submittedAt: number; 80 | } 81 | 82 | interface ListTransactionsResp { 83 | next: string | null; 84 | previous: string | null; 85 | results: ListTransactionsResult[]; 86 | } 87 | 88 | interface ListTransactionsResult { 89 | type: string; 90 | transaction: ListedTx; 91 | conflictType: string; 92 | } 93 | 94 | interface ListedTx { 95 | txInfo: TxInfo; 96 | id: TxID; 97 | timestamp: number; 98 | txStatus: TxStatus; 99 | executionInfo: ExecutionInfo; 100 | // safeAppInfo: SafeAppInfo; 101 | txHash: string | null; 102 | } 103 | 104 | interface ExecutionInfo { 105 | type: string; 106 | nonce: number; 107 | confirmationsRequired: number; 108 | confirmationsSubmitted: number; 109 | missingSigners: AddressInfo[] | null; 110 | } 111 | 112 | interface ParsedTxId { 113 | multisig: Address; 114 | safeTxHash: Hash; 115 | } 116 | 117 | function parseTxId(id: TxID): ParsedTxId { 118 | const [__, multisig, safeTxHash] = id.split("_"); 119 | return { multisig: multisig as Address, safeTxHash: safeTxHash as Hash }; 120 | } 121 | 122 | const CHAIN_IDS: Record = { 123 | arb1: 42161, 124 | eth: 1, 125 | gor: 5, 126 | oeth: 10, 127 | }; 128 | 129 | function normalizeLisited(tx: ListedTx): ListedSafeTx { 130 | const { safeTxHash } = parseTxId(tx.id); 131 | return { 132 | safeTxHash, 133 | nonce: tx.executionInfo.nonce, 134 | confirmations: tx.executionInfo.confirmationsSubmitted, 135 | confirmationsRequired: tx.executionInfo.confirmationsRequired, 136 | isExecuted: tx.txStatus === "SUCCESS", 137 | }; 138 | } 139 | 140 | function normalizeDetailed(tx: Transaction): SafeTx
{ 141 | const { safeTxHash } = parseTxId(tx.txId); 142 | return { 143 | safeTxHash, 144 | nonce: tx.detailedExecutionInfo.nonce, 145 | to: tx.txInfo.to.value, 146 | operation: tx.txData.operation, 147 | proposer: tx.detailedExecutionInfo.confirmations?.[0].signer.value ?? "0x0", 148 | confirmations: 149 | tx.detailedExecutionInfo.confirmations?.map(c => c.signer.value) ?? [], 150 | confirmationsRequired: tx.detailedExecutionInfo.confirmationsRequired, 151 | isExecuted: tx.txStatus === "SUCCESS", 152 | }; 153 | } 154 | 155 | export class AltAPI extends BaseApi implements ISafeAPI { 156 | public async fetchAll(): Promise { 157 | let url: string | null | undefined; 158 | const results: ListedTx[] = []; 159 | do { 160 | try { 161 | const data = await this.#fetchList(url); 162 | results.push(...(data.results.map(tx => tx.transaction) ?? [])); 163 | url = data.next; 164 | } catch (e) { 165 | this.logger.error(e); 166 | break; 167 | } 168 | } while (url); 169 | return results.map(normalizeLisited); 170 | } 171 | 172 | public async fetchLatest(): Promise { 173 | const data = await this.#fetchList(); 174 | return (data.results.map(tx => tx.transaction) ?? []).map(normalizeLisited); 175 | } 176 | 177 | public async fetchDetailed(safeTxHash: Hash): Promise> { 178 | const tx = await this.#fetchOne(safeTxHash); 179 | return normalizeDetailed(tx); 180 | } 181 | 182 | async #fetchList(url?: string | null): Promise { 183 | try { 184 | const u = 185 | url ?? `${this.apiURL}/safes/${this.address}/multisig-transactions`; 186 | const resp: ListTransactionsResp = await this.fetch(u); 187 | return resp; 188 | } catch (e) { 189 | this.logger.error(e); 190 | return { results: [], next: null, previous: null }; 191 | } 192 | } 193 | 194 | async #fetchOne(safeTxHash: Hash): Promise { 195 | this.logger.debug(`loading tx ${safeTxHash}`); 196 | const data: Transaction = await this.fetch( 197 | `${this.apiURL}/transactions/${safeTxHash}`, 198 | ); 199 | this.logger.debug(`loaded tx ${safeTxHash}`); 200 | return data; 201 | } 202 | 203 | private get chainId(): number { 204 | const chainId = CHAIN_IDS[this.prefix]; 205 | if (!chainId) { 206 | throw new Error(`no chain id for prefix '${this.prefix}'`); 207 | } 208 | return chainId; 209 | } 210 | 211 | private get apiURL(): string { 212 | return `https://safe-client.safe.global/v1/chains/${this.chainId}`; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/safe/BaseApi.ts: -------------------------------------------------------------------------------- 1 | import type { Logger } from "pino"; 2 | import type { Address } from "viem"; 3 | 4 | import type { PrefixedAddress } from "../config/index.js"; 5 | import { parsePrefixedAddress } from "../config/index.js"; 6 | import logger from "../logger.js"; 7 | import { fetchRetry } from "../utils/index.js"; 8 | 9 | export abstract class BaseApi { 10 | protected readonly logger: Logger; 11 | protected readonly address: Address; 12 | protected readonly prefix: string; 13 | 14 | constructor(safe: PrefixedAddress) { 15 | const [prefix, address] = parsePrefixedAddress(safe); 16 | this.address = address; 17 | this.prefix = prefix; 18 | this.logger = logger.child({ prefix, address }); 19 | } 20 | 21 | protected async fetch(url: string): Promise { 22 | this.logger.debug(`fetching ${url}`); 23 | const resp = await fetchRetry(url, { 24 | retries: 20, 25 | validateResponse: r => { 26 | if (!r.ok) { 27 | throw new Error(`invalid response status: ${r.status}`); 28 | } 29 | const ct = r.headers.get("Content-Type"); 30 | if (!ct?.includes("application/json")) { 31 | throw new Error(`invalid content type: ${ct}`); 32 | } 33 | }, 34 | }); 35 | const data = await resp.json(); 36 | return data; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/safe/ClassicAPI.ts: -------------------------------------------------------------------------------- 1 | import type { Address, Hash } from "viem"; 2 | 3 | import { BaseApi } from "./BaseApi.js"; 4 | import type { ISafeAPI, ListedSafeTx, SafeTx } from "./types.js"; 5 | 6 | // export interface SafeMultisigTransactionResponse { 7 | interface SafeMultisigTransaction { 8 | // safe: string; 9 | to: Address; 10 | // value: string; 11 | // data: string; 12 | operation: number; 13 | // gasToken: string; 14 | // safeTxGas: number; 15 | // baseGas: number; 16 | // gasPrice: string; 17 | // refundReceiver: string; 18 | nonce: number; 19 | executionDate?: string; 20 | submissionDate: string; 21 | // modified: Date; 22 | // blockNumber?: number; 23 | transactionHash: Hash; 24 | safeTxHash: Hash; 25 | // executor: string; 26 | isExecuted: boolean; 27 | // isSuccessful?: boolean; 28 | // ethGasPrice: string; 29 | // maxFeePerGas: string; 30 | // maxPriorityFeePerGas: string; 31 | // gasUsed?: number; 32 | // fee: string; 33 | // origin: string; 34 | // dataDecoded: DataDecoded; 35 | proposer: Address; 36 | confirmationsRequired: number; 37 | confirmations: SafeMultisigConfirmationResponse[]; 38 | // trusted: boolean; 39 | // signatures: string; 40 | } 41 | 42 | interface SafeMultisigConfirmationResponse { 43 | owner: Address; 44 | submissionDate: string; 45 | transactionHash?: Hash; 46 | signature: Hash; 47 | signatureType: string; 48 | } 49 | 50 | interface SafeMultisigTransactionData { 51 | /** 52 | * Total number of transactions 53 | */ 54 | count: number; 55 | /** 56 | * URL to fetch next page 57 | */ 58 | next?: string | null; 59 | /** 60 | * URL to fetch previos page 61 | */ 62 | previous?: string | null; 63 | /** 64 | * Array of results, max 100 results 65 | */ 66 | results?: SafeMultisigTransaction[]; 67 | countUniqueNonce: number; 68 | } 69 | 70 | function normalizeListed(tx: SafeMultisigTransaction): ListedSafeTx { 71 | return { 72 | safeTxHash: tx.safeTxHash, 73 | nonce: tx.nonce, 74 | confirmations: tx.confirmations?.length ?? 0, 75 | confirmationsRequired: tx.confirmationsRequired, 76 | isExecuted: tx.isExecuted, 77 | }; 78 | } 79 | 80 | function normalizeDetailed(tx: SafeMultisigTransaction): SafeTx
{ 81 | return { 82 | safeTxHash: tx.safeTxHash, 83 | nonce: tx.nonce, 84 | to: tx.to, 85 | operation: tx.operation, 86 | proposer: tx.proposer, 87 | confirmations: tx.confirmations.map(c => c.owner), 88 | confirmationsRequired: tx.confirmationsRequired, 89 | isExecuted: tx.isExecuted, 90 | }; 91 | } 92 | 93 | const APIS: Record = { 94 | arb1: "https://safe-transaction-arbitrum.safe.global", 95 | eth: "https://safe-transaction-mainnet.safe.global", 96 | gor: "https://safe-transaction-goerli.safe.global", 97 | oeth: "https://safe-transaction-optimism.safe.global", 98 | }; 99 | 100 | export class ClassicAPI extends BaseApi implements ISafeAPI { 101 | readonly #txs = new Map(); 102 | 103 | public async fetchAll(): Promise { 104 | let url: string | null | undefined; 105 | const results: SafeMultisigTransaction[] = []; 106 | do { 107 | const data = await this.#fetchMany(url); 108 | results.push(...(data.results ?? [])); 109 | url = data.next; 110 | } while (url); 111 | for (const result of results) { 112 | this.#txs.set(result.safeTxHash, result); 113 | } 114 | return results.map(normalizeListed); 115 | } 116 | 117 | public async fetchLatest(): Promise { 118 | const data = await this.#fetchMany(); 119 | const txs = data.results ?? []; 120 | for (const tx of txs) { 121 | this.#txs.set(tx.safeTxHash, tx); 122 | } 123 | return txs.map(normalizeListed); 124 | } 125 | 126 | public async fetchDetailed(safeTxHash: Hash): Promise> { 127 | const cached = this.#txs.get(safeTxHash); 128 | if (cached) { 129 | return normalizeDetailed(cached); 130 | } 131 | const data = await this.#fetchOne(safeTxHash); 132 | this.#txs.set(data.safeTxHash, data); 133 | return normalizeDetailed(data); 134 | } 135 | 136 | async #fetchMany(url?: string | null): Promise { 137 | const u = 138 | url ?? 139 | `${this.apiURL}/api/v1/safes/${this.address}/multisig-transactions/`; 140 | const data = await this.fetch(u); 141 | return data; 142 | } 143 | 144 | async #fetchOne(safeTxHash: Hash): Promise { 145 | const url = `${this.apiURL}/api/v1/safes/${this.address}/multisig-transactions/${safeTxHash}`; 146 | const data = await this.fetch(url); 147 | return data; 148 | } 149 | 150 | private get apiURL(): string { 151 | const api = APIS[this.prefix]; 152 | if (!api) { 153 | throw new Error(`no API URL for chain '${this.prefix}'`); 154 | } 155 | return api; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/safe/SafeApiWrapper.ts: -------------------------------------------------------------------------------- 1 | import type { PrefixedAddress } from "../config/index.js"; 2 | import { AltAPI } from "./AltAPI.js"; 3 | import { BaseApi } from "./BaseApi.js"; 4 | import { ClassicAPI } from "./ClassicAPI.js"; 5 | import type { SafeAPIMode } from "./schema.js"; 6 | import type { ISafeAPI } from "./types.js"; 7 | 8 | const methods = ["fetchAll", "fetchLatest", "fetchDetailed"] as Array< 9 | keyof ISafeAPI 10 | >; 11 | 12 | export class SafeApiWrapper extends BaseApi implements ISafeAPI { 13 | readonly #classic: ISafeAPI; 14 | readonly #alt: ISafeAPI; 15 | 16 | constructor(safe: PrefixedAddress, mode: SafeAPIMode = "fallback") { 17 | super(safe); 18 | this.#classic = new ClassicAPI(safe); 19 | this.#alt = new AltAPI(safe); 20 | for (const m of methods) { 21 | this[m] = async (...args: Parameters) => { 22 | if (mode === "classic") { 23 | return this.#classic[m](...args); 24 | } else if (mode === "alt") { 25 | return this.#alt[m](...args); 26 | } else { 27 | try { 28 | const classic = await Promise.resolve(this.#classic[m](...args)); 29 | return classic; 30 | } catch (e) { 31 | this.logger.error(e); 32 | this.logger.warn("falling back to alternative api"); 33 | const alt = await Promise.resolve(this.#alt[m](...args)); 34 | return alt; 35 | } 36 | } 37 | }; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/safe/constants.ts: -------------------------------------------------------------------------------- 1 | import type { Address } from "viem"; 2 | 3 | /** 4 | * See multi_send_call_only.sol here 5 | * https://docs.safe.global/advanced/smart-account-supported-networks?service=Transaction+Service&page=2&expand=1 6 | */ 7 | export const MULTISEND_CALL_ONLY = new Set
([ 8 | "0x9641d764fc13c8b624c04430c7356c1c7c8102e2", 9 | "0x40a2accbd92bca938b02010e17a5b8929b49130d", 10 | ]); 11 | -------------------------------------------------------------------------------- /src/safe/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./constants.js"; 2 | export * from "./SafeApiWrapper.js"; 3 | export * from "./schema.js"; 4 | export * from "./types.js"; 5 | -------------------------------------------------------------------------------- /src/safe/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const SafeAPIMode = z.enum(["classic", "alt", "fallback"] as const); 4 | 5 | export type SafeAPIMode = z.infer; 6 | -------------------------------------------------------------------------------- /src/safe/types.ts: -------------------------------------------------------------------------------- 1 | import type { Address, Hash } from "viem"; 2 | 3 | export interface ISafeAPI { 4 | fetchAll: () => Promise; 5 | fetchLatest: () => Promise; 6 | fetchDetailed: (safeTxHash: Hash) => Promise>; 7 | } 8 | 9 | export interface Signer { 10 | address: Address; 11 | name?: string; 12 | } 13 | 14 | /** 15 | * Common response from different listing APIs 16 | */ 17 | export interface ListedSafeTx { 18 | safeTxHash: Hash; 19 | nonce: number; 20 | confirmations: number; 21 | confirmationsRequired: number; 22 | isExecuted: boolean; 23 | } 24 | 25 | /** 26 | * Common safe TX details that are used in notifications and can be obtained from any API 27 | */ 28 | export interface SafeTx { 29 | safeTxHash: Hash; 30 | nonce: number; 31 | to: Address; 32 | operation: number; 33 | proposer: TSigner; 34 | confirmations: TSigner[]; 35 | confirmationsRequired: number; 36 | isExecuted: boolean; 37 | } 38 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Address } from "viem"; 2 | 3 | import type { ListedSafeTx, SafeTx, Signer } from "./safe/index.js"; 4 | 5 | export type EventType = "created" | "updated" | "executed" | "malicious"; 6 | 7 | export interface Event { 8 | chainPrefix: string; 9 | safe: Address; 10 | type: EventType; 11 | tx: SafeTx; 12 | pending: ListedSafeTx[]; 13 | } 14 | 15 | export interface INotificationSender { 16 | notify: (event: Event) => Promise; 17 | } 18 | 19 | export interface INotifier { 20 | send: (event: Event) => void | Promise; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/fetchRetry.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from "./sleep.js"; 2 | 3 | const DEFAULT_RETRIES = 5; 4 | const DEFAULT_RETRY_INTERVAL = 1000; 5 | 6 | interface RetrySettings { 7 | retries?: number; 8 | retryInterval?: number; 9 | validateResponse?: (response: Response) => void; 10 | } 11 | 12 | export async function fetchRetry( 13 | input: string | URL, 14 | init: RequestInit & RetrySettings = {}, 15 | ): Promise { 16 | const { 17 | retries = DEFAULT_RETRIES, 18 | retryInterval = DEFAULT_RETRY_INTERVAL, 19 | validateResponse, 20 | ...options 21 | } = init; 22 | try { 23 | const response = await fetch(input, options); 24 | validateResponse?.(response); 25 | return response; 26 | } catch (e: any) { 27 | if (retries > 0) { 28 | await sleep(retryInterval); 29 | return fetchRetry(input, { 30 | ...options, 31 | retries: retries - 1, 32 | retryInterval, 33 | validateResponse, 34 | }); 35 | } else { 36 | throw e; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./fetchRetry.js"; 2 | export * from "./sleep.js"; 3 | -------------------------------------------------------------------------------- /src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(ms: number) { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, ms); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "lib": ["esnext"], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "strict": true, 12 | "noErrorTruncation": true, 13 | "strictPropertyInitialization": false, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "forceConsistentCasingInFileNames": true 20 | } 21 | } 22 | --------------------------------------------------------------------------------