From b4b752d3b8fd459244ed9d3c6a0c93292a35e20b Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 21 Apr 2026 12:57:34 +0530 Subject: [PATCH 1/6] feat(contracts): Foundry workspace + CharonLiquidator skeleton (closes #11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the on-chain foundation. The contract shape and the storage layout are fixed by this commit so the Rust tx builder (#14) and the fork tests (#22) can target stable signatures; the actual liquidation + flash-loan + swap logic is the next commit (#12). - `contracts/` Foundry workspace, `forge-std` pinned via submodule - `foundry.toml` — solc 0.8.24, optimizer 1M runs, 100-char fmt, BSC RPC + etherscan endpoints driven from `${BNB_HTTP_URL}` / `${BSCSCAN_API_KEY}` so fork tests run against mainnet state - `src/CharonLiquidator.sol`: - `executeLiquidation(LiquidationParams)` entrypoint, `onlyOwner` - `executeOperation(...)` Aave V3 callback, gated by `msg.sender == AAVE_POOL` AND `initiator == address(this)` - both stub bodies revert with explicit "not yet implemented" so any accidental skeleton deploy fails loud before touching funds - `rescue(token, to, amount)` fully implemented as a safety hatch (handles native BNB and ERC-20, checks transfer return value) - `LiquidationExecuted` + `Rescued` events - immutables: owner, AAVE_POOL, SWAP_ROUTER (zero-address guarded) - `src/interfaces/`: minimal IFlashLoanSimpleReceiver, IERC20, IAaveV3Pool, IVToken stubs — no external libs, all defined inline - Skipped: Balancer `receiveFlashLoan` callback (not on BSC), IComet/IMorpho (out of v0.1 scope) `forge build` clean, `forge fmt --check` clean. --- .gitmodules | 3 + contracts/.gitignore | 7 + contracts/foundry.toml | 25 ++ contracts/lib/forge-std | 1 + contracts/src/CharonLiquidator.sol | 256 ++++++++++++++++++ contracts/src/interfaces/IAaveV3Pool.sol | 28 ++ contracts/src/interfaces/IERC20.sol | 31 +++ .../interfaces/IFlashLoanSimpleReceiver.sol | 25 ++ contracts/src/interfaces/IVToken.sol | 37 +++ 9 files changed, 413 insertions(+) create mode 100644 .gitmodules create mode 100644 contracts/.gitignore create mode 100644 contracts/foundry.toml create mode 160000 contracts/lib/forge-std create mode 100644 contracts/src/CharonLiquidator.sol create mode 100644 contracts/src/interfaces/IAaveV3Pool.sol create mode 100644 contracts/src/interfaces/IERC20.sol create mode 100644 contracts/src/interfaces/IFlashLoanSimpleReceiver.sol create mode 100644 contracts/src/interfaces/IVToken.sol diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c65a596 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "contracts/lib/forge-std"] + path = contracts/lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/contracts/.gitignore b/contracts/.gitignore new file mode 100644 index 0000000..a399e03 --- /dev/null +++ b/contracts/.gitignore @@ -0,0 +1,7 @@ +# Foundry build artifacts +out/ +cache/ +broadcast/ + +# Local dev +.env diff --git a/contracts/foundry.toml b/contracts/foundry.toml new file mode 100644 index 0000000..db3ce7f --- /dev/null +++ b/contracts/foundry.toml @@ -0,0 +1,25 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc_version = "0.8.24" +optimizer = true +optimizer_runs = 1_000_000 +via_ir = false +remappings = [ + "forge-std/=lib/forge-std/src/", +] + +# Forge fmt — 100-char width, 4-space indent — matches the Rust side. +[fmt] +line_length = 100 +tab_width = 4 +bracket_spacing = true + +# Fork-test endpoint for BSC mainnet — `BNB_HTTP_URL` from the repo +# `.env`. Aliased here so test scripts can pass `--fork-url bnb`. +[rpc_endpoints] +bnb = "${BNB_HTTP_URL}" + +[etherscan] +bnb = { key = "${BSCSCAN_API_KEY}", chain = 56 } diff --git a/contracts/lib/forge-std b/contracts/lib/forge-std new file mode 160000 index 0000000..2999b65 --- /dev/null +++ b/contracts/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 2999b6563e1f07971a09d48b82f3fac910d72a05 diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol new file mode 100644 index 0000000..b3f45b8 --- /dev/null +++ b/contracts/src/CharonLiquidator.sol @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IFlashLoanSimpleReceiver } from "./interfaces/IFlashLoanSimpleReceiver.sol"; +import { IERC20 } from "./interfaces/IERC20.sol"; + +// ───────────────────────────────────────────────────────────────────────────── +// CharonLiquidator — multi-chain flash-loan liquidation engine, v0.1 +// +// Scope (v0.1): Venus Protocol on BNB Chain. +// 1. Bot calls executeLiquidation() with repayment parameters. +// 2. Contract requests a flash loan from Aave V3 (flashLoanSimple). +// 3. Aave calls back executeOperation(); inside we: +// a. Approve Venus vToken to spend the debt asset. +// b. Call vToken.liquidateBorrow() — repay debt, seize collateral vTokens. +// c. Call vToken.redeemUnderlying() — convert seized vTokens to underlying. +// d. Swap collateral → debt asset via PancakeSwap V3. +// e. Repay Aave (amount + premium). +// f. Transfer profit to owner. +// Steps (a–f) are NOT implemented in this skeleton — bodies revert loudly. +// +// Security invariants (enforced even in skeleton): +// - Only owner may trigger liquidations or rescue funds. +// - executeOperation is only callable by the Aave Pool. +// - initiator must equal address(this) — prevents a malicious pool from +// invoking our callback with forged parameters. +// - No tx.origin usage. No delegatecall. No assembly. No upgradeability. +// - No external imports — all interfaces are inline/local for zero-dependency +// forge build in the skeleton phase. +// ───────────────────────────────────────────────────────────────────────────── + +/// @title CharonLiquidator +/// @notice On-chain executor for flash-loan-backed liquidations across DeFi protocols. +/// v0.1 supports Venus Protocol on BNB Chain. +/// @dev Implements IFlashLoanSimpleReceiver for the Aave V3 flash-loan callback. +/// The bot (hot wallet = owner) is the sole authorized caller of executeLiquidation. +/// All liquidation and swap logic is stubbed — see issue #12. +contract CharonLiquidator is IFlashLoanSimpleReceiver { + // ───────────────────────────────────────────────────────────────────────── + // Protocol ID constants — must mirror the Rust `ProtocolId` enum order. + // ───────────────────────────────────────────────────────────────────────── + + /// @dev ProtocolId::Venus = 3 in the Rust enum (0-indexed: Aave=0, Compound=1, ...). + uint8 internal constant PROTOCOL_VENUS = 3; + + // ───────────────────────────────────────────────────────────────────────── + // Immutable configuration — set once at construction, never changed. + // ───────────────────────────────────────────────────────────────────────── + + /// @notice The bot's hot wallet. Only address authorised to call executeLiquidation and rescue. + address public immutable owner; + + /// @notice Aave V3 Pool proxy on BNB Chain. + /// Mainnet: 0x6807dc923806fE8Fd134338EABCA509979a7e08 + address public immutable AAVE_POOL; + + /// @notice PancakeSwap V3 SwapRouter on BNB Chain. + /// Mainnet: 0x13f4EA83D0bd40E75C8222255bc855a974568Dd4 + address public immutable SWAP_ROUTER; + + // ───────────────────────────────────────────────────────────────────────── + // Structs + // ───────────────────────────────────────────────────────────────────────── + + /// @notice All parameters required to execute a single Venus liquidation. + /// @dev Packed into `bytes` and forwarded through Aave's flashLoanSimple `params` + /// argument so executeOperation can decode them without extra storage. + /// Field layout must remain stable — the Rust side abi-encodes this struct. + struct LiquidationParams { + /// @dev Protocol identifier. Must equal PROTOCOL_VENUS (3) for v0.1. + uint8 protocolId; + /// @dev The underwater borrower whose position is being liquidated. + address borrower; + /// @dev Underlying ERC-20 token that the borrower owes (e.g., USDT). + address debtToken; + /// @dev Underlying ERC-20 token posted as collateral (e.g., BNB/WBNB). + address collateralToken; + /// @dev Venus vToken representing the debt side (e.g., vUSDT). + address debtVToken; + /// @dev Venus vToken representing the collateral side (e.g., vBNB). + address collateralVToken; + /// @dev Amount of debtToken to repay, capped at the Venus close factor. + uint256 repayAmount; + /// @dev Minimum amount of debtToken to receive from the collateral swap. + /// Acts as a slippage floor — revert if swap output falls below this. + uint256 minSwapOut; + } + + // ───────────────────────────────────────────────────────────────────────── + // Events + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Emitted when a liquidation cycle completes successfully. + /// @param borrower The liquidated account. + /// @param debtToken The underlying asset that was repaid. + /// @param repayAmount The amount of debtToken that was repaid. + /// @param profit Net profit in debtToken units retained by this contract. + event LiquidationExecuted( + address indexed borrower, address indexed debtToken, uint256 repayAmount, uint256 profit + ); + + /// @notice Emitted when the owner recovers tokens or native BNB via rescue(). + /// @param token The ERC-20 address that was rescued, or address(0) for native BNB. + /// @param to The recipient of the recovered funds. + /// @param amount The amount transferred. + event Rescued(address indexed token, address indexed to, uint256 amount); + + // ───────────────────────────────────────────────────────────────────────── + // Modifiers + // ───────────────────────────────────────────────────────────────────────── + + /// @dev Restricts a function to the deploying hot wallet (owner). + /// Uses a string revert for maximum compatibility with off-chain tooling + /// that parses revert reasons at this stage of the skeleton. + modifier onlyOwner() { + require(msg.sender == owner, "!owner"); + _; + } + + // ───────────────────────────────────────────────────────────────────────── + // Constructor + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Deploys CharonLiquidator and permanently binds it to one Aave Pool + /// and one PancakeSwap V3 router. + /// @dev msg.sender becomes the immutable owner (the bot's hot wallet). + /// Both addresses are validated non-zero at construction. + /// @param _aavePool Aave V3 Pool proxy address on BNB Chain. + /// @param _swapRouter PancakeSwap V3 SwapRouter address on BNB Chain. + constructor(address _aavePool, address _swapRouter) { + require(_aavePool != address(0), "!aavePool"); + require(_swapRouter != address(0), "!swapRouter"); + owner = msg.sender; + AAVE_POOL = _aavePool; + SWAP_ROUTER = _swapRouter; + } + + // ───────────────────────────────────────────────────────────────────────── + // External — owner entry point + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Initiates a flash-loan-backed liquidation of a Venus borrower. + /// @dev Called exclusively by the off-chain bot (owner). The function encodes + /// `params` and requests a flash loan from Aave; the actual liquidation + /// logic executes inside executeOperation(). + /// + /// Checks performed here (skeleton phase): + /// - Caller is owner (onlyOwner modifier). + /// - protocolId == PROTOCOL_VENUS. + /// - Key addresses are non-zero. + /// - repayAmount > 0. + /// + /// BODY NOT IMPLEMENTED — see issue #12. + /// @param params All parameters describing the Venus liquidation opportunity. + function executeLiquidation(LiquidationParams calldata params) external onlyOwner { + // Input validation — performed even in skeleton so the deployed shape is correct. + require(params.protocolId == PROTOCOL_VENUS, "!protocolId"); + require(params.borrower != address(0), "!borrower"); + require(params.debtToken != address(0), "!debtToken"); + require(params.collateralToken != address(0), "!collateralToken"); + require(params.debtVToken != address(0), "!debtVToken"); + require(params.collateralVToken != address(0), "!collateralVToken"); + require(params.repayAmount > 0, "!repayAmount"); + + revert("CharonLiquidator: executeLiquidation not yet implemented"); + } + + // ───────────────────────────────────────────────────────────────────────── + // External — Aave V3 flash-loan callback + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Aave V3 flash-loan callback. Called by the Pool immediately after + /// transferring `amount` of `asset` to this contract. + /// @dev Two security gates are checked before any logic runs: + /// 1. msg.sender == AAVE_POOL — only the genuine Aave Pool may call this. + /// 2. initiator == address(this) — only flash loans we ourselves initiated. + /// Both checks together prevent any external actor from using our callback + /// as a weapon (e.g., to drain approved allowances). + /// + /// Full implementation (decode params, liquidate Venus, swap, repay): + /// see issue #12. + /// + /// @dev Parameters: (asset, amount, premium, initiator, data) — see IFlashLoanSimpleReceiver. + /// `asset`, `amount`, `premium`, and `data` are unnamed in this skeleton to suppress + /// unused-variable compiler warnings; they will be named and consumed in issue #12. + /// `initiator` is named because the security gate reads it. + /// @return True on success (unreachable in skeleton — revert fires first). + function executeOperation( + address, /* asset — flash-loaned ERC-20; used in issue #12 */ + uint256, /* amount — flash-loan principal; used in issue #12 */ + uint256, /* premium — Aave fee; used in issue #12 */ + address initiator, + bytes calldata /* data — ABI-encoded LiquidationParams; used in issue #12 */ + ) + external + override + returns (bool) + { + // Security gate 1: only the real Aave Pool can invoke this callback. + require(msg.sender == AAVE_POOL, "!pool"); + // Security gate 2: we only process flash loans we ourselves requested. + require(initiator == address(this), "!initiator"); + + revert("CharonLiquidator: executeOperation not yet implemented"); + } + + // ───────────────────────────────────────────────────────────────────────── + // External — safety hatch + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Recovers ERC-20 tokens or native BNB that are stuck in this contract. + /// @dev Fully implemented — this is a safety hatch, not core liquidation logic. + /// For ERC-20: calls token.transfer(to, amount). + /// For native BNB: uses payable(to).transfer(amount). + /// + /// Security notes: + /// - onlyOwner: only the hot wallet can pull funds. + /// - `to` is validated non-zero to prevent burning. + /// - Uses IERC20.transfer directly (no SafeERC20) because this is a + /// skeleton with no OZ dependency; full impl (#12) should assess + /// whether fee-on-transfer tokens need special handling here. + /// - Native transfer uses Solidity's `transfer` which forwards 2300 gas + /// and reverts on failure — appropriate for a trusted owner address. + /// + /// @param token ERC-20 contract address, or address(0) for native BNB. + /// @param to Recipient address. Must be non-zero. + /// @param amount Number of tokens (or wei) to transfer. + function rescue(address token, address to, uint256 amount) external onlyOwner { + require(to != address(0), "!to"); + require(amount > 0, "!amount"); + + if (token == address(0)) { + // Native BNB path. + // `transfer` reverts on failure and caps forwarded gas at 2300, + // which is appropriate for a trusted owner EOA. + payable(to).transfer(amount); + } else { + // ERC-20 path. + // The return value is checked to handle tokens that return false rather than reverting. + // NOTE: fee-on-transfer or rebasing tokens may transfer less than `amount`; + // that edge case is acceptable in the rescue context (excess stays in contract). + bool ok = IERC20(token).transfer(to, amount); + require(ok, "rescue: transfer failed"); + } + + emit Rescued(token, to, amount); + } + + // ───────────────────────────────────────────────────────────────────────── + // Receive — accept native BNB + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Allows this contract to receive native BNB (e.g., from vBNB redemption + /// or direct top-up by the operator) so that rescue() can withdraw it. + receive() external payable { } +} diff --git a/contracts/src/interfaces/IAaveV3Pool.sol b/contracts/src/interfaces/IAaveV3Pool.sol new file mode 100644 index 0000000..a9774fa --- /dev/null +++ b/contracts/src/interfaces/IAaveV3Pool.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IAaveV3Pool +/// @notice Stub interface for the Aave V3 Pool contract deployed on BNB Chain. +/// @dev Only the entry points required by CharonLiquidator v0.1 are declared here. +/// Additional methods (liquidationCall, supply, withdraw, etc.) will be added in +/// future commits as the implementation grows. +/// BNB Chain mainnet Pool proxy: 0x6807dc923806fE8Fd134338EABCA509979a7e08 +interface IAaveV3Pool { + /// @notice Allows a smart contract to access the liquidity of the pool within one transaction, + /// as long as the amount taken plus a fee is returned. + /// @dev The receiving contract must implement IFlashLoanSimpleReceiver and repay in executeOperation. + /// @param receiverAddress The address of the contract that will receive the flash-loaned funds + /// and must implement IFlashLoanSimpleReceiver. + /// @param asset The address of the ERC-20 asset to flash-borrow. + /// @param amount The amount to flash-borrow. + /// @param params Variadic packed params to pass to the receiver as extra information. + /// @param referralCode The code used to register the integrator for the referral program. + /// Pass 0 if no referral. + function flashLoanSimple( + address receiverAddress, + address asset, + uint256 amount, + bytes calldata params, + uint16 referralCode + ) external; +} diff --git a/contracts/src/interfaces/IERC20.sol b/contracts/src/interfaces/IERC20.sol new file mode 100644 index 0000000..7c29cd0 --- /dev/null +++ b/contracts/src/interfaces/IERC20.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IERC20 +/// @notice Minimal ERC-20 interface required by CharonLiquidator. +/// @dev Only the subset of ERC-20 that the liquidator and rescue logic directly call. +/// Full transfer/approval events are omitted here — they are emitted by token contracts. +interface IERC20 { + /// @notice Returns the token balance of `account`. + /// @param account The address to query. + /// @return The token balance. + function balanceOf(address account) external view returns (uint256); + + /// @notice Transfers `amount` tokens to `to` from the caller. + /// @param to Recipient address. + /// @param amount Number of tokens to send. + /// @return True on success (non-standard tokens may revert instead). + function transfer(address to, uint256 amount) external returns (bool); + + /// @notice Approves `spender` to spend up to `amount` of the caller's tokens. + /// @param spender The address being approved. + /// @param amount The allowance ceiling. + /// @return True on success. + function approve(address spender, uint256 amount) external returns (bool); + + /// @notice Returns the remaining allowance that `spender` may transfer on behalf of `owner`. + /// @param owner The token holder. + /// @param spender The approved spender. + /// @return The remaining allowance. + function allowance(address owner, address spender) external view returns (uint256); +} diff --git a/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol b/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol new file mode 100644 index 0000000..1677d77 --- /dev/null +++ b/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IFlashLoanSimpleReceiver +/// @notice Aave V3 flash-loan simple receiver callback interface. +/// @dev Implementors MUST repay asset + premium to the Aave Pool within the same transaction. +/// Reference: https://github.com/aave/aave-v3-core/blob/master/contracts/flashloan/interfaces/IFlashLoanSimpleReceiver.sol +interface IFlashLoanSimpleReceiver { + /// @notice Executes an operation after receiving a flash-loaned asset. + /// @dev Called by the Aave V3 Pool after funds are transferred. The callee must + /// repay `amount + premium` of `asset` back to the pool before this returns. + /// @param asset The address of the flash-loaned ERC-20 token. + /// @param amount The amount that was flash-loaned. + /// @param premium The fee owed on top of `amount`. + /// @param initiator The address that initiated the flash loan (must equal address(this) for CharonLiquidator). + /// @param params Arbitrary bytes passed by the initiator — used to forward LiquidationParams. + /// @return True if the operation succeeded; the pool reverts if false is returned. + function executeOperation( + address asset, + uint256 amount, + uint256 premium, + address initiator, + bytes calldata params + ) external returns (bool); +} diff --git a/contracts/src/interfaces/IVToken.sol b/contracts/src/interfaces/IVToken.sol new file mode 100644 index 0000000..089ea7d --- /dev/null +++ b/contracts/src/interfaces/IVToken.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IVToken +/// @notice Stub interface for Venus Protocol vToken contracts on BNB Chain. +/// @dev Only the entry points required by CharonLiquidator v0.1 are declared. +/// Venus vTokens follow the Compound V2 cToken model. +/// Additional methods (mint, redeem, borrow, borrowBalanceCurrent, etc.) will be +/// added in future commits as the implementation grows. +/// Venus Comptroller (unitroller) on BNB Chain: 0xfD36E2c2a6789Db23113685031d7F16329158384 +interface IVToken { + /// @notice The caller repays `repayAmount` of the underlying asset on behalf of `borrower` + /// and seizes `vTokenCollateral` from the borrower in return. + /// @dev Caller must have pre-approved this contract to spend `repayAmount` of the debt token. + /// Returns an error code (0 = success) following the Compound V2 convention. + /// Reverts on failure in more recent Venus deployments. + /// @param borrower The account whose borrow is being repaid. + /// @param repayAmount The amount of the underlying debt asset to repay. + /// @param vTokenCollateral The vToken address of the collateral to seize. + /// @return 0 on success, non-zero error code on failure. + function liquidateBorrow(address borrower, uint256 repayAmount, address vTokenCollateral) + external + returns (uint256); + + /// @notice Redeems vTokens for the specified amount of the underlying asset. + /// @dev Returns an error code (0 = success). The caller must hold at least enough vTokens + /// to cover `redeemAmount` of underlying after conversion. + /// @param redeemAmount The amount of underlying asset to receive. + /// @return 0 on success, non-zero error code on failure. + function redeemUnderlying(uint256 redeemAmount) external returns (uint256); + + /// @notice Returns the vToken balance of `account`. + /// @dev Used by rescue() to validate amounts before pulling vTokens out of the contract. + /// @param account The address to query. + /// @return The vToken balance (in vToken units, not underlying). + function balanceOf(address account) external view returns (uint256); +} From 0a945195b0f6a87fa4de8a88b15f5ca83c2654bd Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 12:38:17 +0530 Subject: [PATCH 2/6] fix(contracts): pin pragma to exact 0.8.24 on all sol files drop the floating `^0.8.24` caret across CharonLiquidator.sol and the four inline interfaces. pins deployed-bytecode producing solc to a single known version and removes risk of a future 0.8.x patch release subtly changing codegen, optimizer behaviour or metadata hash. closes #113 --- contracts/src/CharonLiquidator.sol | 2 +- contracts/src/interfaces/IAaveV3Pool.sol | 2 +- contracts/src/interfaces/IERC20.sol | 2 +- contracts/src/interfaces/IFlashLoanSimpleReceiver.sol | 2 +- contracts/src/interfaces/IVToken.sol | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index b3f45b8..de0841d 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; import { IFlashLoanSimpleReceiver } from "./interfaces/IFlashLoanSimpleReceiver.sol"; import { IERC20 } from "./interfaces/IERC20.sol"; diff --git a/contracts/src/interfaces/IAaveV3Pool.sol b/contracts/src/interfaces/IAaveV3Pool.sol index a9774fa..c61d1fa 100644 --- a/contracts/src/interfaces/IAaveV3Pool.sol +++ b/contracts/src/interfaces/IAaveV3Pool.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title IAaveV3Pool /// @notice Stub interface for the Aave V3 Pool contract deployed on BNB Chain. diff --git a/contracts/src/interfaces/IERC20.sol b/contracts/src/interfaces/IERC20.sol index 7c29cd0..aa8f0d6 100644 --- a/contracts/src/interfaces/IERC20.sol +++ b/contracts/src/interfaces/IERC20.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title IERC20 /// @notice Minimal ERC-20 interface required by CharonLiquidator. diff --git a/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol b/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol index 1677d77..711676f 100644 --- a/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol +++ b/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title IFlashLoanSimpleReceiver /// @notice Aave V3 flash-loan simple receiver callback interface. diff --git a/contracts/src/interfaces/IVToken.sol b/contracts/src/interfaces/IVToken.sol index 089ea7d..651b36d 100644 --- a/contracts/src/interfaces/IVToken.sol +++ b/contracts/src/interfaces/IVToken.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title IVToken /// @notice Stub interface for Venus Protocol vToken contracts on BNB Chain. From 4bccdfab1cd7567a9cc892dcffd6dcf3c8a1f005 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 12:38:26 +0530 Subject: [PATCH 3/6] fix(contracts): set evm_version=paris in foundry.toml BNB Chain has not adopted the Shanghai hard fork and rejects the PUSH0 opcode. solc >= 0.8.20 emits PUSH0 by default, so contracts compiled with the prior config would revert on first touch when deployed to BSC. pinning the target to paris keeps our bytecode compatible with the live chain until BSC enables Shanghai. closes #114 --- contracts/foundry.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contracts/foundry.toml b/contracts/foundry.toml index db3ce7f..ca029f4 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -3,6 +3,11 @@ src = "src" out = "out" libs = ["lib"] solc_version = "0.8.24" +# BNB Chain's EVM is still at the Paris hard fork — it has not enabled the +# Shanghai PUSH0 opcode. Without `evm_version = "paris"` the compiler emits +# PUSH0 bytecode (default for solc >= 0.8.20) and deployed contracts revert +# on first touch. This setting is load-bearing for v0.1 BSC-only scope. +evm_version = "paris" optimizer = true optimizer_runs = 1_000_000 via_ir = false From 7e184ed5e0c40a8c0a378cf3d4b0f133de2f308b Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 12:38:36 +0530 Subject: [PATCH 4/6] chore(contracts): document forge-std submodule commit pin the forge-std submodule is already pinned by gitlink to commit 2999b6563e1f07971a09d48b82f3fac910d72a05 (v1.15.0 + 15) in the parent repo's index, but .gitmodules carried no explanation. add comments recording the pin, noting the absence of `branch =` is deliberate (so `git submodule update` tracks the recorded commit, not a moving tip), and warning against `--remote` in CI. closes #115 --- .gitmodules | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitmodules b/.gitmodules index c65a596..8f2302b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,13 @@ +; Submodule declarations for the Charon workspace. +; +; forge-std is pinned by gitlink to an exact commit — we deliberately do NOT +; set a `branch = ...` line. Omitting `branch` means `git submodule update` +; (without `--remote`) always checks out the commit recorded in the parent +; repo's index, which is reproducible across machines and CI runs. +; +; Current pin: 2999b6563e1f07971a09d48b82f3fac910d72a05 (v1.15.0 + 15) +; Bumping the pin requires a commit that updates the gitlink; do not run +; `git submodule update --remote` in CI. [submodule "contracts/lib/forge-std"] path = contracts/lib/forge-std url = https://github.com/foundry-rs/forge-std From 33b935719242b7fbf72a0c8f035484fa42ec4bd8 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 12:40:23 +0530 Subject: [PATCH 5/6] fix(contracts): reject plain bnb sends, use call in rescue remove the open `receive() external payable { }` hatch on CharonLiquidator so misrouted or griefing bnb transfers now revert instead of silently accumulating. v0.1 does not handle the vBNB native market; when it is added, a sender-gated receive() will be introduced. existing rescue(address(0), ...) still recovers bnb credited via selfdestruct. switch the native-bnb branch of rescue() from `payable(to).transfer` to `payable(to).call{value: amount}("")` with a checked success return. the 2300-gas stipend of `transfer`/`send` is insufficient post-EIP-1884 for gnosis safe and other smart-wallet recipients, which would otherwise trap funds. erc-20 path is unchanged. closes #117 --- contracts/src/CharonLiquidator.sol | 43 +++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index de0841d..35e054d 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -210,8 +210,8 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// @notice Recovers ERC-20 tokens or native BNB that are stuck in this contract. /// @dev Fully implemented — this is a safety hatch, not core liquidation logic. - /// For ERC-20: calls token.transfer(to, amount). - /// For native BNB: uses payable(to).transfer(amount). + /// For ERC-20: calls token.transfer(to, amount) and checks the return value. + /// For native BNB: uses a low-level call{value: amount}("") with success check. /// /// Security notes: /// - onlyOwner: only the hot wallet can pull funds. @@ -219,8 +219,13 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// - Uses IERC20.transfer directly (no SafeERC20) because this is a /// skeleton with no OZ dependency; full impl (#12) should assess /// whether fee-on-transfer tokens need special handling here. - /// - Native transfer uses Solidity's `transfer` which forwards 2300 gas - /// and reverts on failure — appropriate for a trusted owner address. + /// - Native transfer uses `call` rather than `transfer` or `send`. + /// Solidity's `transfer` forwards a hard-coded 2300-gas stipend which + /// reverts against any recipient whose fallback does non-trivial work + /// (Gnosis Safe and most multisigs, smart-contract wallets, any + /// custody solution that logs inbound receipts). `call` forwards the + /// remaining gas and is the EIP-1884-safe primitive; its boolean + /// return value is checked to surface failures as reverts. /// /// @param token ERC-20 contract address, or address(0) for native BNB. /// @param to Recipient address. Must be non-zero. @@ -231,9 +236,12 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { if (token == address(0)) { // Native BNB path. - // `transfer` reverts on failure and caps forwarded gas at 2300, - // which is appropriate for a trusted owner EOA. - payable(to).transfer(amount); + // Use `call` with full remaining gas so the recipient may be a multisig + // or smart-contract wallet (Gnosis Safe, etc.). The 2300-gas stipend of + // `transfer`/`send` is insufficient post-EIP-1884 for such recipients + // and would trap funds in this contract. + (bool ok,) = payable(to).call{ value: amount }(""); + require(ok, "rescue: bnb transfer failed"); } else { // ERC-20 path. // The return value is checked to handle tokens that return false rather than reverting. @@ -247,10 +255,21 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { } // ───────────────────────────────────────────────────────────────────────── - // Receive — accept native BNB + // Receive — native BNB is intentionally rejected // ───────────────────────────────────────────────────────────────────────── - - /// @notice Allows this contract to receive native BNB (e.g., from vBNB redemption - /// or direct top-up by the operator) so that rescue() can withdraw it. - receive() external payable { } + // + // No `receive()` or `fallback()` is defined. Plain BNB transfers to this + // contract revert. Rationale: + // - v0.1 does not liquidate the vBNB native-BNB market; all supported + // Venus markets settle collateral in ERC-20 (WBNB, BUSD, USDT, ...). + // - An open `receive()` would silently accumulate BNB from any sender, + // making misrouted funds hard to notice and providing free storage + // for griefers / mixers. + // - If the vBNB market is added later, reintroduce a gated `receive()` + // that requires `msg.sender == vBNB_MARKET` so only the Venus + // contract can push native BNB into this contract during redeem. + // + // If BNB is ever trapped here (e.g. as a SELFDESTRUCT beneficiary), the + // owner can still recover it via rescue(address(0), ...) because + // SELFDESTRUCT credits the balance without invoking `receive()`. } From 3e99212e60eef5456feda42256b0232b3bd1ed50 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 12:43:43 +0530 Subject: [PATCH 6/6] test(contracts): add skeleton test suite for CharonLiquidator step-11 requires a passing forge test suite. add one that covers the shape of the deployed skeleton without any fork dependency: - constructor zero-address guards and owner/immutable binding - onlyOwner on executeLiquidation and rescue - per-field input validation inside executeLiquidation (seven require branches reached before the skeleton's final revert) - executeOperation `!pool` and `!initiator` security gates - rescue() erc-20 and native-bnb happy paths, zero-recipient and zero-amount reverts, plus the post-#117 `call`-based bnb path against a gas-hungry contract recipient that would reject a 2300-gas `transfer` stipend - post-#117 no-receive policy: direct bnb sends must revert uses only forge-std and two in-file mocks (MockERC20, GasHungryReceiver); pinned at solidity 0.8.24. fork-based end-to-end coverage remains tracked under issue #22. closes #116 --- contracts/test/CharonLiquidator.t.sol | 277 ++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 contracts/test/CharonLiquidator.t.sol diff --git a/contracts/test/CharonLiquidator.t.sol b/contracts/test/CharonLiquidator.t.sol new file mode 100644 index 0000000..defe738 --- /dev/null +++ b/contracts/test/CharonLiquidator.t.sol @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { Test } from "forge-std/Test.sol"; + +import { CharonLiquidator } from "../src/CharonLiquidator.sol"; + +// ───────────────────────────────────────────────────────────────────────────── +// Minimal ERC-20 stub used only by the rescue() ERC-20 path test. +// Lives in-file so this suite has zero external dependencies beyond forge-std. +// ───────────────────────────────────────────────────────────────────────────── +contract MockERC20 { + mapping(address => uint256) public balanceOf; + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + } + + function transfer(address to, uint256 amount) external returns (bool) { + require(balanceOf[msg.sender] >= amount, "insufficient"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + return true; + } + + function approve(address, uint256) external pure returns (bool) { + return true; + } + + function allowance(address, address) external pure returns (uint256) { + return 0; + } +} + +/// @dev Contract recipient whose `receive()` writes a storage slot, costing well +/// over the 2300-gas stipend that Solidity's `transfer`/`send` forwards. +/// Used to prove that rescue()'s BNB path uses `call` (full gas) and not +/// `transfer`/`send` — critical for multisig / smart-wallet compatibility. +contract GasHungryReceiver { + uint256 public touched; + + receive() external payable { + // SSTORE on a cold slot is ~20k gas — guaranteed to exceed the 2300 + // stipend that `transfer`/`send` would forward. + touched += 1; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Skeleton test suite — issue #116 +// +// The CharonLiquidator at this point in the branch is a skeleton: the +// executeLiquidation and executeOperation bodies revert with a "not yet +// implemented" message after input validation and the security gates. +// +// This suite therefore focuses on the shape of the deployed contract: +// - Constructor non-zero-address guards. +// - Owner assignment. +// - onlyOwner on executeLiquidation and rescue. +// - Input validation inside executeLiquidation (per-field zero-address / +// zero-amount / wrong-protocol reverts — reached BEFORE the "not yet +// implemented" revert). +// - executeOperation security gates (!pool, !initiator). +// - rescue() happy and sad paths, including the post-#117 BNB-via-call path. +// - Absence of an open `receive()` (post-#117) — direct BNB sends revert. +// +// Full end-to-end liquidation coverage lands with issue #12 (impl) and +// issue #22 (fork tests). +// ───────────────────────────────────────────────────────────────────────────── +contract CharonLiquidatorTest is Test { + // ── Deterministic stub addresses ────────────────────────────────────────── + address internal constant STUB_POOL = address(0xA11E); + address internal constant STUB_ROUTER = address(0xB22E); + + CharonLiquidator internal liquidator; + address internal alice; + address internal recipient; + + function setUp() public { + alice = makeAddr("alice"); + recipient = makeAddr("recipient"); + // msg.sender at construction is the test contract, so address(this) == owner. + liquidator = new CharonLiquidator(STUB_POOL, STUB_ROUTER); + } + + // ── Internal helper: a fully-valid LiquidationParams struct ────────────── + function _validParams() internal returns (CharonLiquidator.LiquidationParams memory) { + return CharonLiquidator.LiquidationParams({ + protocolId: 3, // PROTOCOL_VENUS + borrower: makeAddr("borrower"), + debtToken: makeAddr("debtToken"), + collateralToken: makeAddr("collateralToken"), + debtVToken: makeAddr("debtVToken"), + collateralVToken: makeAddr("collateralVToken"), + repayAmount: 1e18, + minSwapOut: 0 + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // A. Constructor guards & owner binding + // ───────────────────────────────────────────────────────────────────────── + + function test_constructor_revertsOnZeroAavePool() public { + vm.expectRevert(bytes("!aavePool")); + new CharonLiquidator(address(0), STUB_ROUTER); + } + + function test_constructor_revertsOnZeroSwapRouter() public { + vm.expectRevert(bytes("!swapRouter")); + new CharonLiquidator(STUB_POOL, address(0)); + } + + function test_constructor_setsOwnerAndImmutables() public view { + assertEq(liquidator.owner(), address(this), "owner must be deployer"); + assertEq(liquidator.AAVE_POOL(), STUB_POOL, "AAVE_POOL mismatch"); + assertEq(liquidator.SWAP_ROUTER(), STUB_ROUTER, "SWAP_ROUTER mismatch"); + } + + // ───────────────────────────────────────────────────────────────────────── + // B. Access control + // ───────────────────────────────────────────────────────────────────────── + + function test_executeLiquidation_revertsWhenNotOwner() public { + CharonLiquidator.LiquidationParams memory p = _validParams(); + vm.prank(alice); + vm.expectRevert(bytes("!owner")); + liquidator.executeLiquidation(p); + } + + function test_rescue_revertsWhenNotOwner() public { + vm.prank(alice); + vm.expectRevert(bytes("!owner")); + liquidator.rescue(address(0), recipient, 1 ether); + } + + // ───────────────────────────────────────────────────────────────────────── + // C. executeLiquidation input validation (skeleton reverts come AFTER these) + // ───────────────────────────────────────────────────────────────────────── + + function test_executeLiquidation_revertsOnWrongProtocolId() public { + CharonLiquidator.LiquidationParams memory p = _validParams(); + p.protocolId = 0; // ProtocolId::Aave — not supported in v0.1 + vm.expectRevert(bytes("!protocolId")); + liquidator.executeLiquidation(p); + } + + function test_executeLiquidation_revertsOnZeroBorrower() public { + CharonLiquidator.LiquidationParams memory p = _validParams(); + p.borrower = address(0); + vm.expectRevert(bytes("!borrower")); + liquidator.executeLiquidation(p); + } + + function test_executeLiquidation_revertsOnZeroDebtToken() public { + CharonLiquidator.LiquidationParams memory p = _validParams(); + p.debtToken = address(0); + vm.expectRevert(bytes("!debtToken")); + liquidator.executeLiquidation(p); + } + + function test_executeLiquidation_revertsOnZeroCollateralToken() public { + CharonLiquidator.LiquidationParams memory p = _validParams(); + p.collateralToken = address(0); + vm.expectRevert(bytes("!collateralToken")); + liquidator.executeLiquidation(p); + } + + function test_executeLiquidation_revertsOnZeroDebtVToken() public { + CharonLiquidator.LiquidationParams memory p = _validParams(); + p.debtVToken = address(0); + vm.expectRevert(bytes("!debtVToken")); + liquidator.executeLiquidation(p); + } + + function test_executeLiquidation_revertsOnZeroCollateralVToken() public { + CharonLiquidator.LiquidationParams memory p = _validParams(); + p.collateralVToken = address(0); + vm.expectRevert(bytes("!collateralVToken")); + liquidator.executeLiquidation(p); + } + + function test_executeLiquidation_revertsOnZeroRepayAmount() public { + CharonLiquidator.LiquidationParams memory p = _validParams(); + p.repayAmount = 0; + vm.expectRevert(bytes("!repayAmount")); + liquidator.executeLiquidation(p); + } + + /// @dev Validated params still hit the skeleton's "not yet implemented" revert + /// — this test pins the current skeleton behaviour so replacing the body + /// in issue #12 deliberately breaks this test (reminder to update). + function test_executeLiquidation_skeletonStillReverts() public { + CharonLiquidator.LiquidationParams memory p = _validParams(); + vm.expectRevert(bytes("CharonLiquidator: executeLiquidation not yet implemented")); + liquidator.executeLiquidation(p); + } + + // ───────────────────────────────────────────────────────────────────────── + // D. executeOperation security gates + // ───────────────────────────────────────────────────────────────────────── + + function test_executeOperation_revertsWhenNotPool() public { + vm.prank(alice); // any non-AAVE_POOL caller + vm.expectRevert(bytes("!pool")); + liquidator.executeOperation(address(0), 0, 0, address(liquidator), bytes("")); + } + + function test_executeOperation_revertsWhenInitiatorNotSelf() public { + vm.prank(STUB_POOL); + vm.expectRevert(bytes("!initiator")); + liquidator.executeOperation(address(0), 0, 0, alice, bytes("")); + } + + // ───────────────────────────────────────────────────────────────────────── + // E. rescue() + // ───────────────────────────────────────────────────────────────────────── + + function test_rescue_revertsOnZeroRecipient() public { + vm.expectRevert(bytes("!to")); + liquidator.rescue(address(0), address(0), 1 ether); + } + + function test_rescue_revertsOnZeroAmount() public { + vm.expectRevert(bytes("!amount")); + liquidator.rescue(address(0), recipient, 0); + } + + function test_rescue_transfersErc20() public { + MockERC20 token = new MockERC20(); + token.mint(address(liquidator), 1_000); + + vm.expectEmit(true, true, false, true); + emit CharonLiquidator.Rescued(address(token), recipient, 400); + + liquidator.rescue(address(token), recipient, 400); + + assertEq(token.balanceOf(address(liquidator)), 600, "liquidator token balance wrong"); + assertEq(token.balanceOf(recipient), 400, "recipient token balance wrong"); + } + + function test_rescue_transfersNativeBnbToEoa() public { + vm.deal(address(liquidator), 5 ether); + uint256 before = recipient.balance; + + vm.expectEmit(true, true, false, true); + emit CharonLiquidator.Rescued(address(0), recipient, 2 ether); + + liquidator.rescue(address(0), recipient, 2 ether); + + assertEq(recipient.balance - before, 2 ether, "bnb not received"); + assertEq(address(liquidator).balance, 3 ether, "liquidator bnb wrong"); + } + + /// @dev Proves that rescue()'s BNB path uses `call{value}` (full gas) and not + /// `transfer` (2300-gas stipend). A contract recipient that writes storage + /// in `receive()` would cause `transfer` to fail. Covers issue #117. + function test_rescue_bnbToGasHungryContractRecipient() public { + GasHungryReceiver gh = new GasHungryReceiver(); + vm.deal(address(liquidator), 5 ether); + uint256 before = address(gh).balance; + + liquidator.rescue(address(0), address(gh), 2 ether); + + assertEq(address(gh).balance - before, 2 ether, "bnb not received by contract"); + assertEq(gh.touched(), 1, "recipient fallback did not execute"); + } + + // ───────────────────────────────────────────────────────────────────────── + // F. No-receive policy — plain BNB sends must revert (issue #117) + // ───────────────────────────────────────────────────────────────────────── + + function test_directBnbTransferReverts() public { + (bool ok,) = address(liquidator).call{ value: 1 ether }(""); + assertFalse(ok, "liquidator must refuse plain BNB transfers"); + } +}