Tooling for the Morpho Midnight market — an EVM event indexer and liquidation bot that tracks on-chain market and position state and acts on liquidatable positions.
The system is split into two independently-deployable services that communicate over a RabbitMQ topic exchange:
-
Indexer — polls an EVM chain for logs emitted by the Midnight contract, filters them to known event signatures, persists the raw logs to Postgres, and publishes each decoded event to a RabbitMQ topic exchange (one routing key per event type, e.g.
event.repay,event.liquidate). -
Liquidator — declares a durable queue bound to the exchange and consumes the decoded event stream, folding it into an in-memory projection of markets and positions (
market → user → position) while persisting the state to themarkets,collateral_params,positions, anduser_collateralstables. On startup it loads the state, then continues consuming new events off the queue, scans for liquidatable positions, and submitsliquidatetransactions on-chain with its own signer. Each submission is recorded in theliquidation_transactionstable (tx hash, sender, nonce, repaid units,pending/confirmed/revertedstatus) so in-flight positions are excluded from re-selection.
The raw events table plus per-row history tables (history_markets, history_positions) provide durability and reorg recovery: every state-row update copies the prior version into history via a trigger, so on a chain reorg the state can be rolled back to a known block and the new fork's events re-applied. RabbitMQ carries the live delta and fans it out.
flowchart LR
chain[(EVM chain<br/>Anvil / RPC)]
subgraph indexer[Indexer service]
idx[poll · filter · decode]
end
subgraph broker[RabbitMQ]
ex{{topic exchange<br/>morpho.events}}
q[[queue<br/>liquidator.events]]
end
subgraph liquidator[Liquidator service]
liq[apply · scan for<br/>liquidatable positions]
end
db[(Postgres<br/>events · block_headers · indexer_status<br/>markets · positions · collateral_params · user_collaterals<br/>history_* · liquidator_status · liquidation_transactions)]
chain -->|get_logs / get_block| idx
idx -->|persist raw logs| db
idx -->|publish event.*| ex
ex -->|bind event.#| q
q -->|consume| liq
liq -->|persist reduced state| db
db -.->|hydrate on startup| liq
liq -->|liquidate tx| chain
liq -->|record tx status| db
ex -.->|future: event.liquidate| future[Other subscribers]
A Cargo workspace with a shared common crate and two service binaries:
.
├── docker-compose.yml # Postgres + RabbitMQ + Anvil + services
├── anvil/ # Dockerfile for a local Anvil EVM node
├── Cargo.toml # [workspace] + shared dependency versions
├── migrations/ # sqlx SQL migrations (events, positions, markets, …)
└── crates/
├── common/ # shared crate, no service-specific logic
│ └── src/
│ ├── contracts/ # sol! event types + decoding + EventEnvelope (wire format)
│ ├── messaging.rs # AmqpConfig + RabbitPublisher / RabbitSubscriber
│ ├── db.rs # Postgres/sqlx Store trait + impl
│ ├── rpc.rs # alloy ChainReader trait + impl
│ └── price.rs # PriceProvider trait + math
├── services/
│ ├── indexer/ # binary: poll chain, persist, publish
│ │ └── src/{main,indexer,configuration}.rs
│ └── liquidator/ # binary: consume, fold state, persist, scan
│ └── src/{main,liquidator,protocol_state,market,position,selector,price,math,configuration}.rs
│ └── db/ # LiquidatorDb: state persistence, hydration, reorg rollback
└── test_utils/ # mock chain / store / publisher / price for unit tests
- Rust (Cargo workspace, two binaries + shared crate)
- alloy for EVM types, RPC, and
sol!event decoding - amqprs for the RabbitMQ (AMQP 0-9-1) publisher/subscriber
- sqlx with Postgres (compile-time-checked queries)
- tracing for structured logging (rolling file + stdout)
- Anvil (Foundry) for a local dev chain
docker compose up rabbitmq anvil postgresanvil deploys Midnight and runs the liquidation-opportunity loop (see anvil/); its deploy grants the liquidator (anvil account 1) a one-time loan-token approval.
Each service reads configuration from the environment. The two services use separate variable sets (the liquidator prefixes its own with LIQUIDATOR_); the AMQP_* set is shared. See indexer/configuration.rs and liquidator/configuration.rs. Create a .env at the repo root:
Indexer
| Variable | Description |
|---|---|
DATABASE_URL |
Postgres connection string |
RPC_URL |
EVM JSON-RPC endpoint |
CONTRACT_ADDRESS |
Midnight contract address to index |
START_BLOCK |
Block to begin indexing from |
BLOCK_RANGE |
Max blocks per get_logs batch |
INTERVAL |
Seconds between indexer polls |
LOG_FILE |
Log filename (written under logs/) |
LOG_LEVEL |
trace/debug/info/warn/error (default info) |
Liquidator
| Variable | Description |
|---|---|
LIQUIDATOR_DATABASE_URL |
Postgres connection string |
LIQUIDATOR_SUPPORTED_CHAINIDS |
Comma-separated chain IDs to service (e.g. 31337) |
LIQUIDATOR_RPC_URLS |
Comma-separated chainId=url map, one entry per supported chain (e.g. 31337=http://localhost:48545) |
LIQUIDATOR_PRIVATE_KEY |
Signer key that submits liquidate txs (anvil account 1, the liquidator) |
LIQUIDATOR_LOG_FILE |
Log filename (written under logs/) |
LIQUIDATOR_LOG_LEVEL |
trace/debug/info/warn/error (default info) |
AMQP (shared) — indexer publishes, liquidator consumes; queue/binding/prefetch are liquidator-only.
| Variable | Description |
|---|---|
AMQP_HOST |
RabbitMQ hostname — bare host, no http:// or port (e.g. localhost) |
AMQP_PORT |
AMQP port (default 5672; use the docker-compose-mapped port for local) |
AMQP_USER |
RabbitMQ user (default guest) |
AMQP_PASS |
RabbitMQ password (default guest) |
AMQP_EXCHANGE |
Topic exchange name (default morpho.events) |
AMQP_QUEUE |
Durable queue name, liquidator (e.g. liquidator.events) |
AMQP_BINDING_KEY |
Routing-key pattern, liquidator (default events.<chainId>.#) |
AMQP_PREFETCH |
Consumer QoS prefetch for back-pressure, liquidator (default 32) |
In a second terminal (from the repo root), apply migrations and start the indexer:
sqlx migrate run # applies migrations/ against DATABASE_URL
cargo run --bin indexer # publisherIn a third terminal, start the liquidator:
cargo run --bin liquidator # subscriber + on-chain executorThe indexer logs rabbitmq publisher ready; the liquidator logs rabbitmq subscriber ready after declaring and binding its queue.
Per-service .env.sample files live next to each binary:
indexer/.env.sample and
liquidator/.env.sample.
Each service writes structured logs to stdout and to a daily-rotated file under
logs/. The filename comes from that service's LOG_FILE, and the on-disk file is
suffixed with the date: logs/<LOG_FILE>.YYYY-MM-DD.
To keep the two services in separate log files, give each a distinct LOG_FILE
(the samples already do this):
# indexer .env
LOG_FILE=indexer.log # -> logs/indexer.log.YYYY-MM-DD
# liquidator .env
LOG_FILE=liquidator.log # -> logs/liquidator.log.YYYY-MM-DD# from the repo root
cargo build --workspace
cargo test --workspace # DB-backed tests need DATABASE_URL setSee LICENSE.