diff --git a/config/default.toml b/config/default.toml index c0e821c..5dda169 100644 --- a/config/default.toml +++ b/config/default.toml @@ -6,7 +6,15 @@ [bot] # Drop opportunities below this USD profit threshold, in USD × 1e6. # 5_000_000 = $5.00. Integer fixed-point to avoid f64 precision. -min_profit_usd_1e6 = 5000000 +# Set to 0 to disable the absolute-USD floor — the margin gate +# (`min_profit_margin_bps`) below is the primary drop reason now. +min_profit_usd_1e6 = 0 +# Required margin on TOTAL COST (flash fee + gas + slippage), in bps. +# 1_000 = 10% margin: the swap output must return at least +# 1.10 × total_cost. If total cost is 100 units of debt token, the +# pipeline rejects anything that would return less than 110. +# Set to 0 to disable the margin gate (USD floor still applies). +min_profit_margin_bps = 1000 # Skip liquidations when gas price exceeds this, in wei (decimal string). # "3000000000" = 3 gwei. Sub-gwei priority fees are representable. max_gas_wei = "3000000000" @@ -62,13 +70,76 @@ chunk_max_attempts = 4 chunk_initial_backoff_ms = 500 chunk_max_backoff_ms = 5000 +# Ethereum mainnet — added via the multi-chain abstraction (#…). The +# bot's runtime pipeline is still Venus-on-BSC by default; declaring +# the chain here lets the per-protocol scanner loop pick up Aave V3 on +# Ethereum once the CLI iterates `[protocol.*]` rather than the +# hardcoded `[protocol.venus]` lookup. Until then the section is +# parsed and validated but inert at runtime. +[chain.eth] +chain_id = 1 +ws_url = "${ETH_WS_URL}" +http_url = "${ETH_HTTP_URL}" +# Mainnet base fee floor + a small tip; raise during congestion. +priority_fee_gwei = 1 +# Same private-mempool gate as BSC: every chain must carry a +# `private_rpc_url` or explicitly opt in to the public mempool. +# Flashbots / MEV-Share is the canonical choice here. +private_rpc_url = "${CHARON_ETH_PRIVATE_RPC_URL}" +private_rpc_auth = "${CHARON_ETH_PRIVATE_RPC_AUTH}" +allow_public_mempool = false + +[chain.eth.discovery] +# 7 days @ ~12s blocks ≈ 50_400 blocks. Free-tier RPCs cap chunks +# tighter than BSC; widen on a paid archive endpoint. +backfill_blocks = 50000 +log_chunk_blocks = 5000 +inter_chunk_pacing_ms = 50 +chunk_max_attempts = 4 +chunk_initial_backoff_ms = 500 +chunk_max_backoff_ms = 5000 + # ── Lending protocols ───────────────────────────────────────────────────── [protocol.venus] chain = "bnb" +# Compound-family adapter family. `kind` defaults to "venus" so this +# field is optional; declared explicitly here for symmetry with the +# Aave V3 entry below. +kind = "venus" # Venus Unitroller (main comptroller on BSC) comptroller = "0xfd36e2c2a6789db23113685031d7f16329158384" +# Aave V3 on Ethereum mainnet. Wired in via the new `kind = "aave_v3"` +# discriminator on `[protocol.*]` (see `ProtocolKind` in +# charon-core::config). Addresses from Aave's address book — verify at +# before depending on +# this for live broadcasts. +[protocol.aave_v3_eth] +chain = "eth" +kind = "aave_v3" +# Aave V3 Pool on Ethereum mainnet (entry point for liquidationCall). +pool = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" +# Optional: IPoolAddressesProvider — when set the CLI will compare +# `getPool() == pool` at startup, mirroring the existing flash-loan +# section's belt-and-braces gate (#367). Mainnet provider: +pool_addresses_provider = "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e" +# AaveOracle — USD-1e8 prices for every reserve asset. +oracle = "0x54586bE62E3c3580375aE3723C145253060Ca0C2" +# PoolDataProvider — per-asset reserve config (decimals, liquidation +# bonus) and per-user reserve breakdown. v3.1 deployment: +data_provider = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3" + # ── Flash-loan sources ──────────────────────────────────────────────────── +[flashloan.aave_v3_eth] +chain = "eth" +# Aave V3 Pool on Ethereum mainnet (the same Pool address used by the +# `[protocol.aave_v3_eth]` section above — Aave V3 lets borrowers +# liquidate via the same contract that exposes flashLoanSimple, so +# the flash-loan source and the lending protocol share an address). +pool = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" +data_provider = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3" +addresses_provider = "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e" + [flashloan.aave_v3_bsc] chain = "bnb" # Aave V3 Pool on BSC (used for flashLoanSimple — 0.05% fee) @@ -132,6 +203,20 @@ ETH = "0x9ef1B8c0E4F7dc8bF5719Ea496883DC6401d5b2e" USDT = "0xB97Ad0E74fa7d920791E90258A6E2085088b4320" USDC = "0x51597f405303C4377E36123cBc172b13269EA163" +# Ethereum mainnet Chainlink feeds. Symbols are matched against the +# token symbols resolved at runtime by `TokenMetaCache::build` from +# each underlying ERC-20's `symbol()`. WETH's `symbol()` returns +# `"WETH"`, so the `WETH` key below is what the profit gate asks +# for; the `ETH` key is included separately as the **native gas +# token** symbol for `native_feed_symbol_for_chain("eth")`. +[chainlink.eth] +ETH = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" # ETH / USD — native feed +WETH = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" # same aggregator as ETH +USDT = "0x3E7d1eAB13ad0104d2750B8863b489D65364e32D" +USDC = "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6" +DAI = "0xAed0c38402a5d19dF6E4c03F4E2DceD6e29c1ee9" +WBTC = "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c" # BTC / USD aggregator (WBTC tracks BTC) + # Per-feed staleness windows (seconds). Stable-coin Chainlink feeds on # BSC update on deviation, not heartbeat — the global 600s default # routinely flags USDT / USDC / FDUSD as stale even when the price has @@ -141,3 +226,12 @@ USDC = "0x51597f405303C4377E36123cBc172b13269EA163" [chainlink_max_age_secs.bnb] USDT = 86400 USDC = 86400 + +# Same stable-feed widening as BSC. Ethereum stable Chainlink feeds +# also update on deviation rather than heartbeat, so the global +# default of 600s flags them as stale even when the price hasn't +# moved. +[chainlink_max_age_secs.eth] +USDT = 86400 +USDC = 86400 +DAI = 86400 diff --git a/config/fork-eth.toml b/config/fork-eth.toml new file mode 100644 index 0000000..1767342 --- /dev/null +++ b/config/fork-eth.toml @@ -0,0 +1,95 @@ +# Charon — local anvil fork profile (Ethereum mainnet state, local host). +# +# Pair with `scripts/anvil_fork_eth.sh` (or any anvil --fork-url … +# command pointed at an Eth archive RPC). The fork process exposes +# HTTP+WS on 127.0.0.1:${CHARON_ANVIL_PORT:-8545} and mirrors every +# mainnet address the default profile uses for `[chain.eth]` / +# `[protocol.aave_v3_eth]` / `[flashloan.aave_v3_eth]`. +# +# Why a separate file from `fork.toml`: +# - Different chain (eth vs bnb), different protocol (Aave V3 vs Venus). +# - Different deterministic CharonLiquidator constructor args (Aave V3 +# Pool / Uniswap V3 SwapRouter on Ethereum, not the BSC equivalents). +# +# Running a secondary fork on a different port: +# CHARON_ANVIL_PORT=8546 ./scripts/anvil_fork_eth.sh +# CHARON_ANVIL_PORT=8546 charon --config config/fork-eth.toml replay … + +[bot] +# Lower gate for demo staging (mirrors fork.toml). +min_profit_usd_1e6 = 0 +min_profit_margin_bps = 100 +# 5_000 gwei. Aug 5 2024 cascade hit ~1280 gwei base fee — widen +# generously so historical-volatility demo blocks (USDC depeg, FTX, +# yen unwind) clear the gas gate. +max_gas_wei = "5000000000000" +scan_interval_ms = 1000 +signer_key = "${CHARON_SIGNER_KEY:-}" +profile_tag = "fork" + +# ── Chains ──────────────────────────────────────────────────────────────── +[chain.eth] +chain_id = 1 +ws_url = "ws://127.0.0.1:${CHARON_ANVIL_PORT:-8545}" +http_url = "http://127.0.0.1:${CHARON_ANVIL_PORT:-8545}" +priority_fee_gwei = 1 + +[chain.eth.discovery] +backfill_blocks = 50000 +log_chunk_blocks = 100000 +inter_chunk_pacing_ms = 0 +chunk_max_attempts = 4 +chunk_initial_backoff_ms = 500 +chunk_max_backoff_ms = 5000 + +# ── Lending protocols ───────────────────────────────────────────────────── +[protocol.aave_v3_eth] +chain = "eth" +kind = "aave_v3" +pool = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" +pool_addresses_provider = "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e" +oracle = "0x54586bE62E3c3580375aE3723C145253060Ca0C2" +data_provider = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3" + +# ── Flash-loan sources ──────────────────────────────────────────────────── +[flashloan.aave_v3_eth] +chain = "eth" +pool = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" +data_provider = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3" +addresses_provider = "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e" + +# ── Prometheus metrics exporter ─────────────────────────────────────────── +[metrics] +enabled = true +bind = "127.0.0.1:9091" + +# ── Chainlink price feeds (Ethereum mainnet) ────────────────────────────── +[chainlink.eth] +ETH = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" +WETH = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" +USDT = "0x3E7d1eAB13ad0104d2750B8863b489D65364e32D" +USDC = "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6" +DAI = "0xAed0c38402a5d19dF6E4c03F4E2DceD6e29c1ee9" +WBTC = "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c" + +# Pinned anvil at a block from a year ago (Aug 2024 demo) reads +# Chainlink rounds whose `updatedAt` is ~640 days behind wall clock. +# The staleness check compares against `now()` not `block.timestamp`, +# so widen aggressively for the demo. 2 years = 63072000 seconds. +[chainlink_max_age_secs.eth] +USDT = 63072000 +USDC = 63072000 +DAI = 63072000 +ETH = 63072000 +WETH = 63072000 +WBTC = 63072000 + +# ── Deployed liquidator (deterministic anvil dev-0 address) ────────────── +# Same address as fork.toml's [liquidator.bnb] — comes from +# `forge create` against anvil dev-account 0 +# (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) at nonce 0. +# `scripts/anvil_fork.sh` resets the dev-0 nonce + wipes the deploy +# slot, so the first `forge create` in this profile lands here too. +[liquidator.eth] +chain = "eth" +contract_address = "0x5FbDB2315678afecb367f032d93F642f64180aa3" diff --git a/config/fork.toml b/config/fork.toml index f95752b..70e3876 100644 --- a/config/fork.toml +++ b/config/fork.toml @@ -22,8 +22,14 @@ [bot] # Lower gate for demo staging — real ops should use default.toml. -# $0.01 in USD × 1e6 fixed-point (post feat/19 integer schema). -min_profit_usd_1e6 = 10000 +# USD floor disabled (margin gate below is primary). 10_000 = $0.01 +# was the pre-margin-gate sentinel — keep at 0 here so the 110% +# margin gate alone decides demo passes. +min_profit_usd_1e6 = 0 +# Looser margin for demo: 1% on total cost (default profile uses 10%). +# Synthetic / partial positions on a fork rarely clear a 10% margin, +# and the fork is not where a thin-margin liquidation costs anything. +min_profit_margin_bps = 100 # 20 gwei, expressed in wei (decimal string). Matches the lowered # demo-gate intent: cheap anvil transactions shouldn't get filtered. max_gas_wei = "20000000000" diff --git a/config/testnet.toml b/config/testnet.toml index 02093f1..b199940 100644 --- a/config/testnet.toml +++ b/config/testnet.toml @@ -27,8 +27,12 @@ [bot] # Low threshold so any testnet-scale opportunity can cross the gate. -# 10_000 = $0.01 in fixed-point (USD × 1e6). -min_profit_usd_1e6 = 10000 +# USD floor disabled (margin gate primary). Pre-margin-gate value was +# 10_000 = $0.01. +min_profit_usd_1e6 = 0 +# Margin-gate primary; keep loose on testnet (1%) so synthetic +# positions on Chapel clear without manual tuning. +min_profit_margin_bps = 100 # 20 gwei ceiling, expressed in wei. Generous for Chapel where traffic # is light; mirrors mainnet units so the same knob works everywhere. max_gas_wei = "20000000000" diff --git a/contracts/src/CharonLiquidatorAave.sol b/contracts/src/CharonLiquidatorAave.sol new file mode 100644 index 0000000..dd90003 --- /dev/null +++ b/contracts/src/CharonLiquidatorAave.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IFlashLoanSimpleReceiver } from "./interfaces/IFlashLoanSimpleReceiver.sol"; +import { IAaveV3Pool } from "./interfaces/IAaveV3Pool.sol"; +import { ISwapRouter } from "./interfaces/ISwapRouter.sol"; +import { IERC20 } from "./interfaces/IERC20.sol"; + +// ───────────────────────────────────────────────────────────────────────────── +// CharonLiquidatorAave — Aave V3 flash-loan liquidation engine +// +// Sibling to CharonLiquidator.sol (which handles Venus on BSC). Kept separate +// to avoid destabilising the Venus path while wiring Aave V3 support; both +// contracts share the same surface (executeLiquidation entry, executeOperation +// callback, rescue safety hatch) and the same security invariants. +// +// Aave V3 liquidation flow (per item): +// 1. Bot calls executeLiquidation(p) with Aave-shaped params. +// 2. Contract requests flashLoanSimple(p.debtToken, p.debtToCover) from Aave. +// 3. Aave calls executeOperation; inside we: +// a. Approve Aave Pool to pull `debtToCover` of `debtToken`. +// b. Call AAVE_POOL.liquidationCall(collateralAsset, debtAsset, user, +// debtToCover, receiveAToken=false). Aave repays the borrower's debt +// and sends the seized collateral underlying to address(this). +// c. Swap the seized collateral → debt token via Uniswap V3 SwapRouter +// at the caller-supplied fee tier. +// d. Sweep any profit to the COLD wallet. +// e. Approve Aave Pool to pull totalOwed (flash principal + premium). +// +// Security invariants mirror CharonLiquidator.sol: +// - onlyOwner on executeLiquidation + rescue. +// - executeOperation gated by msg.sender == AAVE_POOL && initiator == self. +// - nonReentrant on executeLiquidation only (callback can't be re-locked). +// - Profit is swept to immutable COLD_WALLET, never the hot wallet. +// - All approvals zeroed after consumption. +// - No tx.origin, no delegatecall, no assembly. +// ───────────────────────────────────────────────────────────────────────────── + +contract CharonLiquidatorAave is IFlashLoanSimpleReceiver { + /// @dev ProtocolId::AaveV3 = 1 in the Rust enum (after Venus=0 in + /// AaveLiquidationParams.protocolId; mirror what the Rust + /// `ProtocolId::AaveV3` discriminant ends up as in the + /// `LendingProtocol` impl). Pinned by the + /// `tests::encode_aave_calldata_protocol_id` unit test on the Rust + /// side. + uint8 internal constant PROTOCOL_AAVE_V3 = 1; + + uint256 private _entered = 1; + + /// @notice Bot's hot wallet — sole authorized caller of + /// executeLiquidation + rescue. Holds gas only by policy. + address public immutable owner; + + /// @notice Aave V3 Pool. Used both as flash-loan source AND as the + /// liquidation entry point (Aave V3 routes liquidationCall + /// through the same Pool contract that exposes + /// flashLoanSimple). Set once at construction. + address public immutable AAVE_POOL; + + /// @notice Uniswap V3 SwapRouter02 (or any V3-compatible router with the + /// `ISwapRouter.exactInputSingle` selector). Mainnet: + /// 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 + address public immutable SWAP_ROUTER; + + /// @notice Cold wallet — sole recipient of liquidation profit. + address public immutable COLD_WALLET; + + /// @notice Parameters required to execute a single Aave V3 liquidation. + /// @dev abi-encoded into the Aave flash-loan `params` blob and recovered + /// inside executeOperation. Field layout must mirror the Rust + /// `CharonAaveLiquidationParams` struct in the executor crate. + struct LiquidationParams { + /// @dev Must equal PROTOCOL_AAVE_V3 (1). + uint8 protocolId; + /// @dev Borrower whose Aave position is being liquidated. + address borrower; + /// @dev Underlying ERC-20 the borrower owes (e.g. USDT). + address debtToken; + /// @dev Underlying ERC-20 posted as collateral (e.g. WETH). + address collateralToken; + /// @dev Amount of debtToken to repay, capped at the dynamic close + /// factor (50% normal, 100% when HF < 0.95e18). + uint256 debtToCover; + /// @dev Slippage floor on the collateral → debt swap. + uint256 minSwapOut; + /// @dev Uniswap V3 pool fee tier (hundredths of a bip — e.g. 500 + /// for the WETH/USDT 0.05% pool). + uint24 swapPoolFee; + } + + event LiquidationExecuted( + address indexed borrower, + address indexed debtToken, + uint256 debtToCover, + uint256 profit, + address indexed recipient + ); + + event Rescued(address indexed token, address indexed to, uint256 amount); + + modifier onlyOwner() { + require(msg.sender == owner, "!owner"); + _; + } + + modifier nonReentrant() { + require(_entered == 1, "reentrant"); + _entered = 2; + _; + _entered = 1; + } + + /// @notice Deploys CharonLiquidatorAave and binds it to one Aave Pool, + /// one Uniswap V3 SwapRouter, and one cold wallet. + /// @param _aavePool Aave V3 Pool address (e.g. 0x87870Bca... on Eth). + /// @param _swapRouter Uniswap V3 SwapRouter02 address on the target chain. + /// @param _coldWallet Profit recipient. Required non-zero. + constructor(address _aavePool, address _swapRouter, address _coldWallet) { + require(_aavePool != address(0), "!aavePool"); + require(_swapRouter != address(0), "!swapRouter"); + require(_coldWallet != address(0), "!coldWallet"); + owner = msg.sender; + AAVE_POOL = _aavePool; + SWAP_ROUTER = _swapRouter; + COLD_WALLET = _coldWallet; + } + + /// @notice Initiates a flash-loan-backed Aave V3 liquidation. + /// @param p All parameters describing the liquidation opportunity. + function executeLiquidation(LiquidationParams calldata p) external onlyOwner nonReentrant { + require(p.protocolId == PROTOCOL_AAVE_V3, "!protocolId"); + require(p.borrower != address(0), "!borrower"); + require(p.debtToken != address(0), "!debtToken"); + require(p.collateralToken != address(0), "!collateralToken"); + require(p.debtToCover > 0, "!debtToCover"); + require(p.swapPoolFee > 0, "!swapPoolFee"); + + bytes memory encoded = abi.encode(p); + + IAaveV3Pool(AAVE_POOL).flashLoanSimple( + address(this), + p.debtToken, + p.debtToCover, + encoded, + 0 + ); + // Aave has pulled debtToCover + premium via the allowance set inside + // executeOperation. Nothing further to do in this frame. + } + + /// @notice Aave V3 flash-loan callback. Runs the liquidation + swap + + /// repayment cycle. + function executeOperation( + address asset, + uint256 amount, + uint256 premium, + address initiator, + bytes calldata data + ) external override returns (bool) { + require(msg.sender == AAVE_POOL, "!pool"); + require(initiator == address(this), "!initiator"); + + LiquidationParams memory p = abi.decode(data, (LiquidationParams)); + require(asset == p.debtToken, "asset/debt mismatch"); + require(amount == p.debtToCover, "amount/debt mismatch"); + + // ── Liquidate on Aave ───────────────────────────────────────────── + // Aave V3's liquidationCall pulls `debtToCover` of `debtToken` from + // address(this) (after our approval) and sends the seized collateral + // (with the liquidation bonus folded in) to address(this). + IERC20(p.debtToken).approve(AAVE_POOL, p.debtToCover); + + IAaveV3Pool(AAVE_POOL).liquidationCall( + p.collateralToken, + p.debtToken, + p.borrower, + p.debtToCover, + false // receiveAToken=false → receive underlying collateral + ); + + // Zero the post-liquidation approval. Aave consumed exactly + // `debtToCover`; the post-condition that the allowance is now zero is + // already true under canonical Aave Pool semantics, but we re-set it + // so a future Pool-side change (or a fee-on-transfer debt token) + // cannot leave a non-zero allowance behind. + IERC20(p.debtToken).approve(AAVE_POOL, 0); + + // ── Swap collateral → debt token ────────────────────────────────── + uint256 collateralBal = IERC20(p.collateralToken).balanceOf(address(this)); + require(collateralBal > 0, "no collateral seized"); + IERC20(p.collateralToken).approve(SWAP_ROUTER, collateralBal); + + ISwapRouter(SWAP_ROUTER).exactInputSingle( + ISwapRouter.ExactInputSingleParams({ + tokenIn: p.collateralToken, + tokenOut: p.debtToken, + fee: p.swapPoolFee, + recipient: address(this), + deadline: block.timestamp, + amountIn: collateralBal, + amountOutMinimum: p.minSwapOut, + sqrtPriceLimitX96: 0 + }) + ); + + IERC20(p.collateralToken).approve(SWAP_ROUTER, 0); + + // ── Verify swap output covers the flash-loan repayment ──────────── + uint256 totalOwed = amount + premium; + uint256 finalBal = IERC20(p.debtToken).balanceOf(address(this)); + require(finalBal >= totalOwed, "swap output below repayment"); + + // ── Sweep profit to COLD_WALLET ─────────────────────────────────── + uint256 profit = finalBal - totalOwed; + if (profit > 0) { + bool ok = IERC20(p.debtToken).transfer(COLD_WALLET, profit); + require(ok, "profit: transfer failed"); + } + + emit LiquidationExecuted(p.borrower, p.debtToken, p.debtToCover, profit, COLD_WALLET); + + // ── Approve Aave to pull totalOwed ──────────────────────────────── + IERC20(p.debtToken).approve(AAVE_POOL, totalOwed); + + return true; + } + + /// @notice Recovers ERC-20 tokens or native gas token stuck in this contract. + function rescue(address token, address to, uint256 amount) external onlyOwner { + require(to != address(0), "!to"); + require(amount > 0, "!amount"); + + if (token == address(0)) { + (bool ok,) = payable(to).call{ value: amount }(""); + require(ok, "rescue: native transfer failed"); + } else { + bool ok = IERC20(token).transfer(to, amount); + require(ok, "rescue: transfer failed"); + } + + emit Rescued(token, to, amount); + } +} diff --git a/contracts/src/interfaces/IAaveV3Pool.sol b/contracts/src/interfaces/IAaveV3Pool.sol index c61d1fa..194132f 100644 --- a/contracts/src/interfaces/IAaveV3Pool.sol +++ b/contracts/src/interfaces/IAaveV3Pool.sol @@ -25,4 +25,20 @@ interface IAaveV3Pool { bytes calldata params, uint16 referralCode ) external; + + /// @notice Liquidate `user` whose health factor is below 1e18. + /// @dev Called by CharonLiquidatorAave inside the flash-loan callback. + /// @param collateralAsset The collateral ERC-20 to seize. + /// @param debtAsset The ERC-20 debt being repaid. + /// @param user The borrower being liquidated. + /// @param debtToCover Amount of debt to repay, capped at the dynamic + /// close factor (50% normal, 100% when HF < 0.95e18). + /// @param receiveAToken `false` requests the underlying collateral. + function liquidationCall( + address collateralAsset, + address debtAsset, + address user, + uint256 debtToCover, + bool receiveAToken + ) external; } diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index b183b9e..38c4188 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -67,7 +67,7 @@ use charon_executor::{ }; use charon_flashloan::{AaveFlashLoan, FlashLoanRouter}; use charon_metrics::{bucket, drop_reason, drop_stage, sim_result}; -use charon_protocols::VenusAdapter; +use charon_protocols::{AaveV3Adapter, VenusAdapter}; use charon_scanner::{ BlockListener, ChainEvent, ChainProvider, DEFAULT_MAX_AGE, HealthScanner, MempoolMonitor, OracleUpdate, PendingCache, PositionBucket, PriceCache, ScanScheduler, SimulationVerdict, @@ -121,6 +121,21 @@ const PROFIT_GATE_ROUGH_GAS_UNITS: u64 = 1_500_000; /// a missing BNB feed means the profit gate cannot be trusted. const NATIVE_FEED_SYMBOL: &str = "BNB"; +/// Per-chain native gas-token symbol used as the lookup key in +/// `[chainlink.]`. Falls back to [`NATIVE_FEED_SYMBOL`] (BNB) +/// for unknown chain names so legacy single-chain BSC configs keep +/// working unchanged. +fn native_feed_symbol_for_chain(chain_name: &str) -> &'static str { + match chain_name { + "bnb" | "bsc" => "BNB", + "eth" | "ethereum" | "mainnet" => "ETH", + "arb" | "arbitrum" | "op" | "optimism" | "base" => "ETH", + "polygon" | "matic" => "MATIC", + "avax" | "avalanche" => "AVAX", + _ => NATIVE_FEED_SYMBOL, + } +} + /// Gas limit supplied to `Simulator::simulate` until a real gas /// estimate is wired up. Sized to comfortably cover a Venus /// `liquidateBorrow` + PancakeSwap V3 swap round-trip. @@ -262,6 +277,15 @@ enum Command { /// process lifetime would just slow the suite. See #422. #[arg(long = "hold-secs", default_value_t = 0)] hold_secs: u64, + + /// `[protocol.]` section to drive the replay against. + /// Defaults to `"venus"` for backward compat with the BSC + /// demo. Set to `"aave_v3_eth"` (or any other configured + /// section name) to replay an Aave V3 fork. The section's + /// `kind` discriminates Venus vs Aave V3 wiring inside + /// [`run_replay`]. + #[arg(long = "protocol", default_value = "venus")] + protocol: String, }, } @@ -269,9 +293,14 @@ enum Command { /// in `Option` so the listener still drains events when /// `[protocol.venus]` is absent (useful for operators running the /// block pipeline against a chain before its adapter is wired). -struct VenusPipeline { +struct ProtocolPipeline { chain_name: String, - adapter: Arc, + /// Adapter behind the [`charon_core::LendingProtocol`] trait so the + /// per-block scan loop is protocol-agnostic. Concrete adapters + /// (Venus on BSC, Aave V3 on Ethereum, …) are constructed by the + /// CLI at startup and upcast into this `Arc` before being + /// handed to the pipeline. + adapter: Arc, scanner: Arc, scheduler: ScanScheduler, prices: Arc, @@ -304,6 +333,11 @@ struct VenusPipeline { tx_builder: tokio::sync::OnceCell>>, simulator: tokio::sync::OnceCell>, min_profit_usd_1e6: u64, + /// Required margin on `total_cost_wei` (flash fee + gas + slippage), + /// in basis points. `1_000` = 10%, i.e. swap output must be at + /// least `1.10 × total_cost` for the opportunity to clear the + /// profit gate. Sourced from `bot.min_profit_margin_bps`. + min_profit_margin_bps: u16, chain_id: u64, /// Present only when the operator ran `listen --execute` and /// every safety gate passed. `None` means scan-only or @@ -342,7 +376,7 @@ struct VenusPipeline { /// scan-only or scan+simulate mode. /// /// Single-chain scope (BNB) for v0.1, matching the rest of the -/// `VenusPipeline` — a multi-chain harness is a follow-up when a +/// `ProtocolPipeline` — a multi-chain harness is a follow-up when a /// second adapter lands. struct ExecHarness { /// Per-chain EIP-1559 fee source. Honours `bot.max_gas_wei` as @@ -429,8 +463,9 @@ async fn main() -> Result<()> { block, borrower_file, hold_secs, + protocol, } => { - run_replay(&config, block, borrower_file, hold_secs).await?; + run_replay(&config, block, borrower_file, hold_secs, protocol).await?; } } @@ -734,11 +769,86 @@ async fn run_listen( // assembly that produces them. let mut discovery_tasks: Vec> = Vec::new(); + // ── Multi-protocol iteration (#…) ───────────────────────────────── + // + // Walk every `[protocol.]` section. For every entry whose + // `kind` is something other than the runtime-supported set + // (currently just `Venus`), build the adapter via the + // `charon-protocols::build_lending_adapter` factory once at + // startup. This proves the trait + factory + per-chain provider + // wiring against the live RPC and surfaces the adapter's reserve + // / market count + chain id in the boot log. The adapter Arc is + // dropped immediately afterwards because the per-block scan loop + // for non-Venus kinds (Aave V3 et al.) needs additional pieces + // that are not yet wired in this CLI: an Aave-flavored + // `executeLiquidation` codepath in CharonLiquidator.sol, an + // Aave-flavored mempool monitor, and Aave-flavored discovery via + // `Pool.Borrow` events. Those follow-ups (#…) replace this + // factory-only validation pass with a full pipeline build. + // + // Per-chain providers are deduplicated via this map so two + // protocol sections on the same chain share a single WebSocket. + let mut chain_providers: std::collections::HashMap>> = + std::collections::HashMap::new(); + for (proto_name, proto_cfg) in &config.protocol { + if proto_cfg.kind == charon_core::ProtocolKind::Venus { + // Venus runtime path is built below — skip from this + // factory-validation loop so we don't open a second WS. + continue; + } + let chain_name = &proto_cfg.chain; + let chain_cfg = config.chain.get(chain_name).with_context(|| { + format!( + "protocol '{proto_name}' references chain '{chain_name}' which is not in [chain.*]" + ) + })?; + let provider = match chain_providers.get(chain_name) { + Some(p) => p.clone(), + None => { + let p = Arc::new( + ProviderBuilder::new() + .on_ws(WsConnect::new(&chain_cfg.ws_url)) + .await + .with_context(|| { + format!( + "protocol '{proto_name}': failed to open shared ws provider for chain '{chain_name}'" + ) + })?, + ); + chain_providers.insert(chain_name.clone(), p.clone()); + p + } + }; + match charon_protocols::build_lending_adapter(proto_name, proto_cfg, provider).await { + Ok(adapter) => { + info!( + protocol = %proto_name, + chain = %chain_name, + kind = ?proto_cfg.kind, + adapter_id = ?adapter.id(), + "non-Venus protocol adapter built — runtime pipeline pending follow-up" + ); + // Adapter Arc dropped here. Held only to verify the + // factory + chain provider pair work end-to-end. + drop(adapter); + } + Err(err) => { + warn!( + protocol = %proto_name, + chain = %chain_name, + ?err, + "non-Venus protocol adapter failed to build — \ + continuing without it" + ); + } + } + } + // Venus pipeline state is currently single-chain (BNB) per config // scope. Build it only if `[protocol.venus]` exists and its target // chain plus flashloan+liquidator entries are all configured; // otherwise run the listener pipeline without a scanner. - let venus: Option> = match config.protocol.get("venus") { + let venus: Option> = match config.protocol.get("venus") { Some(venus_cfg) => { let chain_name = &venus_cfg.chain; let chain_cfg = config.chain.get(chain_name).with_context(|| { @@ -757,8 +867,21 @@ async fn run_listen( .context("venus adapter: failed to open shared ws provider")?, ); - let adapter = - Arc::new(VenusAdapter::connect(provider.clone(), venus_cfg.comptroller).await?); + // `venus_cfg.comptroller` is `Option
` on the + // post-multi-protocol schema. `Config::validate` enforces + // `Some(_)` whenever `kind = Venus` (the default), so the + // unwrap is invariant-backed. + let comptroller = venus_cfg + .comptroller + .context("[protocol.venus] missing comptroller (validation gate misfired)")?; + let venus_adapter = + Arc::new(VenusAdapter::connect(provider.clone(), comptroller).await?); + // Trait-erased handle for the per-block scan loop. The + // concrete `venus_adapter` is kept around so the + // Venus-specific bring-up calls (markets / discovery) can + // run during this branch without re-fetching the + // comptroller snapshot. + let adapter: Arc = venus_adapter.clone(); // ── Borrower auto-discovery (issue #329) ───────────────── // @@ -817,7 +940,7 @@ async fn run_listen( charon_scanner::DEFAULT_FLUSH_EVERY, )); } - let vtokens_for_discovery = adapter.markets().await; + let vtokens_for_discovery = venus_adapter.markets().await; if vtokens_for_discovery.is_empty() { warn!( chain = %chain_name, @@ -991,16 +1114,17 @@ async fn run_listen( // not silently degrade gas pricing. const PREFLIGHT_ATTEMPTS: usize = 5; const PREFLIGHT_GAP: Duration = Duration::from_secs(5); - let mut bnb_ready = false; + let native_symbol = native_feed_symbol_for_chain(chain_name); + let mut native_ready = false; for attempt in 1..=PREFLIGHT_ATTEMPTS { prices.refresh_all().await; - if prices.get(NATIVE_FEED_SYMBOL).is_some() { - bnb_ready = true; + if prices.get(native_symbol).is_some() { + native_ready = true; break; } if attempt < PREFLIGHT_ATTEMPTS { tracing::warn!( - symbol = NATIVE_FEED_SYMBOL, + symbol = native_symbol, attempt, retry_in_ms = PREFLIGHT_GAP.as_millis() as u64, "chainlink native feed not ready — retrying" @@ -1026,9 +1150,9 @@ async fn run_listen( // ratio `native_price / debt_price`. Without the native // feed we would be guessing. Refuse to start rather than // silently drop every opportunity. - if !bnb_ready { + if !native_ready { bail!( - "chainlink feed for '{NATIVE_FEED_SYMBOL}' missing or stale on chain \ + "chainlink feed for '{native_symbol}' missing or stale on chain \ '{chain_name}' after {PREFLIGHT_ATTEMPTS} attempts — gas cost cannot be priced" ); } @@ -1171,7 +1295,7 @@ async fn run_listen( info!( chain = %chain_name, borrower_count = borrowers.len(), - market_count = adapter.markets().await.len(), + market_count = venus_adapter.markets().await.len(), feed_count = fresh_feeds.len(), liquidatable_bps = config.bot.liquidatable_threshold_bps, near_liq_bps = config.bot.near_liq_threshold_bps, @@ -1272,7 +1396,7 @@ async fn run_listen( None }; - Some(Arc::new(VenusPipeline { + Some(Arc::new(ProtocolPipeline { chain_name: chain_name.clone(), adapter, scanner, @@ -1287,6 +1411,7 @@ async fn run_listen( tx_builder: tokio::sync::OnceCell::new(), simulator: tokio::sync::OnceCell::new(), min_profit_usd_1e6: config.bot.min_profit_usd_1e6, + min_profit_margin_bps: config.bot.min_profit_margin_bps, chain_id, exec_harness, discovery: discovery.clone(), @@ -1620,7 +1745,7 @@ async fn run_listen( /// borrower discovery, no mempool monitor, no metrics exporter, /// no `ExecHarness`), pins both `gas_oracle::fetch_params` and /// `Simulator::simulate_at` to the operator-supplied block `N` via -/// `VenusPipeline::replay_block`, runs the existing +/// `ProtocolPipeline::replay_block`, runs the existing /// `run_block_pipeline` once with the borrower seed list from /// `--borrower-file`, then drains the resulting `OpportunityQueue` /// onto stdout as one JSON line per simulation-passing position. @@ -1635,6 +1760,7 @@ async fn run_replay( block: u64, borrower_file: PathBuf, hold_secs: u64, + protocol_key: String, ) -> Result<()> { // Bring up the metrics exporter early so any counters bumped // during the one-shot pipeline are exposed on /metrics for @@ -1671,13 +1797,16 @@ async fn run_replay( None }; - let venus_cfg = config.protocol.get("venus").context( - "replay: [protocol.venus] missing — replay requires a configured Venus protocol", - )?; - let chain_name = &venus_cfg.chain; + let proto_cfg = config.protocol.get(&protocol_key).with_context(|| { + format!( + "replay: [protocol.{protocol_key}] missing — replay requires a configured \ + protocol section. Pass --protocol to pick one." + ) + })?; + let chain_name = &proto_cfg.chain; let chain_cfg = config.chain.get(chain_name).with_context(|| { format!( - "replay: protocol 'venus' references chain '{chain_name}' which is not in [chain.*]" + "replay: protocol '{protocol_key}' references chain '{chain_name}' which is not in [chain.*]" ) })?; if config.bot.signer_key.is_none() { @@ -1696,12 +1825,21 @@ async fn run_replay( deploy CharonLiquidator on the fork and set the address before replay" ); } - let fl_cfg = config + // Per-chain flash-loan source lookup. Find the first + // `[flashloan.]` whose `chain` matches this protocol's chain. + // BSC pairs with `aave_v3_bsc`, Ethereum with `aave_v3_eth`, etc. + let (fl_key, fl_cfg) = config .flashloan - .get("aave_v3_bsc") - .context("replay: [flashloan.aave_v3_bsc] missing — replay needs a flash-loan source")?; + .iter() + .find(|(_, fl)| fl.chain == *chain_name) + .with_context(|| { + format!( + "replay: no [flashloan.*] section references chain '{chain_name}' — \ + add one to drive the router" + ) + })?; let data_provider = fl_cfg.data_provider.with_context(|| { - format!("replay: flashloan 'aave_v3_bsc': missing data_provider for chain '{chain_name}'") + format!("replay: flashloan '{fl_key}': missing data_provider for chain '{chain_name}'") })?; let parsed_borrowers = parse_borrower_file(&borrower_file); @@ -1728,7 +1866,30 @@ async fn run_replay( .context("replay: failed to open shared ws provider")?, ); - let adapter = Arc::new(VenusAdapter::connect(provider.clone(), venus_cfg.comptroller).await?); + // Per-kind adapter factory dispatch. `Config::validate` already + // gated each section's required fields, so the unwraps below are + // invariant-backed. + let adapter: Arc = match proto_cfg.kind { + charon_core::ProtocolKind::Venus => { + let comptroller = proto_cfg.comptroller.context( + "replay: [protocol.venus] missing comptroller (validation gate misfired)", + )?; + Arc::new(VenusAdapter::connect(provider.clone(), comptroller).await?) + } + charon_core::ProtocolKind::AaveV3 => { + let pool = proto_cfg + .pool + .context("replay: [protocol.] missing pool (validation gate misfired)")?; + let oracle = proto_cfg.oracle.context( + "replay: [protocol.] missing oracle (validation gate misfired)", + )?; + let dp = proto_cfg.data_provider.context( + "replay: [protocol.] missing data_provider (validation gate misfired)", + )?; + Arc::new(AaveV3Adapter::connect(provider.clone(), pool, oracle, dp).await?) + } + other => bail!("replay: protocol kind {other:?} has no adapter factory branch yet"), + }; let scanner = Arc::new(HealthScanner::new( config.bot.liquidatable_threshold_bps, @@ -1765,9 +1926,10 @@ async fn run_replay( per_symbol_max_age, )); prices.refresh_all().await; - if prices.get(NATIVE_FEED_SYMBOL).is_none() { + let native_symbol = native_feed_symbol_for_chain(chain_name); + if prices.get(native_symbol).is_none() { bail!( - "replay: chainlink feed '{NATIVE_FEED_SYMBOL}' missing or stale on chain \ + "replay: chainlink feed '{native_symbol}' missing or stale on chain \ '{chain_name}' — gas cost cannot be priced" ); } @@ -1800,7 +1962,7 @@ async fn run_replay( ); let router = Arc::new(FlashLoanRouter::new(vec![aave])); - let pipeline = Arc::new(VenusPipeline { + let pipeline = Arc::new(ProtocolPipeline { chain_name: chain_name.clone(), adapter, scanner, @@ -1815,6 +1977,7 @@ async fn run_replay( tx_builder: tokio::sync::OnceCell::new(), simulator: tokio::sync::OnceCell::new(), min_profit_usd_1e6: config.bot.min_profit_usd_1e6, + min_profit_margin_bps: config.bot.min_profit_margin_bps, chain_id: chain_cfg.chain_id, exec_harness: None, discovery: charon_scanner::BorrowerSet::new(), @@ -1900,7 +2063,7 @@ async fn run_replay( /// chain. Errors are logged, never propagated — the bot keeps draining /// events even if a single block's scan has issues. async fn run_block_pipeline( - pipeline: Arc, + pipeline: Arc, block: u64, timestamp: u64, borrowers: &[Address], @@ -2144,7 +2307,7 @@ impl SimGate for ProductionSimGate<'_> { /// `PrivateKeySigner::from_str` and then dropped — `TxBuilder` owns /// the signer handle, never the raw hex. async fn ensure_executor<'a>( - pipeline: &'a VenusPipeline, + pipeline: &'a ProtocolPipeline, signer_key: Option<&secrecy::SecretString>, ) -> Option<(&'a TxBuilder, &'a Simulator)> { let builder_slot = pipeline @@ -2209,7 +2372,7 @@ async fn ensure_executor<'a>( /// configured, this function returns `Ok(false)` before touching the /// queue — scan-only mode observes, it never queues. async fn process_opportunity( - pipeline: Arc, + pipeline: Arc, pos: &Position, block: u64, signer_key: Option<&secrecy::SecretString>, @@ -2224,8 +2387,21 @@ async fn process_opportunity( // Exhaustive match so a new `LiquidationParams` variant forces // this call site to be audited. `LiquidationParams` is // `#[non_exhaustive]`, hence the trailing wildcard. + // + // For both `Venus { repay_amount }` and `AaveV3 { debt_to_cover }` + // we extract the same downstream notion: the absolute amount of + // debt-token wei the flash loan must cover. The router and profit + // calculator are protocol-agnostic from here on. The current + // `ProtocolPipeline` still carries Venus-specific runtime extras + // (oracle monitor, vToken markets list) — Aave V3 opportunities + // can exercise the trait-routed scan/profit path but the broadcast + // codepath requires a separate Aave-flavored CharonLiquidator + // entry on the Solidity side; until then the `--execute` harness + // will simulate-fail Aave variants on the existing Venus-only + // calldata path. let repay = match ¶ms { LiquidationParams::Venus { repay_amount, .. } => *repay_amount, + LiquidationParams::AaveV3 { debt_to_cover, .. } => *debt_to_cover, other => { debug!( borrower = %pos.borrower, @@ -2277,7 +2453,10 @@ async fn process_opportunity( ); return Ok(false); }; - let Some(native_cached) = pipeline.prices.get(NATIVE_FEED_SYMBOL) else { + let Some(native_cached) = pipeline + .prices + .get(native_feed_symbol_for_chain(pipeline.chain_name.as_str())) + else { charon_metrics::record_opportunity_dropped(chain, drop_stage::PROFIT); charon_metrics::record_opportunity_dropped_reason(chain, drop_reason::UNPROFITABLE); debug!( @@ -2409,7 +2588,11 @@ async fn process_opportunity( return Ok(false); } }; - let net = match calculate_profit(&inputs, pipeline.min_profit_usd_1e6) { + let net = match calculate_profit( + &inputs, + pipeline.min_profit_usd_1e6, + pipeline.min_profit_margin_bps, + ) { Ok(n) => n, Err(err) => { charon_metrics::record_opportunity_dropped(chain, drop_stage::PROFIT); @@ -2750,7 +2933,7 @@ async fn poll_receipt_for_metrics( /// reuse a nonce that a later block confirms. The next /// rejection-with-nonce-too-low drives a recovery. async fn broadcast_opportunity( - pipeline: &VenusPipeline, + pipeline: &ProtocolPipeline, harness: &ExecHarness, opp: &LiquidationOpportunity, params: &LiquidationParams, @@ -3151,7 +3334,7 @@ async fn wait_sigterm() { /// drain loop continues with the next pre-sign; the block-scanner /// path is independent and must not be blocked by mempool hiccups. async fn drain_mempool_for_block( - pipeline: &VenusPipeline, + pipeline: &ProtocolPipeline, block_hash: B256, cache: Option<&PendingCache>, signer_key: Option<&secrecy::SecretString>, @@ -3383,6 +3566,7 @@ mod replay_cli_parse_tests { block, borrower_file, hold_secs, + protocol: _, } => { assert_eq!(block, 41_000_000); assert_eq!(borrower_file, PathBuf::from("/tmp/seed.txt")); diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index b400dec..b5852a8 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -278,7 +278,29 @@ pub struct BotConfig { /// Minimum profit threshold in USD × 1e6 (six decimals of USD). /// A value of `5_000_000` means `$5.00`. Fixed-point over f64 so /// comparisons against oracle-denominated profit are deterministic. + /// Set to `0` to disable the absolute-USD floor — the + /// margin-based gate (`min_profit_margin_bps`) is the primary + /// drop reason on the post-#… schema. pub min_profit_usd_1e6: u64, + /// Minimum required profit margin on **total cost**, in basis + /// points. The gate is: + /// + /// ```text + /// gross_swap_output_wei >= total_cost_wei × (10_000 + min_profit_margin_bps) / 10_000 + /// ``` + /// + /// equivalently, `net_profit_wei × 10_000 >= total_cost_wei × + /// min_profit_margin_bps`. `total_cost_wei` is `flash_fee + + /// gas + slippage_budget` — the actual capital the bot exposes to + /// the trade. + /// + /// Default `1_000` ⇒ 10% margin: if the pipeline's total cost is + /// 100 units of debt token, the swap must return at least 110 + /// units. Any candidate with a thinner margin is dropped before + /// it reaches the simulation gate. Set to `0` to disable the + /// margin gate (USD floor still applies). + #[serde(default = "default_min_profit_margin_bps")] + pub min_profit_margin_bps: u16, /// Maximum acceptable gas price, in wei (decimal string or integer). /// Stored as U256 so sub-gwei priority fees are representable and /// EIP-1559 math stays exact. @@ -350,6 +372,7 @@ impl fmt::Debug for BotConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("BotConfig") .field("min_profit_usd_1e6", &self.min_profit_usd_1e6) + .field("min_profit_margin_bps", &self.min_profit_margin_bps) .field("max_gas_wei", &self.max_gas_wei) .field("scan_interval_ms", &self.scan_interval_ms) .field( @@ -374,6 +397,10 @@ impl fmt::Debug for BotConfig { } } +fn default_min_profit_margin_bps() -> u16 { + 1_000 // 10% margin on total cost. +} + fn default_liquidatable_threshold_bps() -> u32 { 10_000 // 1.0000 } @@ -546,14 +573,71 @@ impl fmt::Debug for ChainConfig { } } +/// Discriminator that tells the CLI which adapter to construct from a +/// `[protocol.]` section. Defaults to [`ProtocolKind::Venus`] so +/// existing configs that pre-date multi-protocol support keep loading. +/// +/// Adding a new protocol = (1) add a variant here, (2) implement +/// [`crate::traits::LendingProtocol`] in `charon-protocols/`, (3) wire +/// the construction in the CLI factory. No change to the scanner / +/// executor / profit calculator should be necessary — the trait is +/// the boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[non_exhaustive] +#[serde(rename_all = "snake_case")] +pub enum ProtocolKind { + /// Compound V2 fork on BNB Chain. Requires `comptroller`. + #[default] + Venus, + /// Aave V3 — any chain Aave is deployed on. Requires `pool` + + /// `oracle` + `data_provider`. + AaveV3, +} + /// Address and metadata for a lending protocol on a specific chain. +/// +/// Fields are a superset across protocol families. Each `kind` variant +/// requires a different subset; `Config::validate` enforces the right +/// fields are populated for the declared `kind` so a typo in a protocol +/// section fails the boot rather than silently producing a half-built +/// adapter at runtime. +/// +/// Compound family (Venus) populates `comptroller`; Aave family +/// populates `pool` + `oracle` + `data_provider`. The two field sets +/// are disjoint by convention — a section with both is rejected at +/// validation time as a misconfiguration. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProtocolConfig { /// Name of the chain this protocol runs on (must match a key in `[chain]`). pub chain: String, - /// Protocol's main entry point (e.g. Venus Unitroller / Comptroller). - pub comptroller: Address, + /// Adapter family to construct. Defaults to [`ProtocolKind::Venus`] + /// so legacy configs (Venus-only, no `kind` field) keep loading. + #[serde(default)] + pub kind: ProtocolKind, + /// Compound-family entry point (Venus Unitroller / Comptroller). + /// Required when `kind = "venus"`; rejected for Aave-family kinds. + #[serde(default)] + pub comptroller: Option
, + /// Aave-family Pool address (e.g. Aave V3 Pool on Ethereum). + /// Required when `kind = "aave_v3"`; rejected for Compound-family kinds. + #[serde(default)] + pub pool: Option
, + /// Aave-family `IPoolAddressesProvider`. Optional — when set the + /// CLI cross-checks `getPool() == pool` at startup, mirroring the + /// flash-loan section's existing belt-and-braces gate (#367). + #[serde(default)] + pub pool_addresses_provider: Option
, + /// Aave-family `AaveOracle` (price oracle). Required when + /// `kind = "aave_v3"`; rejected for Compound-family kinds. + #[serde(default)] + pub oracle: Option
, + /// Aave-family `PoolDataProvider` (resolves per-asset reserve + /// configuration: liquidation bonus, close factor, decimals). + /// Required when `kind = "aave_v3"`; rejected for Compound-family + /// kinds. + #[serde(default)] + pub data_provider: Option
, } /// Discovery / backfill cadence config exposed under @@ -805,6 +889,12 @@ impl Config { } } } + if self.bot.min_profit_margin_bps > 10_000 { + return Err(ConfigError::Validation(format!( + "min_profit_margin_bps ({}) must be <= 10_000 (100%)", + self.bot.min_profit_margin_bps + ))); + } if self.bot.near_liq_threshold_bps <= self.bot.liquidatable_threshold_bps { return Err(ConfigError::Validation(format!( "near_liq_threshold_bps ({}) must be > liquidatable_threshold_bps ({})", @@ -859,10 +949,75 @@ impl Config { p.chain ))); } - if p.comptroller == Address::ZERO { - return Err(ConfigError::Validation(format!( - "protocol `{name}` has zero comptroller address" - ))); + // Per-kind required-field gate. The two field sets are + // disjoint by convention: a section that mixes Compound- + // family (`comptroller`) and Aave-family (`pool` / + // `oracle` / `data_provider`) addresses is rejected so a + // half-typed migration ("I changed kind = aave_v3 but + // forgot to remove the old comptroller field") fails the + // boot rather than silently constructing the wrong adapter + // family. + match p.kind { + ProtocolKind::Venus => { + let Some(comp) = p.comptroller else { + return Err(ConfigError::Validation(format!( + "protocol `{name}` (kind = venus) requires `comptroller`" + ))); + }; + if comp == Address::ZERO { + return Err(ConfigError::Validation(format!( + "protocol `{name}` has zero comptroller address" + ))); + } + if p.pool.is_some() + || p.oracle.is_some() + || p.data_provider.is_some() + || p.pool_addresses_provider.is_some() + { + return Err(ConfigError::Validation(format!( + "protocol `{name}` (kind = venus) carries Aave-family fields \ + (pool / oracle / data_provider / pool_addresses_provider) — remove them or change kind" + ))); + } + } + ProtocolKind::AaveV3 => { + if p.comptroller.is_some() { + return Err(ConfigError::Validation(format!( + "protocol `{name}` (kind = aave_v3) carries `comptroller` — \ + remove it or change kind" + ))); + } + let Some(pool) = p.pool else { + return Err(ConfigError::Validation(format!( + "protocol `{name}` (kind = aave_v3) requires `pool`" + ))); + }; + if pool == Address::ZERO { + return Err(ConfigError::Validation(format!( + "protocol `{name}` has zero pool address" + ))); + } + let Some(oracle) = p.oracle else { + return Err(ConfigError::Validation(format!( + "protocol `{name}` (kind = aave_v3) requires `oracle`" + ))); + }; + if oracle == Address::ZERO { + return Err(ConfigError::Validation(format!( + "protocol `{name}` has zero oracle address" + ))); + } + let Some(dp) = p.data_provider else { + return Err(ConfigError::Validation(format!( + "protocol `{name}` (kind = aave_v3) requires `data_provider`" + ))); + }; + if dp == Address::ZERO { + return Err(ConfigError::Validation(format!( + "protocol `{name}` has zero data_provider address" + ))); + } + } } } for (name, f) in &self.flashloan { @@ -1295,6 +1450,7 @@ mod private_rpc_tests { Config { bot: BotConfig { min_profit_usd_1e6: 5_000_000, + min_profit_margin_bps: 1_000, max_gas_wei: U256::from(3_000_000_000u64), scan_interval_ms: 1000, liquidatable_threshold_bps: 10_000, @@ -1552,6 +1708,7 @@ mod fork_profile_tests { Config { bot: BotConfig { min_profit_usd_1e6: 10_000, // $0.01 — lowered for fork + min_profit_margin_bps: 100, max_gas_wei: U256::from(20_000_000_000u64), scan_interval_ms: 1000, liquidatable_threshold_bps: 10_000, @@ -1942,6 +2099,7 @@ allow_public_mempool = true Config { bot: BotConfig { min_profit_usd_1e6: 1, + min_profit_margin_bps: 0, max_gas_wei: U256::from(1u64), scan_interval_ms: 1, liquidatable_threshold_bps: 10_000, @@ -2038,6 +2196,7 @@ allow_public_mempool = true Config { bot: BotConfig { min_profit_usd_1e6: 1, + min_profit_margin_bps: 0, max_gas_wei: U256::from(1u64), scan_interval_ms: 1, liquidatable_threshold_bps: 10_000, @@ -2099,4 +2258,183 @@ allow_public_mempool = true other => panic!("wrong variant: {other:?}"), } } + + /// `[protocol.]` declared as `kind = "venus"` (the default) + /// must carry a `comptroller`. Missing comptroller fails the + /// boot rather than silently defaulting to the zero address. + #[test] + fn validate_rejects_venus_kind_without_comptroller() { + let toml_str = r#" +[bot] +min_profit_usd_1e6 = 0 +max_gas_wei = "1000000000" +scan_interval_ms = 1000 +signer_key = "" + +[chain.bnb] +chain_id = 56 +ws_url = "wss://example/bnb" +http_url = "https://example/bnb" +priority_fee_gwei = 1 +allow_public_mempool = true + +[protocol.venus] +chain = "bnb" +"#; + let err = Config::from_toml_str(toml_str) + .expect_err("venus without comptroller must fail validation"); + match err { + ConfigError::Validation(m) => { + assert!(m.contains("comptroller"), "{m}"); + assert!(m.contains("venus"), "{m}"); + } + other => panic!("wrong variant: {other:?}"), + } + } + + /// `[protocol.]` with `kind = "aave_v3"` requires `pool`, + /// `oracle`, and `data_provider`. Missing any one field is rejected. + #[test] + fn validate_aave_v3_requires_pool_oracle_data_provider() { + let base = r#" +[bot] +min_profit_usd_1e6 = 0 +max_gas_wei = "1000000000" +scan_interval_ms = 1000 +signer_key = "" + +[chain.eth] +chain_id = 1 +ws_url = "wss://example/eth" +http_url = "https://example/eth" +priority_fee_gwei = 1 +allow_public_mempool = true +"#; + // Missing pool. + let toml_str = format!( + r#"{base} +[protocol.aave_v3_eth] +chain = "eth" +kind = "aave_v3" +oracle = "0x54586bE62E3c3580375aE3723C145253060Ca0C2" +data_provider = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3" +"#, + ); + let err = Config::from_toml_str(&toml_str).expect_err("aave_v3 missing pool must fail"); + assert!( + matches!(err, ConfigError::Validation(ref m) if m.contains("pool")), + "{err:?}" + ); + + // Missing oracle. + let toml_str = format!( + r#"{base} +[protocol.aave_v3_eth] +chain = "eth" +kind = "aave_v3" +pool = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" +data_provider = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3" +"#, + ); + let err = Config::from_toml_str(&toml_str).expect_err("aave_v3 missing oracle must fail"); + assert!( + matches!(err, ConfigError::Validation(ref m) if m.contains("oracle")), + "{err:?}" + ); + + // Missing data_provider. + let toml_str = format!( + r#"{base} +[protocol.aave_v3_eth] +chain = "eth" +kind = "aave_v3" +pool = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" +oracle = "0x54586bE62E3c3580375aE3723C145253060Ca0C2" +"#, + ); + let err = + Config::from_toml_str(&toml_str).expect_err("aave_v3 missing data_provider must fail"); + assert!( + matches!(err, ConfigError::Validation(ref m) if m.contains("data_provider")), + "{err:?}" + ); + + // All required fields present → loads cleanly. + let toml_str = format!( + r#"{base} +[protocol.aave_v3_eth] +chain = "eth" +kind = "aave_v3" +pool = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" +oracle = "0x54586bE62E3c3580375aE3723C145253060Ca0C2" +data_provider = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3" +"#, + ); + let cfg = Config::from_toml_str(&toml_str).expect("complete aave_v3 config must parse"); + assert_eq!(cfg.protocol["aave_v3_eth"].kind, ProtocolKind::AaveV3); + } + + /// Mixing Compound-family (`comptroller`) and Aave-family (`pool`) + /// addresses in one section is rejected — a half-typed migration + /// must fail boot rather than silently constructing the wrong + /// adapter. + #[test] + fn validate_rejects_mixed_family_fields() { + // Aave kind with stray `comptroller`. + let toml_str = r#" +[bot] +min_profit_usd_1e6 = 0 +max_gas_wei = "1000000000" +scan_interval_ms = 1000 +signer_key = "" + +[chain.eth] +chain_id = 1 +ws_url = "wss://example/eth" +http_url = "https://example/eth" +priority_fee_gwei = 1 +allow_public_mempool = true + +[protocol.aave_v3_eth] +chain = "eth" +kind = "aave_v3" +comptroller = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" +pool = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" +oracle = "0x54586bE62E3c3580375aE3723C145253060Ca0C2" +data_provider = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3" +"#; + let err = Config::from_toml_str(toml_str) + .expect_err("aave_v3 + comptroller must fail validation"); + assert!( + matches!(err, ConfigError::Validation(ref m) if m.contains("comptroller")), + "{err:?}" + ); + + // Venus kind with stray Aave-family fields. + let toml_str = r#" +[bot] +min_profit_usd_1e6 = 0 +max_gas_wei = "1000000000" +scan_interval_ms = 1000 +signer_key = "" + +[chain.bnb] +chain_id = 56 +ws_url = "wss://example/bnb" +http_url = "https://example/bnb" +priority_fee_gwei = 1 +allow_public_mempool = true + +[protocol.venus] +chain = "bnb" +kind = "venus" +comptroller = "0xfd36e2c2a6789db23113685031d7f16329158384" +pool = "0xfd36e2c2a6789db23113685031d7f16329158384" +"#; + let err = Config::from_toml_str(toml_str).expect_err("venus + pool must fail validation"); + assert!( + matches!(err, ConfigError::Validation(ref m) if m.contains("Aave-family")), + "{err:?}" + ); + } } diff --git a/crates/charon-core/src/lib.rs b/crates/charon-core/src/lib.rs index 4950723..1cf063a 100644 --- a/crates/charon-core/src/lib.rs +++ b/crates/charon-core/src/lib.rs @@ -9,7 +9,7 @@ pub mod types; pub use config::{ Config, ConfigError, DiscoveryConfig, MAX_LOG_CHUNK_BLOCKS_VALIDATION, MetricsConfig, - PoolFeeConfig, VALID_PCS_V3_FEE_TIERS, pool_fee_pair_key, + PoolFeeConfig, ProtocolConfig, ProtocolKind, VALID_PCS_V3_FEE_TIERS, pool_fee_pair_key, }; pub use flashloan::{FlashLoanError, FlashLoanProvider, FlashLoanQuote}; pub use profit::{NetProfit, Price, ProfitError, ProfitInputs, calculate_profit}; diff --git a/crates/charon-core/src/profit.rs b/crates/charon-core/src/profit.rs index ec0b7cd..9abf708 100644 --- a/crates/charon-core/src/profit.rs +++ b/crates/charon-core/src/profit.rs @@ -118,6 +118,21 @@ pub enum ProfitError { /// Net profit is positive but below the configured threshold. #[error("below threshold: net_usd_1e6={net_usd_1e6} < min_usd_1e6={min_usd_1e6}")] BelowMinThreshold { net_usd_1e6: u64, min_usd_1e6: u64 }, + /// Net profit is positive but the margin on total cost is thinner + /// than `min_profit_margin_bps`. The gate is `gross >= total_cost + /// × (1 + margin)`, equivalently `net × 10_000 >= total_cost × + /// margin_bps`. A 10% margin (`min_profit_margin_bps = 1_000`) + /// requires the swap to return at least `1.1 × total_cost`. + #[error( + "below margin: net_wei={net_wei} on total_cost_wei={total_cost_wei} \ + is {actual_bps} bps, below required {min_bps} bps" + )] + BelowMinMargin { + net_wei: U256, + total_cost_wei: U256, + actual_bps: u64, + min_bps: u16, + }, } /// Everything the calculator needs, already expressed in debt-token wei. @@ -279,18 +294,32 @@ impl ProfitInputs { /// Compute net profit for a candidate liquidation. /// -/// Returns `Err` whenever the liquidation is unprofitable — either the -/// total cost (flash fee + gas + slippage) swallows the gross swap -/// output, or the net (converted to USD_1e6 via `inputs.debt_price`) -/// falls below `min_profit_usd_1e6`. The caller is expected to drop -/// the opportunity on `Err`; no partial state is ever emitted. +/// Returns `Err` whenever the liquidation is unprofitable. Three drop +/// reasons: +/// 1. Total cost (flash fee + gas + slippage) swallows the gross swap +/// output → [`ProfitError::Unprofitable`]. +/// 2. Net profit's margin on total cost is below +/// `min_profit_margin_bps` → [`ProfitError::BelowMinMargin`]. With +/// the default `1_000` bps that means the swap must return at least +/// `1.10 × total_cost` (i.e. ≥10% margin). +/// 3. Net profit (converted to USD_1e6 via `inputs.debt_price`) is +/// below `min_profit_usd_1e6` → [`ProfitError::BelowMinThreshold`]. +/// Set the floor to `0` to disable this gate; the margin gate stays +/// primary on the post-#… schema. +/// +/// The caller is expected to drop the opportunity on `Err`; no partial +/// state is ever emitted. pub fn calculate_profit( inputs: &ProfitInputs, min_profit_usd_1e6: u64, + min_profit_margin_bps: u16, ) -> Result { if inputs.slippage_bps > 10_000 { return Err(ProfitError::InvalidBps(inputs.slippage_bps)); } + if min_profit_margin_bps > 10_000 { + return Err(ProfitError::InvalidBps(min_profit_margin_bps)); + } // Slippage is charged on the DEX swap output — the bot only pays // slippage on the swap it performs. @@ -324,11 +353,44 @@ pub fn calculate_profit( .checked_sub(total_cost_wei) .ok_or(ProfitError::Overflow)?; + // Margin gate: `net_profit × 10_000 >= total_cost × min_margin_bps`. + // Cross-multiplied form sidesteps division-by-zero in the (unrealistic + // but defensible) `total_cost_wei == 0` corner — a positive net with + // zero cost has infinite margin and trivially passes. + if min_profit_margin_bps > 0 { + let lhs = net_profit_wei + .checked_mul(U256::from(10_000u64)) + .ok_or(ProfitError::Overflow)?; + let rhs = total_cost_wei + .checked_mul(U256::from(min_profit_margin_bps)) + .ok_or(ProfitError::Overflow)?; + if lhs < rhs { + // Compute the achieved bps for the error message — + // saturating to `u64::MAX` for the absurd zero-cost case + // since the gate has already passed in that branch. + let actual_bps: u64 = if total_cost_wei.is_zero() { + u64::MAX + } else { + let scaled = net_profit_wei + .checked_mul(U256::from(10_000u64)) + .ok_or(ProfitError::Overflow)? + / total_cost_wei; + scaled.try_into().unwrap_or(u64::MAX) + }; + return Err(ProfitError::BelowMinMargin { + net_wei: net_profit_wei, + total_cost_wei, + actual_bps, + min_bps: min_profit_margin_bps, + }); + } + } + // Convert net_profit_wei to USD_1e6 for threshold compare + logs. let net_profit_usd_1e6 = wei_to_usd_1e6(net_profit_wei, inputs.debt_price, inputs.debt_decimals)?; - if net_profit_usd_1e6 < min_profit_usd_1e6 { + if min_profit_usd_1e6 > 0 && net_profit_usd_1e6 < min_profit_usd_1e6 { return Err(ProfitError::BelowMinThreshold { net_usd_1e6: net_profit_usd_1e6, min_usd_1e6: min_profit_usd_1e6, @@ -377,7 +439,7 @@ mod tests { fn healthy_liquidation_is_profitable() { let inputs = typical_inputs(); // min = $5.00 (1e6 scale) - let np = calculate_profit(&inputs, 5_000_000).expect("profitable"); + let np = calculate_profit(&inputs, 5_000_000, 0).expect("profitable"); // slippage = 1.1 BNB * 50 / 10_000 = 0.0055 BNB let expected_slippage = @@ -401,7 +463,7 @@ mod tests { fn below_threshold_is_rejected() { // Threshold of $10 000 -> $1 000 000 000 in 1e6 scale. Typical // inputs yield ~$650, nowhere near the bar. - let err = calculate_profit(&typical_inputs(), 1_000_000_000_000) + let err = calculate_profit(&typical_inputs(), 1_000_000_000_000, 0) .expect_err("should reject below threshold"); assert!(matches!(err, ProfitError::BelowMinThreshold { .. })); } @@ -416,7 +478,7 @@ mod tests { debt_price: Price::new(60_000_000_000).expect("valid"), debt_decimals: 18, }; - let err = calculate_profit(&inputs, 0).expect_err("unprofitable"); + let err = calculate_profit(&inputs, 0, 0).expect_err("unprofitable"); assert!(matches!(err, ProfitError::Unprofitable { .. })); } @@ -425,7 +487,7 @@ mod tests { let mut inputs = typical_inputs(); inputs.slippage_bps = 20_000; assert!(matches!( - calculate_profit(&inputs, 0), + calculate_profit(&inputs, 0, 0), Err(ProfitError::InvalidBps(20_000)) )); } @@ -436,12 +498,84 @@ mod tests { // 100% slippage consumes the full swap output -> unprofitable, // but the bps check itself must *pass*. inputs.slippage_bps = 10_000; - let err = calculate_profit(&inputs, 0).expect_err("100% slippage eats the whole swap"); + let err = calculate_profit(&inputs, 0, 0).expect_err("100% slippage eats the whole swap"); assert!(matches!(err, ProfitError::Unprofitable { .. })); inputs.slippage_bps = 10_001; assert!(matches!( - calculate_profit(&inputs, 0), + calculate_profit(&inputs, 0, 0), + Err(ProfitError::InvalidBps(10_001)) + )); + } + + #[test] + fn margin_gate_rejects_thin_profit() { + // total_cost = 0.0005 (fee) + 0.001 (gas) + 0 (slippage_bps=0) + // = 0.0015 BNB. Required at 10% margin: gross >= + // 0.00165 BNB. Setting gross = 0.0016 BNB ⇒ net = + // 0.0001 BNB → margin ≈ 6.67% < 10% → reject. + let inputs = ProfitInputs { + expected_swap_output_wei: U256::from(16 * (ONE_BNB / 10_000)), + flash_fee_wei: U256::from(ONE_BNB / 2_000), + gas_cost_debt_wei: U256::from(ONE_BNB / 1_000), + slippage_bps: 0, + debt_price: Price::new(60_000_000_000).expect("valid"), + debt_decimals: 18, + }; + let err = calculate_profit(&inputs, 0, 1_000) + .expect_err("~6.67% margin must be rejected at 10% gate"); + match err { + ProfitError::BelowMinMargin { + actual_bps, + min_bps, + .. + } => { + assert_eq!(min_bps, 1_000); + assert!( + actual_bps < 1_000, + "actual {actual_bps} should be below 1000" + ); + } + other => panic!("wrong variant: {other:?}"), + } + } + + #[test] + fn margin_gate_accepts_at_or_above_floor() { + // Same costs as above (0.0015 BNB total) but gross = 0.0017 BNB. + // Net = 0.0002 BNB → margin ≈ 13.33% > 10% → pass. + let inputs = ProfitInputs { + expected_swap_output_wei: U256::from(17 * (ONE_BNB / 10_000)), + flash_fee_wei: U256::from(ONE_BNB / 2_000), + gas_cost_debt_wei: U256::from(ONE_BNB / 1_000), + slippage_bps: 0, + debt_price: Price::new(60_000_000_000).expect("valid"), + debt_decimals: 18, + }; + let np = calculate_profit(&inputs, 0, 1_000).expect("13% margin must pass 10% gate"); + assert!(np.net_profit_wei > U256::ZERO); + } + + #[test] + fn margin_gate_zero_disabled() { + // 1 wei net at any cost-scale must pass when gate is disabled. + let inputs = ProfitInputs { + expected_swap_output_wei: U256::from(ONE_BNB / 1_000) + U256::from(2u64), + flash_fee_wei: U256::from(ONE_BNB / 1_000), + gas_cost_debt_wei: U256::from(0u64), + slippage_bps: 0, + debt_price: Price::new(60_000_000_000).expect("valid"), + debt_decimals: 18, + }; + let np = calculate_profit(&inputs, 0, 0).expect("margin=0 disables the gate"); + assert!(np.net_profit_wei > U256::ZERO); + } + + #[test] + fn margin_gate_invalid_bps_rejected() { + let inputs = typical_inputs(); + assert!(matches!( + calculate_profit(&inputs, 0, 10_001), Err(ProfitError::InvalidBps(10_001)) )); } @@ -456,7 +590,7 @@ mod tests { debt_price: Price::new(60_000_000_000).expect("valid"), debt_decimals: 18, }; - let np = calculate_profit(&inputs, 0).expect("profitable"); + let np = calculate_profit(&inputs, 0, 0).expect("profitable"); assert!(np.net_profit_wei > U256::ZERO); } @@ -472,7 +606,7 @@ mod tests { debt_decimals: 18, }; assert!(matches!( - calculate_profit(&inputs, 0), + calculate_profit(&inputs, 0, 0), Err(ProfitError::Overflow) )); } @@ -536,7 +670,7 @@ mod tests { assert_eq!(inputs.expected_swap_output_wei, one_point_one_bnb); assert_eq!(inputs.flash_fee_wei, flash_fee_wei); - let np = calculate_profit(&inputs, 0).expect("profitable"); + let np = calculate_profit(&inputs, 0, 0).expect("profitable"); // net = 1.1 - 0.0005 - 0.001 - (1.1*0.005) = 1.0930 BNB ~= $655.80 assert!(np.net_profit_usd_1e6 >= 655_000_000); assert!(np.net_profit_usd_1e6 <= 656_000_000); @@ -589,7 +723,7 @@ mod tests { #[test] fn with_profit_copies_net_profit_wei_into_opportunity() { let inputs = typical_inputs(); - let np = calculate_profit(&inputs, 0).expect("profitable"); + let np = calculate_profit(&inputs, 0, 0).expect("profitable"); let opp = mk_opp(U256::from(ONE_BNB), U256::from(ONE_BNB), 1_000); let out = LiquidationOpportunity::with_profit( opp.position.clone(), diff --git a/crates/charon-core/src/traits.rs b/crates/charon-core/src/traits.rs index 67da614..1218c7b 100644 --- a/crates/charon-core/src/traits.rs +++ b/crates/charon-core/src/traits.rs @@ -101,4 +101,12 @@ pub trait LendingProtocol: Send + Sync { /// Encode the ABI calldata for `CharonLiquidator.executeLiquidation(...)`. fn build_liquidation_calldata(&self, params: &LiquidationParams) -> Result>; + + /// Underlying ERC-20 addresses the adapter tracks. Used by the + /// CLI to seed `TokenMetaCache::build` with the set of tokens the + /// profit gate will need decimals + symbol metadata for. + /// + /// Returns a point-in-time snapshot. Callers that span a + /// `refresh()` boundary should re-query. + async fn underlying_tokens(&self) -> Vec
; } diff --git a/crates/charon-core/src/types.rs b/crates/charon-core/src/types.rs index a05c923..5efd6de 100644 --- a/crates/charon-core/src/types.rs +++ b/crates/charon-core/src/types.rs @@ -9,14 +9,20 @@ use std::cmp::Ordering; /// Which lending protocol a position belongs to. /// -/// Only `Venus` for v1. Additional variants are added as adapters are -/// implemented (AaveV3, CompoundV3, Morpho, …). Marked `#[non_exhaustive]` -/// so adding variants in future is not a semver-breaking change for -/// downstream exhaustive matches. +/// Marked `#[non_exhaustive]` so adding variants in future is not a +/// semver-breaking change for downstream exhaustive matches. Each +/// variant pairs with an adapter crate under `charon-protocols/` that +/// implements the [`crate::traits::LendingProtocol`] trait. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[non_exhaustive] pub enum ProtocolId { + /// Compound V2 fork on BNB Chain. Venus, + /// Aave V3 — generic across every chain Aave is deployed on + /// (Ethereum, Polygon, Arbitrum, Optimism, Base, Avalanche, …). + /// Per-chain Pool/Oracle/PoolDataProvider addresses live in + /// [`crate::config::ProtocolConfig`], not on this enum. + AaveV3, } /// A single borrow position on a lending protocol, normalized across protocols. @@ -108,6 +114,35 @@ pub enum LiquidationParams { /// Amount of debt to repay, in underlying-debt-token units (not vToken units). repay_amount: U256, }, + /// Aave V3 — encodes a `Pool.liquidationCall(collateral, debt, + /// user, debtToCover, receiveAToken)` invocation. Aave's pool is + /// the entry point (no per-market vToken-style address indirection), + /// so the underlying ERC-20 addresses are the only references the + /// adapter needs to keep. + /// + /// Same `#[non_exhaustive]` carve-out as the `Venus` variant: this + /// struct is constructed inside `charon-protocols`, so adding a + /// new field here is a semver-breaking change for that adapter + /// crate by design. + AaveV3 { + borrower: Address, + /// Underlying ERC-20 address of the collateral asset to seize. + /// Maps to Aave V3 `liquidationCall.collateralAsset`. + collateral_asset: Address, + /// Underlying ERC-20 address of the debt asset to repay. + /// Maps to Aave V3 `liquidationCall.debtAsset`. + debt_asset: Address, + /// Amount of debt to repay, in debt-asset base units. + /// `U256::MAX` would request "max" semantics on Aave; the + /// adapter clamps to `closeFactor × debt` instead so the + /// number is always concrete. + debt_to_cover: U256, + /// `false` — receive the underlying collateral after seize, + /// matching the flash-loan-and-swap pattern. + /// `true` — receive the aToken (no swap path needed). Default + /// `false` for the liquidator pipeline. + receive_a_token: bool, + }, } /// A profitable liquidation that has passed all off-chain gates and is diff --git a/crates/charon-core/tests/config_profiles.rs b/crates/charon-core/tests/config_profiles.rs index 839d258..050a571 100644 --- a/crates/charon-core/tests/config_profiles.rs +++ b/crates/charon-core/tests/config_profiles.rs @@ -80,6 +80,10 @@ fn default_profile_parses() { ("BNB_HTTP_URL", "https://example/bnb"), ("CHARON_BSC_PRIVATE_RPC_URL", "https://example/bnb-private"), ("CHARON_BSC_PRIVATE_RPC_AUTH", ""), + ("ETH_WS_URL", "wss://example/eth"), + ("ETH_HTTP_URL", "https://example/eth"), + ("CHARON_ETH_PRIVATE_RPC_URL", "https://example/eth-private"), + ("CHARON_ETH_PRIVATE_RPC_AUTH", ""), ("CHARON_SIGNER_KEY", ""), // `${CHARON_METRICS_AUTH_TOKEN}` sits inside a commented-out // line in default.toml, but `substitute_env_vars` is a raw @@ -189,6 +193,10 @@ fn fork_profile_parses_and_targets_localhost() { ("BNB_HTTP_URL", "https://example/bnb"), ("CHARON_BSC_PRIVATE_RPC_URL", "https://example/bnb-private"), ("CHARON_BSC_PRIVATE_RPC_AUTH", ""), + ("ETH_WS_URL", "wss://example/eth"), + ("ETH_HTTP_URL", "https://example/eth"), + ("CHARON_ETH_PRIVATE_RPC_URL", "https://example/eth-private"), + ("CHARON_ETH_PRIVATE_RPC_AUTH", ""), ("CHARON_SIGNER_KEY", ""), ("CHARON_METRICS_AUTH_TOKEN", ""), ]; @@ -197,12 +205,16 @@ fn fork_profile_parses_and_targets_localhost() { &default_pairs, ); let default_cfg = Config::from_toml_str(&default_raw).expect("default.toml parses"); + // Post-margin-gate schema: USD floor is disabled by default + // (set to 0) and the margin gate is the primary drop reason. + // Fork must be strictly looser than default so demo-staged + // partial positions still clear. assert!( - cfg.bot.min_profit_usd_1e6 < default_cfg.bot.min_profit_usd_1e6, - "fork profile min_profit_usd_1e6 ({}) must be strictly lower than default profile ({}) — \ + cfg.bot.min_profit_margin_bps < default_cfg.bot.min_profit_margin_bps, + "fork profile min_profit_margin_bps ({}) must be strictly lower than default profile ({}) — \ the fork is a demo-staging surface", - cfg.bot.min_profit_usd_1e6, - default_cfg.bot.min_profit_usd_1e6 + cfg.bot.min_profit_margin_bps, + default_cfg.bot.min_profit_margin_bps ); // `[liquidator.bnb]` carries the deterministic anvil dev-0 nonce-0 diff --git a/crates/charon-executor/src/builder.rs b/crates/charon-executor/src/builder.rs index 3234cd5..1810714 100644 --- a/crates/charon-executor/src/builder.rs +++ b/crates/charon-executor/src/builder.rs @@ -43,6 +43,22 @@ sol! { uint24 swapPoolFee; } + /// Solidity-side `LiquidationParams` for the Aave V3 path — must + /// match `contracts/src/CharonLiquidatorAave.sol` exactly. + /// Disjoint field set from `CharonLiquidationParams` because Aave + /// has no aToken-style indirection (the Pool address is the + /// liquidation entry point, not a per-asset token). + #[derive(Debug)] + struct CharonAaveLiquidationParams { + uint8 protocolId; + address borrower; + address debtToken; + address collateralToken; + uint256 debtToCover; + uint256 minSwapOut; + uint24 swapPoolFee; + } + /// Surface of `CharonLiquidator.sol` consumed by the builder. /// `#[sol(rpc)]` would also generate provider-bound bindings, but /// we only need the call-encoder here, so the bare interface is @@ -50,6 +66,12 @@ sol! { interface ICharonLiquidator { function executeLiquidation(CharonLiquidationParams calldata params) external; } + + /// Surface of `CharonLiquidatorAave.sol` consumed by the builder + /// when the configured liquidator is the Aave-flavored sibling. + interface ICharonLiquidatorAave { + function executeLiquidation(CharonAaveLiquidationParams calldata params) external; + } } /// Numeric protocol id matching `PROTOCOL_VENUS` in the Solidity @@ -59,6 +81,11 @@ sol! { /// pins this end-to-end through the ABI. const PROTOCOL_VENUS: u8 = 3; +/// Numeric protocol id matching `PROTOCOL_AAVE_V3` in +/// `contracts/src/CharonLiquidatorAave.sol`. Discriminator inside +/// the Aave-shaped Solidity params struct. +const PROTOCOL_AAVE_V3: u8 = 1; + /// Errors surfaced by [`TxBuilder`] when constructing or signing a /// transaction. /// @@ -179,43 +206,66 @@ impl TxBuilder { // protocol. `LiquidationParams` is `#[non_exhaustive]`, hence // the trailing wildcard arm that surfaces the miss as an // explicit `Unsupported` error rather than a panic. - let (borrower, collateral_vtoken, debt_vtoken, repay_amount) = match params { + let pool_fee = opp.swap_route.pool_fee.unwrap_or(0); + if pool_fee == 0 { + return Err(BuilderError::MissingSwapPoolFee); + } + let bytes: Bytes = match params { LiquidationParams::Venus { borrower, collateral_vtoken, debt_vtoken, repay_amount, - } => (borrower, collateral_vtoken, debt_vtoken, repay_amount), + } => { + let sol_params = CharonLiquidationParams { + protocolId: PROTOCOL_VENUS, + borrower: *borrower, + debtToken: opp.position.debt_token, + collateralToken: opp.position.collateral_token, + debtVToken: *debt_vtoken, + collateralVToken: *collateral_vtoken, + repayAmount: *repay_amount, + minSwapOut: opp.swap_route.min_amount_out, + swapPoolFee: U24::from(pool_fee), + }; + let call = ICharonLiquidator::executeLiquidationCall { params: sol_params }; + let bytes: Bytes = call.abi_encode().into(); + debug!( + len = bytes.len(), + borrower = %borrower, + "executeLiquidation (Venus) calldata encoded" + ); + bytes + } + LiquidationParams::AaveV3 { + borrower, + collateral_asset, + debt_asset, + debt_to_cover, + receive_a_token: _, + } => { + let sol_params = CharonAaveLiquidationParams { + protocolId: PROTOCOL_AAVE_V3, + borrower: *borrower, + debtToken: *debt_asset, + collateralToken: *collateral_asset, + debtToCover: *debt_to_cover, + minSwapOut: opp.swap_route.min_amount_out, + swapPoolFee: U24::from(pool_fee), + }; + let call = ICharonLiquidatorAave::executeLiquidationCall { params: sol_params }; + let bytes: Bytes = call.abi_encode().into(); + debug!( + len = bytes.len(), + borrower = %borrower, + "executeLiquidation (Aave V3) calldata encoded" + ); + bytes + } other => { return Err(BuilderError::UnsupportedProtocol(format!("{other:?}"))); } }; - - let pool_fee = opp.swap_route.pool_fee.unwrap_or(0); - if pool_fee == 0 { - return Err(BuilderError::MissingSwapPoolFee); - } - - let sol_params = CharonLiquidationParams { - protocolId: PROTOCOL_VENUS, - borrower: *borrower, - debtToken: opp.position.debt_token, - collateralToken: opp.position.collateral_token, - debtVToken: *debt_vtoken, - collateralVToken: *collateral_vtoken, - repayAmount: *repay_amount, - minSwapOut: opp.swap_route.min_amount_out, - swapPoolFee: U24::from(pool_fee), - }; - - let call = ICharonLiquidator::executeLiquidationCall { params: sol_params }; - let bytes: Bytes = call.abi_encode().into(); - - debug!( - len = bytes.len(), - borrower = %borrower, - "executeLiquidation calldata encoded" - ); Ok(bytes) } diff --git a/crates/charon-flashloan/src/aave.rs b/crates/charon-flashloan/src/aave.rs index 852b042..92ab238 100644 --- a/crates/charon-flashloan/src/aave.rs +++ b/crates/charon-flashloan/src/aave.rs @@ -175,10 +175,15 @@ impl AaveFlashLoan { .get_chain_id() .await .context("Aave V3: eth_chainId failed")?; - anyhow::ensure!( - chain_id == BSC_CHAIN_ID, - "Aave V3 adapter is BSC-only for v0.1: expected chain_id {BSC_CHAIN_ID}, got {chain_id}" - ); + // Aave V3 is multi-chain (Ethereum, Polygon, Arbitrum, Optimism, + // Base, Avalanche, BSC, …). The previous strict `chain_id == + // BSC_CHAIN_ID` gate has been relaxed because the adapter only + // calls trait-shaped Aave V3 methods that work uniformly across + // every Aave V3 deployment. Per-chain validation now lives at + // the config layer (`Config::validate` enforces a real + // `[chain.]` reference and a non-zero `pool` per + // `[flashloan.]`). + let _ = (chain_id, BSC_CHAIN_ID); let pool_if = IAaveV3Pool::new(pool, provider.clone()); let premium = pool_if diff --git a/crates/charon-protocols/src/aave_v3.rs b/crates/charon-protocols/src/aave_v3.rs new file mode 100644 index 0000000..ad01ec9 --- /dev/null +++ b/crates/charon-protocols/src/aave_v3.rs @@ -0,0 +1,586 @@ +//! Aave V3 lending-protocol adapter. +//! +//! Aave V3 is deployed across every major EVM chain (Ethereum, +//! Polygon, Arbitrum, Optimism, Base, Avalanche, …). Each deployment +//! exposes the same three contracts the adapter needs: +//! +//! - **Pool** (`0x...Pool`) — the entry point. Holds +//! `getUserAccountData(user)` (aggregate health/collateral/debt in +//! `BASE_CURRENCY` units, usually USD-1e8) and `getReservesList()` +//! (every listed reserve asset). +//! - **AaveOracle** (`0x...Oracle`) — `getAssetPrice(asset)` returns a +//! USD-1e8 price for any reserve asset. +//! - **PoolDataProvider** (`0x...PoolDataProvider`) — per-asset config +//! (`liquidationBonus`, `decimals`) and per-user breakdown +//! (`getUserReserveData`). +//! +//! The adapter walks `getReservesList()` once at connect time and +//! caches `(asset, decimals, liquidation_bonus_bps)` for every active +//! reserve. Per-borrower scans then issue one `getUserReserveData` +//! call per cached reserve — the same shape Venus uses for its vToken +//! list. +//! +//! Liquidation calldata encodes +//! `Pool.liquidationCall(collateralAsset, debtAsset, user, +//! debtToCover, receiveAToken=false)`. The CharonLiquidator contract +//! is responsible for routing this call through the flash-loan +//! callback — see #427 follow-up for the contract-side codepath. + +use std::collections::HashMap; +use std::sync::Arc; + +use alloy::eips::{BlockId, BlockNumberOrTag}; +use alloy::primitives::{Address, U256}; +use alloy::providers::{Provider, RootProvider}; +use alloy::pubsub::PubSubFrontend; +use alloy::sol; +use alloy::sol_types::SolCall; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use charon_core::{ + LendingProtocol, LendingProtocolError, LendingResult, LiquidationParams, Position, ProtocolId, +}; +use futures_util::stream::{FuturesUnordered, StreamExt}; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +/// Aave V3 close factor below the dynamic-50% threshold = 50% of debt. +/// Encoded 1e18-scaled, matching the rest of the bot's mantissa convention. +/// Source: Aave V3 `LIQUIDATION_CLOSE_FACTOR_PERCENT = 5000` (bps of 1e4). +const HALF_CLOSE_FACTOR_1E18: u128 = 500_000_000_000_000_000; // 0.5e18 + +/// Aave V3 close factor above the `CLOSE_FACTOR_HF_THRESHOLD` = 100% of debt. +/// Used when the borrower's health factor drops below 0.95e18 (aka the +/// "deep underwater" regime, where Aave allows full debt absorption in +/// a single call). +const FULL_CLOSE_FACTOR_1E18: u128 = 1_000_000_000_000_000_000; // 1e18 + +/// Aave V3 dynamic close-factor threshold = 0.95e18. When the borrower's +/// health factor is below this, [`Self::get_close_factor`] returns +/// [`FULL_CLOSE_FACTOR_1E18`]; otherwise it returns +/// [`HALF_CLOSE_FACTOR_1E18`]. +/// +/// Source: Aave V3 `CLOSE_FACTOR_HF_THRESHOLD = 0.95e18`. +const CLOSE_FACTOR_HF_THRESHOLD_1E18: u128 = 950_000_000_000_000_000; + +/// 1e18 — reused constant. +fn one_e18() -> U256 { + U256::from(10u64).pow(U256::from(18u64)) +} + +/// On-chain ABI bindings used by the Aave V3 adapter. +pub mod abi { + use super::sol; + + sol! { + /// Aave V3 Pool — risk engine and liquidation entry point. + #[sol(rpc)] + interface IAaveV3Pool { + /// Aggregate health for `user`. All amounts in `BASE_CURRENCY` + /// units (USD with 8 decimals on every deployment to date). + /// `healthFactor` is 1e18-scaled (`< 1e18` ⇒ liquidatable). + function getUserAccountData(address user) external view returns ( + uint256 totalCollateralBase, + uint256 totalDebtBase, + uint256 availableBorrowsBase, + uint256 currentLiquidationThreshold, + uint256 ltv, + uint256 healthFactor + ); + + /// Every reserve asset registered on the Pool. + function getReservesList() external view returns (address[] memory); + + /// Liquidate `user`. Repays `debtToCover` of `debtAsset` and + /// seizes the corresponding amount of `collateralAsset`. + /// `receiveAToken=false` requests the underlying collateral + /// (which the liquidator then swaps back to `debtAsset` to + /// repay the flash loan). + function liquidationCall( + address collateralAsset, + address debtAsset, + address user, + uint256 debtToCover, + bool receiveAToken + ) external; + } + + /// Aave V3 PoolDataProvider — per-asset reserve config + per-user + /// reserve breakdown. Selectors and tuple shapes match Aave V3 + /// `protocol-v3-aave-pool-data-provider` v1 (PoolDataProvider). + #[sol(rpc)] + interface IPoolDataProvider { + /// Per-asset reserve configuration. `liquidationBonus` is in + /// bps of 1e4 (e.g. `10500` = 5% bonus). + function getReserveConfigurationData(address asset) external view returns ( + uint256 decimals, + uint256 ltv, + uint256 liquidationThreshold, + uint256 liquidationBonus, + uint256 reserveFactor, + bool usageAsCollateralEnabled, + bool borrowingEnabled, + bool stableBorrowRateEnabled, + bool isActive, + bool isFrozen + ); + + /// Per-user reserve breakdown — collateral aToken balance, + /// outstanding stable+variable debt, etc. Both debt fields + /// are in the asset's base units; sum them for "total debt + /// in this asset". + function getUserReserveData(address asset, address user) external view returns ( + uint256 currentATokenBalance, + uint256 currentStableDebt, + uint256 currentVariableDebt, + uint256 principalStableDebt, + uint256 scaledVariableDebt, + uint256 stableBorrowRate, + uint256 liquidityRate, + uint40 stableRateLastUpdated, + bool usageAsCollateralEnabled + ); + } + + /// Aave V3 AaveOracle — USD-1e8 prices for reserve assets. + #[sol(rpc)] + interface IAaveOracle { + function getAssetPrice(address asset) external view returns (uint256); + } + } +} + +pub type ChainProvider = Arc>; + +/// Per-reserve metadata cached at connect time. Refreshed on demand +/// via [`AaveV3Adapter::refresh`]. +#[derive(Debug, Clone)] +struct ReserveMeta { + /// Underlying asset address (the address used in every Aave call). + asset: Address, + /// Underlying token decimals — needed to scale per-asset balances + /// into a common USD value during the largest-pair walk. + decimals: u8, + /// Liquidation bonus in bps of 1e4 (e.g. `500` = 5%). Aave reports + /// `10500` ⇒ 5% bonus; the adapter normalizes to "bps of bonus only" + /// (= raw - 10000) so it slots into [`Position::liquidation_bonus_bps`] + /// the same way Venus's value does. + liquidation_bonus_bps: u16, +} + +#[derive(Debug, Clone)] +struct AaveSnapshot { + reserves: Vec, + /// `asset → index in reserves` for O(1) per-borrower lookups. + by_asset: HashMap, +} + +/// Aave V3 adapter — see module docs. +#[derive(Debug, Clone)] +pub struct AaveV3Adapter { + pool: Address, + oracle: Address, + data_provider: Address, + chain_id: u64, + snapshot: Arc>, + provider: ChainProvider, +} + +impl AaveV3Adapter { + /// Connect to the Aave V3 Pool / Oracle / PoolDataProvider trio + /// and snapshot the active reserve list. + pub async fn connect( + provider: ChainProvider, + pool: Address, + oracle: Address, + data_provider: Address, + ) -> Result { + debug!(%pool, %oracle, %data_provider, "connecting Aave V3 adapter"); + + let chain_id = provider + .get_chain_id() + .await + .context("Aave V3: eth_chainId failed")?; + + let snapshot = Self::take_snapshot(&provider, pool, data_provider).await?; + info!( + %pool, %oracle, %data_provider, + chain_id, + reserve_count = snapshot.reserves.len(), + "Aave V3 adapter connected" + ); + + Ok(Self { + pool, + oracle, + data_provider, + chain_id, + snapshot: Arc::new(RwLock::new(snapshot)), + provider, + }) + } + + /// Re-fetch the reserve list and per-asset config. Safe to call on + /// a timer or in response to `ReserveInitialized` / `ReserveDataUpdated` + /// events. + pub async fn refresh(&self) -> Result<()> { + let fresh = Self::take_snapshot(&self.provider, self.pool, self.data_provider).await?; + let mut guard = self.snapshot.write().await; + *guard = fresh; + debug!("Aave V3 snapshot refreshed"); + Ok(()) + } + + async fn take_snapshot( + provider: &ChainProvider, + pool: Address, + data_provider: Address, + ) -> Result { + let pool_c = abi::IAaveV3Pool::new(pool, provider.clone()); + let dp = abi::IPoolDataProvider::new(data_provider, provider.clone()); + + let reserves = pool_c + .getReservesList() + .call() + .await + .context("Aave V3: Pool.getReservesList() failed")? + ._0; + + let mut metas = Vec::with_capacity(reserves.len()); + let mut by_asset = HashMap::with_capacity(reserves.len()); + for asset in reserves { + match dp.getReserveConfigurationData(asset).call().await { + Ok(cfg) => { + if !cfg.isActive || cfg.isFrozen { + continue; + } + let decimals = u8::try_from(cfg.decimals).unwrap_or(18); + // Aave's `liquidationBonus` is `10000 + bonus_bps`; the + // canonical BPS field on `Position` carries only the + // bonus part. A reserve with `liquidationBonus < 10000` + // (which Aave never ships, but defensive) folds to 0. + let raw = u32::try_from(cfg.liquidationBonus).unwrap_or(10_000); + let bonus_bps = u16::try_from(raw.saturating_sub(10_000)).unwrap_or(0); + by_asset.insert(asset, metas.len()); + metas.push(ReserveMeta { + asset, + decimals, + liquidation_bonus_bps: bonus_bps, + }); + } + Err(err) => { + warn!(%asset, ?err, "Aave V3: getReserveConfigurationData failed — skipping"); + } + } + } + + Ok(AaveSnapshot { + reserves: metas, + by_asset, + }) + } + + /// Pool address — exposed so the CLI can log it next to the chain + /// in the startup banner. + pub fn pool(&self) -> Address { + self.pool + } + + pub fn chain_id(&self) -> u64 { + self.chain_id + } + + /// Walk every cached reserve and return the (debt asset, debt amount) + /// + (collateral asset, collateral amount) pair with the largest + /// USD value, both anchored to `block` for snapshot consistency. + async fn fetch_position_inner( + &self, + borrower: Address, + block: BlockNumberOrTag, + ) -> Result> { + let block_id: BlockId = block.into(); + let snap = self.snapshot.read().await.clone(); + let pool_c = abi::IAaveV3Pool::new(self.pool, self.provider.clone()); + let dp = abi::IPoolDataProvider::new(self.data_provider, self.provider.clone()); + let oracle = abi::IAaveOracle::new(self.oracle, self.provider.clone()); + + let acct = pool_c + .getUserAccountData(borrower) + .block(block_id) + .call() + .await + .with_context(|| format!("Aave V3: getUserAccountData({borrower}) failed"))?; + let health_factor = acct.healthFactor; + let total_debt_base = acct.totalDebtBase; + // Aave returns `type(uint256).max` for HF when there is no debt; + // also returns 0 totalDebtBase. Either condition = no position. + if total_debt_base.is_zero() { + return Ok(None); + } + + let mut best_debt: Option<(Address, U256, U256)> = None; + let mut best_coll: Option<(Address, U256, U256)> = None; + + for meta in &snap.reserves { + let user_reserve = match dp + .getUserReserveData(meta.asset, borrower) + .block(block_id) + .call() + .await + { + Ok(r) => r, + Err(err) => { + warn!(asset = %meta.asset, %borrower, ?err, "Aave V3: getUserReserveData failed"); + continue; + } + }; + let collateral_balance = user_reserve.currentATokenBalance; + let debt_balance = user_reserve + .currentStableDebt + .saturating_add(user_reserve.currentVariableDebt); + + // Skip reserves the user touches in neither role. + if collateral_balance.is_zero() && debt_balance.is_zero() { + continue; + } + + let price = match oracle + .getAssetPrice(meta.asset) + .block(block_id) + .call() + .await + { + Ok(r) => r._0, + Err(err) => { + warn!(asset = %meta.asset, ?err, "Aave V3: oracle.getAssetPrice failed"); + continue; + } + }; + // Normalize per-asset balances to a common USD-1e8 magnitude: + // value_usd_1e8 = balance × price / 10^decimals. + let scale = U256::from(10u64).pow(U256::from(meta.decimals)); + let debt_val = debt_balance.saturating_mul(price) / scale; + let coll_val = collateral_balance.saturating_mul(price) / scale; + + if debt_balance > U256::ZERO && best_debt.as_ref().is_none_or(|x| debt_val > x.2) { + best_debt = Some((meta.asset, debt_balance, debt_val)); + } + if collateral_balance > U256::ZERO + && user_reserve.usageAsCollateralEnabled + && best_coll.as_ref().is_none_or(|x| coll_val > x.2) + { + best_coll = Some((meta.asset, collateral_balance, coll_val)); + } + } + + let Some((debt_token, debt_amount, _)) = best_debt else { + return Ok(None); + }; + let Some((collateral_token, collateral_amount, _)) = best_coll else { + return Ok(None); + }; + + let liquidation_bonus_bps = snap + .by_asset + .get(&collateral_token) + .map(|i| snap.reserves[*i].liquidation_bonus_bps) + .unwrap_or(0); + + Ok(Some(Position { + protocol: ProtocolId::AaveV3, + chain_id: self.chain_id, + borrower, + collateral_token, + debt_token, + collateral_amount, + debt_amount, + health_factor, + liquidation_bonus_bps, + })) + } +} + +#[async_trait] +impl LendingProtocol for AaveV3Adapter { + fn id(&self) -> ProtocolId { + ProtocolId::AaveV3 + } + + async fn fetch_positions( + &self, + borrowers: &[Address], + block: BlockNumberOrTag, + ) -> LendingResult> { + let mut futs = FuturesUnordered::new(); + for &borrower in borrowers { + futs.push(async move { (borrower, self.fetch_position_inner(borrower, block).await) }); + } + let mut out = Vec::with_capacity(borrowers.len()); + while let Some((borrower, res)) = futs.next().await { + match res { + Ok(Some(pos)) => out.push(pos), + Ok(None) => {} + Err(err) => warn!(%borrower, ?err, "Aave V3 fetch_position failed, skipping"), + } + } + Ok(out) + } + + async fn get_health_factor( + &self, + borrower: Address, + block: BlockNumberOrTag, + ) -> LendingResult { + let block_id: BlockId = block.into(); + let pool_c = abi::IAaveV3Pool::new(self.pool, self.provider.clone()); + let acct = pool_c + .getUserAccountData(borrower) + .block(block_id) + .call() + .await + .map_err(|e| { + LendingProtocolError::Rpc(format!("getUserAccountData({borrower}): {e}")) + })?; + // `healthFactor` is `type(uint256).max` when the borrower has + // no debt; convention is to map "no debt" to a generously-large + // 2e18 marker, mirroring the Venus adapter. + if acct.totalDebtBase.is_zero() { + return Ok(one_e18().saturating_mul(U256::from(2u64))); + } + Ok(acct.healthFactor) + } + + /// Aave V3's close factor is dynamic: `0.5e18` when HF ≥ + /// `0.95e18`, else `1e18` (full close). This trait method is + /// synchronous and `market`-keyed but the underlying value is + /// global + HF-dependent — we conservatively return the half value + /// because the repay-amount path inside [`Self::get_liquidation_params`] + /// reads the borrower's HF and applies the full-close override + /// there. + fn get_close_factor(&self, _market: Address) -> LendingResult { + Ok(U256::from(HALF_CLOSE_FACTOR_1E18)) + } + + /// Per-reserve liquidation bonus in 1e18-mantissa form (e.g. + /// `1.05e18` = 5% bonus). Falls back to `1e18` (no bonus) for + /// unknown reserves rather than erroring — the profit calculator + /// already handles a zero-bonus opportunity by dropping it. + async fn get_liquidation_incentive(&self, collateral_market: Address) -> LendingResult { + let snap = self.snapshot.read().await; + let bps = snap + .by_asset + .get(&collateral_market) + .map(|i| snap.reserves[*i].liquidation_bonus_bps) + .unwrap_or(0); + let one_e14 = U256::from(10u64).pow(U256::from(14u64)); + Ok(one_e18().saturating_add(U256::from(bps).saturating_mul(one_e14))) + } + + fn get_liquidation_params(&self, position: &Position) -> LendingResult { + // Aave V3's dynamic close factor: full repayment when the + // borrower's HF is below `CLOSE_FACTOR_HF_THRESHOLD = 0.95e18`, + // else half. The position carries a fresh `health_factor` from + // [`Self::fetch_position_inner`], which is the same value Aave + // reads on-chain — so we can compute the same branch + // off-chain without a second RPC. + let close_factor = if position.health_factor < U256::from(CLOSE_FACTOR_HF_THRESHOLD_1E18) { + U256::from(FULL_CLOSE_FACTOR_1E18) + } else { + U256::from(HALF_CLOSE_FACTOR_1E18) + }; + let scale = one_e18(); + let debt_to_cover = position + .debt_amount + .checked_mul(close_factor) + .ok_or_else(|| { + LendingProtocolError::ProtocolState("Aave V3: repay-amount overflow".into()) + })? + / scale; + if debt_to_cover.is_zero() { + return Err(LendingProtocolError::InvalidPosition( + "Aave V3: computed debt_to_cover is zero (debt or close_factor is zero)".into(), + )); + } + Ok(LiquidationParams::AaveV3 { + borrower: position.borrower, + collateral_asset: position.collateral_token, + debt_asset: position.debt_token, + debt_to_cover, + receive_a_token: false, + }) + } + + fn build_liquidation_calldata(&self, params: &LiquidationParams) -> LendingResult> { + encode_liquidation_call_calldata(params) + } + + async fn underlying_tokens(&self) -> Vec
{ + self.snapshot + .read() + .await + .reserves + .iter() + .map(|r| r.asset) + .collect() + } +} + +fn encode_liquidation_call_calldata(params: &LiquidationParams) -> LendingResult> { + let LiquidationParams::AaveV3 { + borrower, + collateral_asset, + debt_asset, + debt_to_cover, + receive_a_token, + } = params + else { + return Err(LendingProtocolError::ProtocolState( + "encode_liquidation_call_calldata called with non-AaveV3 LiquidationParams".into(), + )); + }; + let call = abi::IAaveV3Pool::liquidationCallCall { + collateralAsset: *collateral_asset, + debtAsset: *debt_asset, + user: *borrower, + debtToCover: *debt_to_cover, + receiveAToken: *receive_a_token, + }; + Ok(call.abi_encode()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::address; + + #[test] + fn liquidation_call_calldata_has_correct_selector() { + let params = LiquidationParams::AaveV3 { + borrower: address!("1111111111111111111111111111111111111111"), + collateral_asset: address!("2222222222222222222222222222222222222222"), + debt_asset: address!("3333333333333333333333333333333333333333"), + debt_to_cover: U256::from(42u64), + receive_a_token: false, + }; + let data = encode_liquidation_call_calldata(¶ms).expect("encode"); + assert_eq!( + &data[..4], + &abi::IAaveV3Pool::liquidationCallCall::SELECTOR, + "selector mismatch — check ABI definition order" + ); + // 5 args: 3 × address (32B each) + 1 × uint256 (32B) + 1 × bool (32B padded). + assert_eq!(data.len(), 4 + 32 * 5); + } + + #[test] + fn build_liquidation_calldata_rejects_wrong_variant() { + let params = LiquidationParams::Venus { + borrower: address!("1111111111111111111111111111111111111111"), + collateral_vtoken: address!("2222222222222222222222222222222222222222"), + debt_vtoken: address!("3333333333333333333333333333333333333333"), + repay_amount: U256::from(1u64), + }; + let err = encode_liquidation_call_calldata(¶ms).expect_err("must reject Venus variant"); + assert!(matches!(err, LendingProtocolError::ProtocolState(_))); + } +} diff --git a/crates/charon-protocols/src/lib.rs b/crates/charon-protocols/src/lib.rs index 5238be7..87c8b7d 100644 --- a/crates/charon-protocols/src/lib.rs +++ b/crates/charon-protocols/src/lib.rs @@ -6,11 +6,89 @@ //! specific RPCs directly, so adding a new protocol is a self-contained //! change here with no scanner edits required. //! -//! For v0.1 only the Venus adapter is wired up; Aave / Compound / Morpho -//! adapters land in later milestones. +//! Adding a new protocol: +//! 1. Create a new module (`my_protocol.rs`). +//! 2. Implement [`LendingProtocol`](charon_core::LendingProtocol) for +//! your adapter struct. +//! 3. Add a [`ProtocolKind`](charon_core::ProtocolKind) variant in +//! `charon-core::config` and a per-kind config-validation block. +//! 4. Add a branch to [`build_lending_adapter`] below so the CLI can +//! construct your adapter from a `[protocol.]` section. +//! +//! No edits to the scanner / executor / profit calculator are +//! required — the trait is the contract. + +use std::sync::Arc; +use alloy::providers::RootProvider; +use alloy::pubsub::PubSubFrontend; +use anyhow::{Context, Result}; +use charon_core::{LendingProtocol, ProtocolConfig, ProtocolKind}; + +pub mod aave_v3; pub mod multicall; pub mod venus; +pub use aave_v3::AaveV3Adapter; pub use multicall::{InnerCall, InnerResult, MAX_CALLS_PER_BATCH, MULTICALL3_ADDRESS, chunk_calls}; pub use venus::VenusAdapter; + +/// Construct the right [`LendingProtocol`] adapter for a parsed +/// `[protocol.]` section. +/// +/// The CLI calls this once per configured protocol. `name` is the +/// section key (used only for context in error messages); `provider` +/// is the per-chain WS provider already established by the chain +/// loader. +/// +/// Adding a new protocol = adding a new arm here. The signature is +/// stable; downstream callers only see `Arc`. +pub async fn build_lending_adapter( + name: &str, + cfg: &ProtocolConfig, + provider: Arc>, +) -> Result> { + match cfg.kind { + ProtocolKind::Venus => { + // `Config::validate` already enforces `comptroller.is_some()` + // when `kind = Venus`, so the unwrap is invariant-backed — + // the panic message is a programmer-error alarm, not a + // runtime path. + let comptroller = cfg.comptroller.with_context(|| { + format!( + "protocol `{name}` (kind = venus): missing comptroller \ + (validation gate should have caught this)" + ) + })?; + let adapter = VenusAdapter::connect(provider, comptroller) + .await + .with_context(|| format!("protocol `{name}`: VenusAdapter::connect failed"))?; + Ok(Arc::new(adapter)) + } + ProtocolKind::AaveV3 => { + let pool = cfg + .pool + .with_context(|| format!("protocol `{name}` (kind = aave_v3): missing pool"))?; + let oracle = cfg + .oracle + .with_context(|| format!("protocol `{name}` (kind = aave_v3): missing oracle"))?; + let data_provider = cfg.data_provider.with_context(|| { + format!("protocol `{name}` (kind = aave_v3): missing data_provider") + })?; + let adapter = AaveV3Adapter::connect(provider, pool, oracle, data_provider) + .await + .with_context(|| format!("protocol `{name}`: AaveV3Adapter::connect failed"))?; + Ok(Arc::new(adapter)) + } + // `ProtocolKind` is `#[non_exhaustive]` (semver-protected + // for downstream `match`es) so this wildcard arm is required. + // Hitting it means the enum gained a variant without a + // corresponding factory branch — a programmer error, surfaced + // at first use instead of compiling silently into an + // unreachable path. + other => anyhow::bail!( + "protocol `{name}`: kind {other:?} has no factory branch — \ + add a match arm in `build_lending_adapter`" + ), + } +} diff --git a/crates/charon-protocols/src/venus.rs b/crates/charon-protocols/src/venus.rs index 33bf667..15d6e4c 100644 --- a/crates/charon-protocols/src/venus.rs +++ b/crates/charon-protocols/src/venus.rs @@ -303,20 +303,11 @@ impl VenusAdapter { pub async fn markets(&self) -> Vec
{ self.snapshot.read().await.markets.clone() } - /// Underlying ERC-20 addresses currently known to the adapter. - /// Used by `TokenMetaCache::build` to discover the set of tokens - /// the profit gate will need metadata for. The returned vector - /// is a point-in-time snapshot; callers should rebuild if they - /// run past a `refresh()` boundary. - pub async fn underlying_tokens(&self) -> Vec
{ - self.snapshot - .read() - .await - .underlying_to_vtoken - .keys() - .copied() - .collect() - } + // `underlying_tokens` lives on the `LendingProtocol` trait impl + // below so the CLI's `TokenMetaCache::build` site can call it via + // `Arc`. Inherent inherent helper retained + // via the trait method (callers reach it through Deref-style + // dispatch on the concrete `VenusAdapter` type too). pub async fn oracle(&self) -> Address { self.snapshot.read().await.oracle } @@ -669,6 +660,16 @@ impl LendingProtocol for VenusAdapter { fn build_liquidation_calldata(&self, params: &LiquidationParams) -> LendingResult> { encode_liquidate_borrow_calldata(params) } + + async fn underlying_tokens(&self) -> Vec
{ + self.snapshot + .read() + .await + .underlying_to_vtoken + .keys() + .copied() + .collect() + } } fn encode_liquidate_borrow_calldata(params: &LiquidationParams) -> LendingResult> { diff --git a/crates/charon-protocols/tests/aave_v3_connect.rs b/crates/charon-protocols/tests/aave_v3_connect.rs new file mode 100644 index 0000000..91c2137 --- /dev/null +++ b/crates/charon-protocols/tests/aave_v3_connect.rs @@ -0,0 +1,63 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +//! Live connectivity smoke test for `AaveV3Adapter::connect` against +//! Aave V3 on Ethereum mainnet. +//! +//! Skipped unless `ETH_WS_URL` is set — CI / offline runs see no +//! failure, local dev gets a real Ethereum handshake. Verifies the +//! Pool / Oracle / PoolDataProvider trio reachable + the reserve list +//! snapshot is non-empty + chain id is 1. + +use std::str::FromStr; +use std::sync::Arc; + +use alloy::primitives::Address; +use alloy::providers::{ProviderBuilder, WsConnect}; +use charon_core::{LendingProtocol, ProtocolId}; +use charon_protocols::AaveV3Adapter; + +/// Aave V3 Pool on Ethereum mainnet. +const AAVE_V3_POOL_ETH: &str = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"; + +/// Aave V3 AaveOracle on Ethereum mainnet. +const AAVE_V3_ORACLE_ETH: &str = "0x54586bE62E3c3580375aE3723C145253060Ca0C2"; + +/// Aave V3 PoolDataProvider on Ethereum mainnet (v3.1 deployment). +const AAVE_V3_DATA_PROVIDER_ETH: &str = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3"; + +#[tokio::test] +async fn connect_against_eth_snapshots_reserves() { + let _ = dotenvy::dotenv(); + let Ok(ws_url) = std::env::var("ETH_WS_URL") else { + eprintln!("skipping: ETH_WS_URL not set"); + return; + }; + + let provider = ProviderBuilder::new() + .on_ws(WsConnect::new(ws_url)) + .await + .expect("ws connect"); + let pool = Address::from_str(AAVE_V3_POOL_ETH).unwrap(); + let oracle = Address::from_str(AAVE_V3_ORACLE_ETH).unwrap(); + let data_provider = Address::from_str(AAVE_V3_DATA_PROVIDER_ETH).unwrap(); + + let adapter = AaveV3Adapter::connect(Arc::new(provider), pool, oracle, data_provider) + .await + .expect("aave v3 connect"); + + assert_eq!( + adapter.id(), + ProtocolId::AaveV3, + "id must report AaveV3 variant" + ); + assert_eq!(adapter.chain_id(), 1, "Ethereum mainnet chain_id must be 1"); + assert_eq!( + adapter.pool(), + pool, + "pool getter must round-trip the constructor arg" + ); + assert!( + !adapter.underlying_tokens().await.is_empty(), + "Aave V3 Pool on Ethereum must expose at least one active reserve" + ); +} diff --git a/crates/charon-protocols/tests/aave_v3_fetch.rs b/crates/charon-protocols/tests/aave_v3_fetch.rs new file mode 100644 index 0000000..d60e12e --- /dev/null +++ b/crates/charon-protocols/tests/aave_v3_fetch.rs @@ -0,0 +1,96 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +//! Live `fetch_positions` smoke test against Aave V3 on Ethereum mainnet. +//! +//! Skipped without `ETH_WS_URL`. Verifies the per-borrower walk +//! (Pool.getUserAccountData + per-reserve PoolDataProvider.getUserReserveData +//! + AaveOracle.getAssetPrice) survives real on-chain state without +//! panicking and returns well-formed `Position` structs (or an empty +//! vec for addresses with no Aave activity). + +use std::str::FromStr; +use std::sync::Arc; + +use alloy::eips::BlockNumberOrTag; +use alloy::primitives::Address; +use alloy::providers::{ProviderBuilder, WsConnect}; +use charon_core::{LendingProtocol, ProtocolId}; +use charon_protocols::AaveV3Adapter; + +const AAVE_V3_POOL_ETH: &str = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"; +const AAVE_V3_ORACLE_ETH: &str = "0x54586bE62E3c3580375aE3723C145253060Ca0C2"; +const AAVE_V3_DATA_PROVIDER_ETH: &str = "0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3"; + +/// An address with no Aave V3 interaction — should yield an empty result. +const EMPTY_ADDRESS: &str = "0x000000000000000000000000000000000000dEaD"; + +#[tokio::test] +async fn fetch_positions_returns_ok_for_empty_address() { + let _ = dotenvy::dotenv(); + let Ok(ws_url) = std::env::var("ETH_WS_URL") else { + eprintln!("skipping: ETH_WS_URL not set"); + return; + }; + + let provider = ProviderBuilder::new() + .on_ws(WsConnect::new(ws_url)) + .await + .expect("ws connect"); + let pool = Address::from_str(AAVE_V3_POOL_ETH).unwrap(); + let oracle = Address::from_str(AAVE_V3_ORACLE_ETH).unwrap(); + let data_provider = Address::from_str(AAVE_V3_DATA_PROVIDER_ETH).unwrap(); + + let adapter = AaveV3Adapter::connect(Arc::new(provider), pool, oracle, data_provider) + .await + .expect("aave v3 connect"); + + let empty = Address::from_str(EMPTY_ADDRESS).unwrap(); + let positions = adapter + .fetch_positions(&[empty], BlockNumberOrTag::Latest) + .await + .expect("fetch_positions should not error on a clean address"); + + // Valid outcomes: no positions at all, or a Position whose fields + // reflect whatever state the address has. Either way, no panic. + for p in &positions { + assert_eq!(p.protocol, ProtocolId::AaveV3); + assert_eq!(p.chain_id, 1); + assert_eq!(p.borrower, empty); + } +} + +#[tokio::test] +async fn get_health_factor_returns_no_debt_marker_for_empty_address() { + let _ = dotenvy::dotenv(); + let Ok(ws_url) = std::env::var("ETH_WS_URL") else { + eprintln!("skipping: ETH_WS_URL not set"); + return; + }; + + let provider = ProviderBuilder::new() + .on_ws(WsConnect::new(ws_url)) + .await + .expect("ws connect"); + let pool = Address::from_str(AAVE_V3_POOL_ETH).unwrap(); + let oracle = Address::from_str(AAVE_V3_ORACLE_ETH).unwrap(); + let data_provider = Address::from_str(AAVE_V3_DATA_PROVIDER_ETH).unwrap(); + + let adapter = AaveV3Adapter::connect(Arc::new(provider), pool, oracle, data_provider) + .await + .expect("aave v3 connect"); + + let empty = Address::from_str(EMPTY_ADDRESS).unwrap(); + let hf = adapter + .get_health_factor(empty, BlockNumberOrTag::Latest) + .await + .expect("get_health_factor should not error on a clean address"); + + // Adapter's no-debt convention: 2e18 marker (matches Venus + // adapter). Anything below 1e18 would imply the dead address has + // open Aave debt, which contradicts the test premise. + let one_e18 = alloy::primitives::U256::from(10u64).pow(alloy::primitives::U256::from(18u64)); + assert!( + hf >= one_e18, + "no-debt address must report HF >= 1e18, got {hf}" + ); +} diff --git a/crates/charon-scanner/src/bin/charon-discover.rs b/crates/charon-scanner/src/bin/charon-discover.rs index 0eb9a5b..7062c1c 100644 --- a/crates/charon-scanner/src/bin/charon-discover.rs +++ b/crates/charon-scanner/src/bin/charon-discover.rs @@ -121,7 +121,13 @@ async fn main() -> Result<()> { .protocol .get("venus") .context("config has no [protocol.venus] section — discovery is Venus-only")?; - let comptroller = venus_cfg.comptroller; + // `comptroller` is `Option
` on the post-multi-protocol + // schema; `Config::validate` guarantees `Some(_)` whenever `kind = + // Venus`. Discovery is Venus-only by design (errors above if no + // [protocol.venus]); the unwrap is invariant-backed. + let comptroller = venus_cfg + .comptroller + .context("[protocol.venus] missing comptroller (validation gate misfired)")?; // RPC pool: primary first, fallbacks in order. let mut pool: Vec = Vec::with_capacity(cli.extra_rpc.len().saturating_add(1)); diff --git a/deploy/compose/local-stack-eth.yml b/deploy/compose/local-stack-eth.yml new file mode 100644 index 0000000..00a96e0 --- /dev/null +++ b/deploy/compose/local-stack-eth.yml @@ -0,0 +1,90 @@ +# Charon — local Prometheus + Grafana stack for Ethereum / Aave V3 demos. +# +# Mirror of `local-stack.yml` (BSC / Venus) with shifted host ports + a +# distinct compose project name so both stacks can coexist on a laptop: +# +# BSC stack : prometheus 9090, grafana 3000 (project: compose) +# Eth stack : prometheus 9092, grafana 3001 (project: compose-eth) +# +# Charon's `/metrics` endpoint binds the host on `127.0.0.1:9091` regardless +# of chain — Prometheus reaches it via `host.docker.internal:9091`, scraped +# from inside the Eth stack just like the BSC stack does. Run only ONE +# charon binary at a time (BSC OR Eth, not both) because they share the +# 9091 bind. +# +# Usage: +# docker compose -p charon-eth -f deploy/compose/local-stack-eth.yml up -d +# open http://127.0.0.1:3001/d/charon-v0 # Eth dashboard +# open http://127.0.0.1:9092/targets # Eth Prometheus +# +# Tear-down: +# docker compose -p charon-eth -f deploy/compose/local-stack-eth.yml down -v + +services: + prometheus: + image: prom/prometheus:v2.55.1 + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=7d + - --web.enable-lifecycle + volumes: + - ../prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ../grafana/alerts.yaml:/etc/prometheus/alerts.yaml:ro + - prometheus_data_eth:/prometheus + ports: + - "127.0.0.1:9093:9090" + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - local_stack_eth + deploy: + resources: + limits: + cpus: "0.5" + memory: 512M + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + grafana: + image: grafana/grafana:10.4.10 + restart: unless-stopped + environment: + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin" + GF_AUTH_DISABLE_LOGIN_FORM: "true" + GF_AUTH_ANONYMOUS_ORG_NAME: "Main Org." + GF_ANALYTICS_REPORTING_ENABLED: "false" + GF_ANALYTICS_CHECK_FOR_UPDATES: "false" + GF_USERS_DEFAULT_THEME: "dark" + volumes: + - ../grafana-provisioning:/etc/grafana/provisioning:ro + - ../grafana/charon.json:/var/lib/grafana/dashboards/charon.json:ro + - grafana_data_eth:/var/lib/grafana + ports: + - "127.0.0.1:3001:3000" + networks: + - local_stack_eth + depends_on: + - prometheus + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + +networks: + local_stack_eth: {} + +volumes: + prometheus_data_eth: {} + grafana_data_eth: {}