Skip to content

Repository files navigation

cluby-keeper

The watchdog that can only subtract.
It liquidates positions already underwater, compares every oracle against live trading, and wakes a human. It cannot do anything else.

Node TypeScript Powers License


What it can and cannot do

This is the part worth reading if you are deciding whether to trust the protocol.

flowchart LR
    subgraph CAN["✅ What the keeper can do"]
        C1["Call the liquidator on a position<br/>already underwater by the<br/>market's own arithmetic"]
        C2["Read every oracle and pool"]
        C3["Write borrower scores"]
        C4["Send a Telegram alert"]
    end
    subgraph CANNOT["❌ What it cannot do"]
        D1["Set a cap"]
        D2["Move vault liquidity"]
        D3["Open a position"]
        D4["Change any parameter"]
        D5["Touch anyone's funds"]
    end

    style CAN fill:#03926B,color:#fff
    style CANNOT fill:#3a1414,color:#fff
Loading

It is not an allocator on the vault. Every power it has reduces exposure. Withdrawing a cap is a decision the Safe makes, in public, behind a 24-hour timelock.

The tempting design is a keeper that pulls liquidity automatically at 3am and tells you in the morning. That is also a key on a server with the authority to drain a vault into a market of its choosing. We would rather be woken up.

Without KEEPER_PK it still runs — watching and alerting, signing nothing.


The two passes

flowchart TB
    START(("every POLL_MS")) --> RB["refreshBorrowers()<br/><i>from indexer + chain events</i>"]
    RB --> SH["scanHealth()<br/><i>Lens, pending interest applied</i>"]
    SH --> Q{"HF < 1?"}
    Q -->|no| WARN["warnIfClose()<br/><i>alert near the edge</i>"]
    Q -->|yes| SIM["simulate liquidation"]
    SIM --> PROF{"profit ≥<br/>MIN_PROFIT_USD?"}
    PROF -->|no| SKIP["log and skip —<br/>someone else will take it"]
    PROF -->|yes| EXEC["FlashLiquidator.liquidate()"]

    START2(("every WATCHDOG_MS")) --> GAS["checkGas()"]
    GAS --> LOOP["for each market:<br/>oracle.price() vs pool TWAP"]
    LOOP --> DIV{"divergence ><br/>DIVERGENCE_BPS?"}
    DIV -->|yes| ALERT["alert a human —<br/><b>does not act</b>"]

    style EXEC fill:#03926B,color:#fff
    style ALERT fill:#8a6d1f,color:#fff
Loading

Divergence sits between 0 and 112 basis points per market in normal operation.


Three things learned by being paged at 4am

A transport failure is not a broken market

The first version wrote .catch(() => null) around every read. That erased the difference between the RPC is down and this market is broken — and then alerted per market. One flaky endpoint produced 96 Telegram messages in an hour, none of which said what was actually wrong.

export function isTransportFailure(e: unknown): boolean {
  const text = String(e?.shortMessage ?? e?.message ?? e);
  return /HTTP request failed|RPC Request failed|fetch failed|timed out|ETIMEDOUT|ECONNRESET|ENOTFOUND|socket hang up|502|503|504|429/i.test(text);
}

Transport failures are now collapsed into one alert at the end of the pass. Contract errors stay attributed to the market that raised them.

An alert that never says "it's fine again" trains you to ignore it

recovered() speaks only if a matching alert actually went out. No noise on a clean start, and no silent recovery after a real one.

A quiet keeper and a wedged keeper produce the same log — nothing

alive: 47 borrower(s) watched, 3 with debt, worst HF 1.8412

One heartbeat line every five minutes, carrying the two numbers an operator would otherwise have to go and look up.


Running it

pnpm install
RPC_URL=<rpc> pnpm start           # watch + liquidate
RPC_URL=<rpc> pnpm watch-only      # no KEEPER_PK: watch + alert, sign nothing
Variable Required What it does
RPC_URL ✅ Primary node
RPC_URL_FALLBACK Secondary, used automatically when the primary fails
KEEPER_PK Without it the keeper never signs
LENS_ADDR / FLASH_LIQ_ADDR Override the deployed addresses
PONDER_URL Indexer, for the borrower set. Optional — falls back to chain events
TELEGRAM_TOKEN / TELEGRAM_CHAT Where alerts go
DIVERGENCE_BPS Watchdog tolerance, default 150
MIN_PROFIT_USD Below this it does not bother
POLL_MS / WATCHDOG_MS / HEARTBEAT_MS Cadence

A fallback() transport is wired in env.ts: the free public node is tried first and the paid archive is the backup, which is the right order when the reads are near the chain tip.

Deployment units are in clubytech/cluby under ops/systemd/.


Layout

src/index.ts       the two passes and the heartbeat
src/liquidate.ts   scanHealth, tryLiquidate, warnIfClose
src/watchdog.ts    oracle vs pool, per market, collapsed transport errors
src/borrowers.ts   who to watch — indexer, with a chain-event fallback
src/alerts.ts      Telegram, isTransportFailure, recovered
src/scores.ts      writes borrower scores to the CreditRegistry
src/revert.ts      decodes a revert into something a human can read
src/env.ts         config, clients, the fallback transport
src/facts.ts       protocol constants for alert text
vendor/            @cluby/config, @cluby/abi, @cluby/sdk

Part of Cluby

Repository What it holds
cluby-keeper ← you are here
cluby-liquidator The contract this calls
cluby-oracles What the watchdog compares against trading
cluby-lens Where the health factors come from
cluby-incentives Where the scores are written
cluby-sdk Typed reads shared with the site

cluby.cash · @ClubyTech

About

Liquidation and oracle-divergence watchdog. Every power it has reduces exposure

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages