safe-events-service consumes Safe indexing events from the Transaction Service (via RabbitMQ) and delivers them as HTTP webhooks.
- Team: Platform
- Repository:
safe-global/safe-events-service
- Create issues under team Platform
- Tags:
Backend,Events Service
- Node 24 (
>=24.0.0), pnpm 10 via corepack. Install:corepack enable && pnpm install --frozen-lockfile. - Build:
pnpm build(nest build). Run:pnpm start/pnpm start:dev(watch). - Lint:
pnpm lint(eslint--fix). Format:pnpm format(prettier). - Tests need RabbitMQ + Postgres up and
webmust NOT be running (it would consume the queue messages the tests assert on).- One-shot:
bash ./scripts/run_tests.sh(brings uprabbitmq db db-migrations, runs the suite). - Manual:
docker compose up -d rabbitmq db db-migrationsthenpnpm test(unit) /pnpm test:e2e(e2e config attest/jest-e2e.json). - Single test:
pnpm test -- src/modules/webhook/webhookDispatcher.service.spec.tsorpnpm test -- -t "partial test name".
- One-shot:
- Tests run under
NODE_OPTIONS=--experimental-vm-modules(ESM deps like AdminJS) — keep that when invoking jest directly.
The unit-test DB uses synchronize (no migrations); the dockerized migrations DB is the one migrations target. After adding/changing a TypeORM entity, register it in src/datasources/db/database.options.ts, then generate:
bash ./scripts/db_generate_migrations.sh MIGRATION_NAMEThe whole service is one NestJS app (src/app.module.ts). The core data flow lives in three modules:
datasources/queue—QueueProviderconnects to RabbitMQ viaamqp-connection-manager, asserts a fanout exchange + durable queue, setsprefetch(AMQP_PREFETCH_MESSAGES), and exposessubscribeToEvents(handler). The consumer awaits the handler, thenacks (manual ack, in afinally).modules/events—EventsService.processEvent()is the handler: it parses/validates the JSONTxServiceEvent, pushes it into an in-memory RxJSSubject(for the SSE endpoint), and returnswebhookDispatcher.postEveryWebhook(event). Because the consumer awaits this, a message is only acked after every matching webhook for that event has settled.modules/webhook—WebhookDispatcherServiceholds an in-memoryMapof active webhooks, refreshed every minute (@Cron).postEveryWebhookiterates the cached webhooks, filters byWebhook.isEventRelevant(per-chainId+ per-event-type flags likesendMultisigTxs,sendEtherTransfers, … stored on the entity), and fires the matches in parallel viaPromise.all. HTTP is done with undiciRetryAgent(the agent is provided via theUNDICI_AGENTDI token inwebhook.module.ts).
Key behaviors worth knowing before changing the dispatch path:
- Retries are awaited inline.
RetryAgentretries (HTTP_MAX_RETRIES, default 2) with backoff inside the singleagent.requestawait, so a slow/failing target holds its prefetch slot for the whole retry chain —(retries+1) × HTTP_TIMEOUT + backoff— and blocks that message's ack. This couples one slow URL to overall consumer throughput (head-of-line blocking). Retryable set is an explicit allow-list of status codes (5xx) and undici error codes inwebhook.module.ts— note undici's HTTP/2 stream-timeout error (UND_ERR_INFO) is not retryable. - undici negotiates HTTP/2 by default (undici 8 sets
allowH2true at the connector); targets that advertiseh2via ALPN get HTTP/2 even though the code never sets it. - Webhook health / auto-disable (
checkWebhooksHealth, also on the minute cron): tracks per-webhook success/failure stats overWEBHOOK_HEALTH_MINUTES_WINDOW; if failure rate exceedsWEBHOOK_FAILURE_THRESHOLDit disables the webhook only whenWEBHOOK_AUTO_DISABLE=true(off by default — otherwise it just logs a warning). - Response bodies are read with a byte cap (
WEBHOOK_MAX_RESPONSE_BYTES) — they're only used for logging.
/health(Terminus),/events/sse/{CHECKSUMMED_SAFE_ADDRESS}(server-sent events, filtered from the RxJS subject; gated bySSE_AUTH_TOKENBasic auth when set), and/admin(AdminJS panel over the DB models, ESM-only, mounted viamodules/admin/adminjs.ts).- AdminJS sits behind proxy middleware (
middleware/admin-proxy.middleware.ts,reverse-proxy.middleware.ts) that rewrites asset paths so the panel works underURL_BASE_PATH. AdminJS assets live under a.pnpmdotfile dir, so static serving must allow dotfiles (dotfiles: 'allow') or the assets 404 under pnpm.
- Config is env-only via
@nestjs/config; the full list with defaults is the table inREADME.md. Read config throughConfigService.get(KEY, default)rather than hard-coding. - Event payloads are deliberately minimal (a signal to re-query the Transaction Service, not a source of truth) and events are sent without waiting for block confirmation (no reorg-revert notifications). Don't add fields to event DTOs to "save a query" — that's an explicit product decision.
- Licensing cut-over: code up to 2026-02-16 is MIT (
sef-mit-finaltag); newer code is FSL-1.1. SeeLICENSE/NOTICE.