Multi-chain, flash-loan-backed liquidation bot — written in Rust.
Charon monitors under-collateralized positions across major DeFi lending protocols and executes profitable liquidations using flash loans — zero upfront capital, zero position risk. If a liquidation turns out to be unprofitable at execution time, the entire transaction reverts atomically; the only cost is a failed simulation's gas.
Named after the mythological ferryman. Charon carries underwater positions to their final destination.
- Status
- How it works
- Key features
- Safety model
- Getting started
- Configuration
- Metrics
- Project structure
- Roadmap
- Contributing
- License
v0.1 — work in progress. The foundation is in place: Cargo workspace, normalized types, the LendingProtocol trait, a TOML config loader with env-var substitution, and a runnable CLI that validates and reports on the loaded config. Chain listening, health scanning, and on-chain execution arrive in the next phases of the build.
Current scope: Venus Protocol on BNB Chain. Other protocols and chains are on the roadmap.
⚠️ Do not run this against mainnet with real funds yet. Nothing has been battle-tested. Treat v0.1 as development-only.
flowchart LR
BNB[BNB Chain<br/>WebSocket]:::chain
subgraph Core["Charon Core — Rust"]
direction TB
L[Chain Listener] --> A[Venus Adapter]
A --> S[Health Scanner]
P[Price Engine<br/>Chainlink + TWAP] --> S
S --> Pr[Profit Calculator]
Pr --> R[Flash Loan Router]
R --> B[Tx Builder]
end
subgraph OnChain["On-chain — Solidity"]
direction TB
Liq[CharonLiquidator.sol]
F[Flash Loan Source<br/>Aave V3 Pool]
D[DEX Swap<br/>PancakeSwap / Uniswap V3]
V[Venus Protocol]
Liq --> F
Liq --> V
Liq --> D
end
BNB --> L
B --> Liq
classDef chain fill:#0f1e36,stroke:#3b82f6,color:#e2e8f0
- Listen — A WebSocket listener receives new blocks and log events from the chain.
- Decode — Protocol adapters normalize raw events into a shared
Positionstruct — the rest of the pipeline doesn't care whether the source is Venus, Aave, or anything else. - Price — A price engine reads live USD prices from Chainlink, with Uniswap V3 TWAPs as a fallback when Chainlink is unavailable or stale.
- Scan — The health scanner recomputes health factors and flags any position that drops below
1.0. - Estimate — The profit calculator simulates the full liquidation end-to-end (gas + flash-loan fee + expected DEX slippage) and drops anything below a per-chain USD threshold.
- Route — The flash-loan router picks the cheapest available source (Balancer 0 % → Aave V3 0.05 % → Uniswap V3 pool fee).
- Build — The transaction builder encodes the call, dry-runs it via
eth_call, signs, and submits (via Flashbots on Ethereum, private RPC on L2s). - Execute — On-chain,
CharonLiquidator.solatomically: flash-borrows → calls the protocol's liquidation entry point → swaps seized collateral back to the debt token → repays the flash loan → forwards profit to the bot's hot wallet. If any step fails, the entire transaction reverts.
- Zero capital required. Every liquidation is flash-loan-backed. No pre-funded position, no locked inventory.
- Protocol-agnostic. Adding a new lending protocol means implementing a single Rust trait (
LendingProtocol). No changes to scanning, routing, or execution. - Multi-chain by design. A single binary monitors multiple EVM chains in parallel. v0.1 ships BSC; v0.3 expands to Ethereum, Arbitrum, Polygon, Base, and Avalanche.
- Rust performance.
tokioasync runtime, lock-free concurrent state viaDashMap, sub-50 ms block-to-broadcast latency target. Designed to run comfortably on a $5 VPS. - Flash-loan atomicity. Bad slippage, race conditions, and math errors all revert the transaction — the protocol never loses its liquidity, and the bot never loses capital.
- Open source, MIT licensed. Community extensions welcome.
Every liquidation has the atomic form:
borrow (flash) → liquidate → swap → repay flash → profit
If the chain of operations cannot repay the flash loan in full, the EVM reverts the entire transaction — including the flash borrow itself. Concretely:
| Failure mode | Outcome |
|---|---|
| Profit estimate was wrong | Tx reverts, flash source gets its capital back, bot pays only gas |
| DEX swap slippage exceeds slippage guard | Tx reverts atomically — no capital change |
| Another bot won the race | eth_call simulation catches 99 %+ before submit, so no gas spent |
| Oracle update mid-transaction pushes health back ≥ 1.0 | Tx reverts on the liquidation call |
Worst case: gas for a single failed transaction (typically $0.01–$5 depending on chain). Best case: profit lands in the bot's hot wallet. No intermediate case where bot capital is lost — this is the fundamental guarantee of flash-loan design.
- Rust — edition 2024 /
rustc 1.85+. Install via rustup.rs. - A BNB Chain RPC endpoint —
.env.exampleships with public-node defaults (good for light testing, rate-limited in production). For real use, point it at a private endpoint (QuickNode, Ankr, Blast, or your own node).
# via HTTPS
git clone https://github.com/obchain/Charon.git
# or via SSH
git clone git@github.com:obchain/Charon.git
cd Charon
cargo buildcp .env.example .env
# edit .env with your RPC endpointscargo run -- --config config/default.toml listenExpected output (v0.1 — scanner not wired up yet):
INFO charon: charon starting up
INFO charon: loading config path=config/default.toml
INFO charon: config loaded chains=1 protocols=1 flashloan_sources=1 liquidators=1 min_profit_usd=5
INFO charon: listen: not wired up yet — scanner arrives in Day 2
For verbose logs, prepend RUST_LOG=debug.
Tagged releases are published to GitHub Container Registry as ghcr.io/obchain/charon:
docker pull ghcr.io/obchain/charon:v0.1.0
docker run --rm \
--env-file .env \
-v "$PWD/config:/app/config:ro" \
ghcr.io/obchain/charon:v0.1.0 \
--config /app/config/default.toml listenTag schema: vMAJOR.MINOR.PATCH, MAJOR.MINOR.PATCH, MAJOR.MINOR, and latest. Each release page lists the published sha256 digest — pin to it in production.
For a full local stack (Charon + Alloy → Grafana Cloud), use the compose recipe in deploy/compose/ — see Deploy.
Charon reads a TOML config file (default path: config/default.toml). Secrets — RPC URLs, keys, API tokens — are referenced as ${ENV_VAR} placeholders and substituted from the process environment (or a local .env file) at load time.
Example (abridged):
[bot]
min_profit_usd = 5.0 # drop opportunities below this threshold
max_gas_gwei = 10 # skip when gas spikes beyond this
scan_interval_ms = 1000 # polling cadence (ms)
[chain.bnb]
chain_id = 56
ws_url = "${BNB_WS_URL}"
http_url = "${BNB_HTTP_URL}"
[protocol.venus]
chain = "bnb"
comptroller = "0xfd36e2c2a6789db23113685031d7f16329158384"
[flashloan.aave_v3_bsc]
chain = "bnb"
pool = "0x6807dc923806fe8fd134338eabca509979a7e0cb"Environment variables expected by the default config:
| Variable | Purpose |
|---|---|
BNB_WS_URL |
BNB Chain WebSocket RPC endpoint |
BNB_HTTP_URL |
BNB Chain HTTPS RPC endpoint (for multicall) |
Three TOML profiles ship in config/. Pick one with --config.
| Profile | File | When to use |
|---|---|---|
| Mainnet | config/default.toml |
Production runs against BSC mainnet (real capital). |
| Testnet | config/testnet.toml |
Venus on BSC testnet (Chapel, chainId 97) — no Aave V3 on Chapel, runs read-only. |
| Local anvil fork | config/fork.toml |
Full end-to-end against a local anvil fork of BSC mainnet. Zero capital risk. |
Fork BSC mainnet locally. Real Venus state, real Aave V3, real PancakeSwap — liquidate real positions against a private chain.
Terminal A — boot the fork:
./scripts/anvil_fork.sh # forks latest block via dRPC, falls back to PublicNode
FORK_BLOCK=41000000 ./scripts/anvil_fork.sh # pin a specific block
CHARON_ANVIL_PORT=8546 ./scripts/anvil_fork.sh # run on a non-default portTerminal B — run Charon against it:
cargo run -- --config config/fork.toml listenThe fork profile carries profile_tag = "fork"; Config::validate rejects it at startup if any chain's ws_url / http_url resolves to a non-loopback host. This keeps the intentionally lowered profit gate from ever pointing at mainnet by accident.
The fork profile omits [liquidator.bnb] by default — after forge create against the local anvil, add a [liquidator.bnb] section pointing at the deployed address to exercise the full liquidation path. Until then the CLI runs in read-only mode (scanner + metrics only).
Every profile ships with a Prometheus exporter enabled. Scrape http://<host>:9091/metrics. The exporter binds :9091 (not :9090) so it doesn't collide with a co-located Prometheus server.
Key series (single source of truth in crates/charon-metrics/src/lib.rs — the names module is what dashboards and alert rules must match):
| Metric | Type | Labels |
|---|---|---|
charon_scanner_blocks_total |
counter | chain |
charon_scanner_positions |
gauge | chain, bucket |
charon_pipeline_block_duration_seconds |
histogram | chain |
charon_executor_simulations_total |
counter | chain, result |
charon_executor_opportunities_queued_total |
counter | chain |
charon_executor_opportunities_dropped_total |
counter | chain, stage |
charon_executor_profit_usd_cents |
histogram | chain |
charon_executor_queue_depth |
gauge | — |
A ready-to-import dashboard lives at deploy/grafana/charon.json and a matching alert-rule bundle at deploy/grafana/alerts.yaml. The dashboard is built against Grafana 10.4.x or newer (panel schema v39 and Grafana Cloud both satisfy this); older 9.x installs will reject the import or silently drop panels.
Security — read before exposing
:9091. The metrics endpoint ships unauthenticated and binds0.0.0.0by default. On a public VPS (Hetzner CX22, the documented target) that exposes profit histograms, build SHA, queue depth, and simulation results to the internet. Before scraping from a remote Prometheus, either bind the exporter to127.0.0.1and scrape over a local socket / SSH tunnel / Tailscale, or put a reverse proxy with basic auth (or mTLS) in front of:9091. See tracking issues #213 and #214.
Three steps to load it into Grafana or Grafana Cloud:
- Add a Prometheus data source that scrapes
http://<charon-host>:9091/metrics(every ~10 s is fine). Use a loopback address, a VPN endpoint, or an authenticated reverse-proxy URL here — never a raw public-internet address. - In Grafana, Dashboards → New → Import → Upload JSON file and pick the file above.
- On the import screen, select the Prometheus data source you created and click Import.
Dashboard UID is charon-v0 and tags are charon, liquidation, defi — re-importing over an existing copy replaces it rather than duplicating. Variables (Chain, Instance) auto-populate from label values once metrics start flowing.
Alert rules in deploy/grafana/alerts.yaml can be loaded by Prometheus via rule_files: or by Grafana unified alerting (Alerting → Contact points → Rules → Upload file). Thresholds are tuned for a single-host BSC deployment on a 3s block cadence — adjust per-environment before wiring a pager.
A minimal docker compose stack ships in deploy/compose/. It runs two services:
charon— built from the repo-rootDockerfile(multi-stage:rust:1-slimbuilder →debian:bookworm-slimruntime, ~150 MB final image)alloy— Grafana Alloy sidecar that scrapescharon:9091over the internal compose network andremote_writes every series to Grafana Cloud
No local Prometheus or Grafana is deployed — the Grafana Cloud free tier is the visualisation surface, which fits the CX22 resource envelope (2 vCPU / 4 GB RAM) comfortably.
cd deploy/compose
cp .env.example .env # fill in RPC + Grafana Cloud creds
docker compose up -d --build
docker compose logs -f charonThe metrics endpoint is not exposed to the host — Alloy reaches it by DNS name. Import deploy/grafana/charon.json into Grafana Cloud and the panels populate automatically once Alloy's first push lands.
Charon/
├── crates/
│ ├── charon-core/ # Shared types, LendingProtocol trait, config loader
│ ├── charon-scanner/ # Chain listener + health-factor scanner (v0.1 WIP)
│ └── charon-cli/ # Command-line binary (`charon`)
├── config/
│ └── default.toml # Default configuration (Venus on BNB)
├── .env.example # Environment variable template
└── Cargo.toml # Workspace root + shared dependency versions
Crates planned for later phases:
charon-protocols/— per-protocol adapters (Venus, Aave V3, Compound V3, Morpho, …)charon-flashloan/— flash-loan source routercharon-executor/— transaction builder, batcher, gas strategy, nonce managercharon-telemetry/— Prometheus metrics + Telegram alertingcontracts/— Foundry workspace housingCharonLiquidator.sol
Tracked on GitHub: obchain/Charon › Milestones.
- Cargo workspace + three-crate skeleton
- Core types (
Position,LiquidationOpportunity,FlashLoanSource,SwapRoute, …) -
LendingProtocoltrait - TOML config loader with env-var substitution
- CLI with
--configflag andlistensubcommand - Venus adapter — fetch positions, compute liquidation params
- Chainlink price engine (with Uniswap V3 TWAP fallback)
- Health-factor scanner + near-liquidation cache
-
CharonLiquidator.sol(Foundry) + deploy script - Flash-loan router (Aave V3 on BSC)
- DEX swap optimizer (PancakeSwap / Uniswap V3)
- Transaction builder with
eth_callsimulation - Prometheus metrics + Telegram alerts
- Docker Compose deployment
- Aave V3 adapter
- Compound V3 adapter
- Morpho Blue adapter
- Protocol-specific close-factor handling
- Ethereum Mainnet (with Flashbots bundle submission)
- Arbitrum One
- Polygon PoS
- Base
- Avalanche C-Chain
Contributions are welcome. A few ground rules:
- Open an issue first for non-trivial changes, so the design can be discussed before code is written.
- One logical change per PR. Keep commits focused and follow conventional titles (
feat(core):,fix(scanner):,chore:, etc.). - Respect the crate boundaries. Protocol changes live in
charon-protocols/, execution changes incharon-executor/. Shared types belong incharon-core. - No secrets in the repo — ever.
.envis git-ignored. Keep it that way.
New to the codebase? Check issues tagged good first issue.
MIT — see LICENSE.