Skip to content
Open
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
67 changes: 67 additions & 0 deletions .github/workflows/listener-cargo-fmt-clippy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: listener-cargo-fmt-clippy

on:
push:
branches: [main]
pull_request:

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
check-changes:
name: listener-cargo-fmt-clippy/check-changes
runs-on: ubuntu-latest
outputs:
rust-changed: ${{ github.event_name == 'push' || steps.filter.outputs.rust }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
if: github.event_name != 'push'
with:
persist-credentials: 'false'
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
if: github.event_name != 'push'
id: filter
with:
filters: |
rust:
- .github/workflows/listener-cargo-fmt-clippy.yml
- Cargo.toml
- Cargo.lock
- listener/crates/**

fmt-clippy:
name: listener-cargo-fmt-clippy/fmt-clippy
needs: check-changes
if: ${{ needs.check-changes.outputs.rust-changed == 'true' }}
permissions:
contents: 'read'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: 'false'

- uses: dtolnay/rust-toolchain@a54c7afa936fefeb4456b2dd8068152669aa8203

Check warning

Code scanning / zizmor

commit hash does not point to a Git tag Warning

commit hash does not point to a Git tag
with:
toolchain: 1.91.1
components: rustfmt, clippy

- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-clippy-

- run: cargo fmt --check
working-directory: listener

- run: >
cargo clippy --workspace --all-targets
-- -W clippy::perf -W clippy::suspicious -W clippy::style -D warnings
working-directory: listener
61 changes: 61 additions & 0 deletions .github/workflows/listener-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: listener-tests

on:
push:
branches: [main]
pull_request:

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
check-changes:
name: listener-tests/check-changes
runs-on: ubuntu-latest
outputs:
rust-changed: ${{ github.event_name == 'push' || steps.filter.outputs.rust }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
if: github.event_name != 'push'
with:
persist-credentials: 'false'
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
if: github.event_name != 'push'
id: filter
with:
filters: |
rust:
- .github/workflows/listener-tests.yml
- Cargo.toml
- Cargo.lock
- listener/crates/**

tests:
name: listener-tests/tests
needs: check-changes
if: ${{ needs.check-changes.outputs.rust-changed == 'true' }}
permissions:
contents: 'read'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: 'false'

- uses: dtolnay/rust-toolchain@a54c7afa936fefeb4456b2dd8068152669aa8203

Check warning

Code scanning / zizmor

commit hash does not point to a Git tag Warning

commit hash does not point to a Git tag
with:
toolchain: 1.91.1

- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-test-

- run: make test
working-directory: listener
11 changes: 11 additions & 0 deletions listener/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Exclude everything
*

# Allow only what cargo needs to build
!Cargo.toml
!Cargo.lock
!crates/listener_core/
!crates/shared/
!crates/shared/broker/
!crates/consumer/
!crates/test-support/
11 changes: 11 additions & 0 deletions listener/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.vscode
tmp
target
.cursor
Cargo.lock
_erpc
.env

# Helm
charts/*/charts/
charts/*/Chart.lock
33 changes: 33 additions & 0 deletions listener/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[workspace]
resolver = "2"
members = [
"crates/consumer",
"crates/listener_core",
"crates/shared/broker",
"crates/shared/telemetry",
"crates/test-support",
]

[workspace.package]
edition = "2024"

[workspace.dependencies]
alloy = { version = "1.6.1", features = ["full", "trie", "rpc-types", "network", "consensus", "rlp"] }
alloy-json-rpc = "1.6.1"
alloy-primitives = { version = "1", features = ["serde"] }
alloy-transport-ipc = "1.6.1"
async-trait = "0.1.89"
axum = { version = "0.8", default-features = false }
serde = "1.0.228"
serde_json = "1.0.149"
thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["rt-multi-thread", "sync", "time"] }
tokio-util = "0.7"
tracing = "0.1.44"
futures = "0.3.31"
metrics = "0.24"

[profile.release]
opt-level = 3
lto = true
codegen-units = 1
33 changes: 33 additions & 0 deletions listener/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1

# ── Planner (generate dependency recipe) ─────────────────────────────
FROM rust:1.92-slim-bookworm AS planner
RUN cargo install cargo-chef
WORKDIR /app
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# ── Builder (cook deps, then build app) ──────────────────────────────
FROM rust:1.92-slim-bookworm AS builder
RUN apt-get update \
&& apt-get install -y pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/* \
&& cargo install cargo-chef
ENV SQLX_OFFLINE=true
WORKDIR /app

# Cook dependencies (cached unless Cargo.toml/Cargo.lock change)
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json

# Build application
COPY . .
RUN cargo build --release -p listener_core

# ── Runtime ──────────────────────────────────────────────────────────
FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=builder /app/target/release/listener_core /app/listener_core
COPY --from=builder /app/crates/listener_core/migrations/ /app/migrations/
WORKDIR /app
ENTRYPOINT ["/app/listener_core"]
CMD ["--config", "/config.yaml"]
63 changes: 63 additions & 0 deletions listener/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
.PHONY: test test-unit test-e2e test-e2e-amqp test-e2e-redis test-e2e-consumer clean-e2e-containers clippy \
helm-lint

# ── Unit tests (no Docker required) ──────────────────────────────────────────
test-unit:
cargo test --workspace

# ── E2E helpers ───────────────────────────────────────────────────────────────
# Named containers created by the shared-container pattern in e2e tests.
AMQP_CONTAINER := e2e-rabbitmq
REDIS_CONTAINER := e2e-redis

# Remove named e2e containers if they exist (idempotent).
clean-e2e-containers:
@docker rm -f $(AMQP_CONTAINER) 2>/dev/null || true
@docker rm -f $(REDIS_CONTAINER) 2>/dev/null || true

# ── broker (amqp) e2e ────────────────────────────────────────────────────────
# Runs the AMQP e2e suite and removes the shared container afterwards,
# even if the tests fail.
test-e2e-amqp: clean-e2e-containers
cargo test -p broker --features amqp --test amqp_e2e -- --include-ignored; \
$(MAKE) clean-e2e-containers

# ── broker (redis) e2e ───────────────────────────────────────────────────────
# Runs the Redis e2e suite and removes the shared container afterwards,
# even if the tests fail.
test-e2e-redis: clean-e2e-containers
cargo test -p broker --features redis --test redis_e2e -- --include-ignored; \
$(MAKE) clean-e2e-containers

# ── broker facade e2e ────────────────────────────────────────────────────────
# Runs the Broker facade e2e suite (high-level API: Broker → Publisher / ConsumerBuilder)
# and removes the shared container afterwards.
test-e2e-broker: clean-e2e-containers
cargo test -p broker --features redis --test broker_e2e -- --include-ignored; \
$(MAKE) clean-e2e-containers

# ── consumer-lib e2e ─────────────────────────────────────────────────────────
test-e2e-consumer: clean-e2e-containers
cargo test -p consumer --test watch_e2e -- --include-ignored; \
$(MAKE) clean-e2e-containers

# ── Run all e2e tests ─────────────────────────────────────────────────────────
test-e2e: test-e2e-amqp test-e2e-redis test-e2e-broker test-e2e-consumer

# ── Clippy (same flags as CI) ────────────────────────────────────────────────
clippy:
cargo clippy --workspace --all-targets -- -W clippy::perf -W clippy::suspicious -W clippy::style -D warnings

# ── Run everything ────────────────────────────────────────────────────────────
test: test-unit test-e2e

# ── Run Format ───────────────────────────────────────────────────────────────
format:
cargo fmt

# ── Helm Chart Lint ──────────────────────────────────────────────────────────
# Requires: ct (chart-testing), yamllint, helm
helm-lint:
@command -v ct >/dev/null 2>&1 || { echo "ct not found. Install with: brew install chart-testing"; exit 1; }
@command -v yamllint >/dev/null 2>&1 || { echo "yamllint not found. Install with: brew install yamllint"; exit 1; }
ct lint --config ct.yaml --all
111 changes: 111 additions & 0 deletions listener/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Listener

Zero-event-loss blockchain listener for EVM chains.

Polls RPC nodes in parallel, validates block hash-chains to detect reorgs, and
publishes canonical blocks/transactions/receipts to downstream consumers via
Redis Streams or RabbitMQ. Designed for chains with fast block times where
HTTP latency would otherwise cause gaps.

```
RPC Nodes ──► Parallel Fetchers ──► Cursor Validator ──► Broker ──► Consumers
(AsyncSlotBuffer) (hash-chain check) (Redis/AMQP)
```

**Core guarantees:**
- No block, transaction, or receipt is ever skipped — even across crashes.
- Reorgs (simple, back-and-forth, multi-branch) are detected and handled.
- Transient RPC/broker failures retry indefinitely with circuit breaker protection.

## Crates

| Crate | Purpose |
|-------|---------|
| `listener_core` | Main binary. Fetches blocks via HTTP RPC, validates hash-chains, persists metadata to PostgreSQL, publishes events. |
| `broker` | Backend-agnostic message broker. Wraps Redis Streams (consumer groups, XCLAIM, PEL) and RabbitMQ (exchanges, TTL-DLX retry) behind a unified API with retry, dead-letter, and circuit breaker semantics. |
| `primitives` | Shared Ethereum types and routing constants. |

## Quick Start

```bash
# 1. Start local infrastructure (PostgreSQL, Redis, RabbitMQ)
docker compose up -d

# 2. Run tests
make test-unit # unit tests (no Docker services needed)
make test-e2e # e2e tests (spins up testcontainers)
make clippy # lint

# 3. Run the listener
cargo run -p listener_core -- --config config/listener-1.yaml
```

## Local Development

`docker compose up -d` starts PostgreSQL 17, Redis 7, and RabbitMQ 3.

Profiles add optional services:

```bash
docker compose --profile erpc up -d # + eRPC load balancer (:4000)
docker compose --profile monitoring up -d # + Prometheus (:9090) + Grafana (:3000)
docker compose --profile listener-1 up -d # + listener instance (Ethereum mainnet, Redis)
docker compose --profile listener-2 up -d # + listener instance (Base Sepolia, AMQP)
```

Combine profiles freely: `docker compose --profile listener-1 --profile monitoring up -d`

Service endpoints:

| Service | URL | Credentials |
|---------|-----|-------------|
| PostgreSQL | `localhost:5432` | postgres / postgres |
| Redis | `localhost:6379` | — |
| RabbitMQ | `localhost:5672` | user / pass |
| RabbitMQ UI | `localhost:15672` | user / pass |
| eRPC | `localhost:4000` | — |
| Prometheus | `localhost:9090` | — |
| Grafana | `localhost:3000` | admin / admin |

## Configuration

Copy and edit `config/listener-1.yaml`. The three things you must set:

```yaml
name: listener

database:
db_url: postgres://postgres:postgres@listener-postgres:5432/listener
migration_max_attempts: 5
pool:
max_connections: 10
min_connections: 2

broker:
broker_type: redis
broker_url: redis://listener-redis:6379

blockchain:
type: evm
chain_id: 1
rpc_url: http://listener-erpc:4000/listener-indexer/evm/1
network: ethereum-mainnet
strategy:
automatic_startup: true
block_start_on_first_start: 24572795
range_size: 10
loop_delay_ms: 1000
max_parallel_requests: 10
block_fetcher: block_receipts
batch_receipts_size_range: 10
compute_block: false
```

## Docker Build

Multi-stage distroless image. Database migrations are bundled.

```bash
docker build -t listener .
docker compose --profile listener-1 up
```
Loading
Loading