From 54fe2bbbbb1b46b5797f6bc6070fdef2624a02de Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 21 Apr 2026 13:05:49 +0530 Subject: [PATCH 1/6] feat(contracts): full Aave + Venus + PancakeSwap liquidation flow (closes #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the two stub bodies in `CharonLiquidator.sol` so the contract performs a complete atomic liquidation. The skeleton's signatures, storage layout, and event surface are unchanged — Rust callers and deploy scripts written against #11 keep working. - `executeLiquidation` body: - `nonReentrant` modifier added (1/2 storage flag, gas-cheap) - ABI-encodes `LiquidationParams` and calls `IAaveV3Pool.flashLoanSimple(self, debtToken, repayAmount, encoded, 0)`. The actual liquidation runs inside Aave's callback into `executeOperation`. - `executeOperation` body, in order: 1. Decode params, cross-check `asset == debtToken` and `amount == repayAmount` 2. Approve `debtVToken` for `repayAmount` 3. `IVToken.liquidateBorrow(borrower, repayAmount, collateralVToken)` — error code 0 required 4. `IVToken(collateralVToken).redeem(vBal)` — pulls underlying collateral; non-zero error → revert 5. Approve PancakeSwap V3 router for the seized underlying 6. `ISwapRouter.exactInputSingle(...)` swap collateral → debtToken with `amountOutMinimum = minSwapOut` 7. Final balance check: `finalBal >= amount + premium` — backstop in case the router doesn't catch it 8. Sweep `profit = finalBal - totalOwed` to `owner` BEFORE returning so Aave only pulls exactly what it's owed 9. Approve Aave for `totalOwed`, emit `LiquidationExecuted`, return `true` - Approvals are zeroed after consumption for `debtVToken` and the swap router; the Aave allowance is fully consumed by the same-tx `transferFrom` so it can't leak. - New `interfaces/ISwapRouter.sol` mirrors the Uniswap V3 surface PancakeSwap V3 implements — minimal `ExactInputSingleParams` struct + `exactInputSingle`. `interfaces/IVToken.sol` extended with the Compound-V2 `redeem(uint256)` variant required by step 4. - Reentrancy guard sits only on `executeLiquidation`, not `executeOperation` — the callback runs inside the same call frame so adding it there would deadlock; the `msg.sender == AAVE_POOL` gate already blocks external entry. - No external libs (still no OZ / aave-core). All imports stay inline. `forge build` + `forge fmt --check` both clean. Tests + fork tests + deploy script land in #22 and a follow-up. --- contracts/src/CharonLiquidator.sol | 235 ++++++++++++++++++----- contracts/src/interfaces/ISwapRouter.sol | 41 ++++ contracts/src/interfaces/IVToken.sol | 10 + 3 files changed, 240 insertions(+), 46 deletions(-) create mode 100644 contracts/src/interfaces/ISwapRouter.sol diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index b3f45b8..1cb6891 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -2,6 +2,9 @@ pragma solidity ^0.8.24; import { IFlashLoanSimpleReceiver } from "./interfaces/IFlashLoanSimpleReceiver.sol"; +import { IAaveV3Pool } from "./interfaces/IAaveV3Pool.sol"; +import { IVToken } from "./interfaces/IVToken.sol"; +import { ISwapRouter } from "./interfaces/ISwapRouter.sol"; import { IERC20 } from "./interfaces/IERC20.sol"; // ───────────────────────────────────────────────────────────────────────────── @@ -13,20 +16,23 @@ import { IERC20 } from "./interfaces/IERC20.sol"; // 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. +// c. Call vToken.redeem() — convert ALL 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. +// e. Sweep profit to owner. +// f. Approve Aave for repayment (amount + premium); Aave pulls it after return. // -// Security invariants (enforced even in skeleton): +// Security invariants: // - 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. +// - Reentrancy guard on executeLiquidation (nonReentrant). +// - executeOperation NOT guarded with nonReentrant: it is called by Aave mid- +// flash-loan, re-entering executeLiquidation's guard frame. The msg.sender +// == AAVE_POOL gate is the equivalent protection for the callback. +// - Lingering approvals zeroed after each consume point (vToken, SwapRouter). // - 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. +// - No external library imports — all interfaces are inline/local. // ───────────────────────────────────────────────────────────────────────────── /// @title CharonLiquidator @@ -34,7 +40,6 @@ import { IERC20 } from "./interfaces/IERC20.sol"; /// 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. @@ -43,6 +48,15 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// @dev ProtocolId::Venus = 3 in the Rust enum (0-indexed: Aave=0, Compound=1, ...). uint8 internal constant PROTOCOL_VENUS = 3; + // ───────────────────────────────────────────────────────────────────────── + // Reentrancy guard — simple two-state lock. + // Stored as uint256 rather than bool to match the Solidity optimizer's + // preferred SSTORE encoding and avoid zero→non-zero cold-write gas cost + // on the first call (storage slot is initialized to 1 at deploy time). + // ───────────────────────────────────────────────────────────────────────── + + uint256 private _entered = 1; + // ───────────────────────────────────────────────────────────────────────── // Immutable configuration — set once at construction, never changed. // ───────────────────────────────────────────────────────────────────────── @@ -110,13 +124,23 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { // ───────────────────────────────────────────────────────────────────────── /// @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"); _; } + /// @dev Prevents reentrant calls into executeLiquidation. + /// Uses 1/2 rather than 0/1 to avoid cold-write SSTORE costs on every call. + /// NOT applied to executeOperation — that function is called by Aave mid- + /// flash-loan and is already protected by the msg.sender == AAVE_POOL gate. + /// Applying nonReentrant to executeOperation would deadlock the flash loan. + modifier nonReentrant() { + require(_entered == 1, "reentrant"); + _entered = 2; + _; + _entered = 1; + } + // ───────────────────────────────────────────────────────────────────────── // Constructor // ───────────────────────────────────────────────────────────────────────── @@ -140,20 +164,22 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { // ───────────────────────────────────────────────────────────────────────── /// @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(). + /// @dev Called exclusively by the off-chain bot (owner). Encodes `params` and + /// requests a flash loan from Aave V3; the actual liquidation logic executes + /// atomically inside the executeOperation() callback. /// - /// Checks performed here (skeleton phase): - /// - Caller is owner (onlyOwner modifier). - /// - protocolId == PROTOCOL_VENUS. - /// - Key addresses are non-zero. - /// - repayAmount > 0. + /// Flow: + /// 1. Validate inputs. + /// 2. ABI-encode params to bytes. + /// 3. Call IAaveV3Pool.flashLoanSimple — Aave transfers debtToken to this + /// contract then immediately calls executeOperation(). + /// 4. After executeOperation returns true, Aave pulls amount + premium + /// using the allowance set inside the callback. No further state work + /// is required here. /// - /// 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. + function executeLiquidation(LiquidationParams calldata params) external onlyOwner nonReentrant { + // ── Input validation ────────────────────────────────────────────────── require(params.protocolId == PROTOCOL_VENUS, "!protocolId"); require(params.borrower != address(0), "!borrower"); require(params.debtToken != address(0), "!debtToken"); @@ -162,7 +188,21 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { require(params.collateralVToken != address(0), "!collateralVToken"); require(params.repayAmount > 0, "!repayAmount"); - revert("CharonLiquidator: executeLiquidation not yet implemented"); + // ── Encode params and request the flash loan ────────────────────────── + // Aave forwards `encoded` verbatim to executeOperation as the `data` + // argument; we decode it there to recover the liquidation parameters. + bytes memory encoded = abi.encode(params); + + IAaveV3Pool(AAVE_POOL) + .flashLoanSimple( + address(this), // receiver — this contract implements the callback + params.debtToken, // asset — the token we need to repay Venus with + params.repayAmount, // amount — exact principal to borrow + encoded, // params — forwarded to executeOperation + 0 // referralCode — no referral + ); + // Aave has pulled amount + premium via the allowance set in executeOperation. + // Nothing further to do in this frame. } // ───────────────────────────────────────────────────────────────────────── @@ -171,37 +211,140 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// @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: + /// @dev Security gates (preserved from skeleton): /// 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. + /// Full liquidation flow: + /// a. Decode LiquidationParams from `data`. + /// b. Sanity-check asset/amount match decoded params. + /// c. Approve debtVToken and call liquidateBorrow on Venus. + /// d. Zero out debtVToken approval (consumed). + /// e. Redeem all seized collateral vTokens for underlying. + /// f. Swap collateral underlying → debt token via PancakeSwap V3. + /// g. Zero out SwapRouter approval (consumed). + /// h. Verify post-swap balance covers totalOwed. + /// i. Sweep profit to owner. + /// j. Emit LiquidationExecuted. + /// k. Approve Aave Pool for totalOwed (Aave pulls this after we return). + /// l. Return true. + /// + /// NOTE: nonReentrant is intentionally NOT applied here. Applying it would + /// deadlock the flash loan because executeLiquidation already holds the lock + /// (_entered == 2) when Aave re-enters this callback within the same tx. + /// The msg.sender == AAVE_POOL gate is the equivalent protection. /// - /// @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). + /// @param asset The flash-loaned ERC-20 token (must equal p.debtToken). + /// @param amount The flash-loan principal (must equal p.repayAmount). + /// @param premium The Aave fee owed on top of `amount`. + /// @param initiator The address that initiated the flash loan (must be address(this)). + /// @param data ABI-encoded LiquidationParams forwarded from executeLiquidation. + /// @return True on success; Aave reverts the entire tx if false is returned. 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 asset, + uint256 amount, + uint256 premium, 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. + bytes calldata data + ) external override returns (bool) { + // ── Security gates (from skeleton — do not remove) ──────────────────── + // 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. + // Gate 2: we only process flash loans we ourselves requested. require(initiator == address(this), "!initiator"); - revert("CharonLiquidator: executeOperation not yet implemented"); + // ── Step 1: decode parameters ───────────────────────────────────────── + LiquidationParams memory p = abi.decode(data, (LiquidationParams)); + + // ── Step 2: sanity — confirm Aave gave us exactly what we asked for ─── + // These checks catch any pool-side discrepancy and validate that the + // encoded params are consistent with the actual flash-loan terms. + require(asset == p.debtToken, "asset/debt mismatch"); + require(amount == p.repayAmount, "amount/repay mismatch"); + + // ── Step 3: liquidate on Venus ──────────────────────────────────────── + // Approve the debt vToken to spend exactly repayAmount of the debt asset. + // Venus pulls this during liquidateBorrow; approval is zeroed immediately + // after to eliminate lingering allowances. + IERC20(p.debtToken).approve(p.debtVToken, p.repayAmount); + + uint256 liqErr = IVToken(p.debtVToken) + .liquidateBorrow( + p.borrower, + p.repayAmount, + p.collateralVToken // seized vTokens land in address(this) + ); + require(liqErr == 0, "venus liquidate failed"); + + // Zero out vToken approval — liquidateBorrow has consumed it. + // Protects against a malicious or re-upgraded vToken contract + // attempting a second pull in future calls. + IERC20(p.debtToken).approve(p.debtVToken, 0); + + // ── Step 4: redeem seized collateral vTokens for underlying ─────────── + // balanceOf gives us the exact vToken units seized by liquidateBorrow. + // We use redeem(vTokenAmount) rather than redeemUnderlying(underlyingAmount) + // to drain the full balance in one call without rounding loss. + uint256 vBal = IVToken(p.collateralVToken).balanceOf(address(this)); + require(vBal > 0, "no collateral seized"); + + uint256 redeemErr = IVToken(p.collateralVToken).redeem(vBal); + require(redeemErr == 0, "venus redeem failed"); + + // ── Step 5: swap collateral underlying → debt token via PancakeSwap V3 ─ + // Read the full collateral balance just redeemed; use it as exact amountIn. + uint256 collateralBal = IERC20(p.collateralToken).balanceOf(address(this)); + + // Approve the router for the exact amount we are about to swap. + IERC20(p.collateralToken).approve(SWAP_ROUTER, collateralBal); + + ISwapRouter(SWAP_ROUTER) + .exactInputSingle( + ISwapRouter.ExactInputSingleParams({ + tokenIn: p.collateralToken, + tokenOut: p.debtToken, + fee: 3000, // 0.30 % pool — most liquid tier on PCS V3 for major pairs + recipient: address(this), + deadline: block.timestamp, + amountIn: collateralBal, + amountOutMinimum: p.minSwapOut, // router reverts if output < this + sqrtPriceLimitX96: 0 // no price limit — slippage floor above is enough + }) + ); + + // Zero out router approval — exactInputSingle has consumed it. + IERC20(p.collateralToken).approve(SWAP_ROUTER, 0); + + // ── Step 6: verify post-swap balance covers repayment ───────────────── + uint256 totalOwed = amount + premium; + uint256 finalBal = IERC20(p.debtToken).balanceOf(address(this)); + + // Defensive check on top of the router's amountOutMinimum guard: + // ensures the contract cannot accidentally under-repay Aave even if + // minSwapOut was set below totalOwed by the caller. + require(finalBal >= totalOwed, "swap output below repayment"); + + // ── Step 7: sweep profit to owner ───────────────────────────────────── + // Profit must leave this contract before we approve Aave, otherwise Aave + // could theoretically pull more than totalOwed if the token has quirks. + uint256 profit = finalBal - totalOwed; + if (profit > 0) { + // transfer return value not checked: owner is a trusted address set at + // construction; a failure here reverts the whole tx (excess funds stay + // in the contract until rescued). Standard ERC-20s revert on failure. + IERC20(p.debtToken).transfer(owner, profit); + } + + // ── Step 8: emit before the final approval so logs reflect the full state ─ + emit LiquidationExecuted(p.borrower, p.debtToken, p.repayAmount, profit); + + // ── Step 9: approve Aave to pull totalOwed ──────────────────────────── + // Aave pulls amount + premium from this contract after executeOperation + // returns true. We set approval here; Aave consumes it entirely, so + // there is no practical way to zero it out post-return in this call frame. + IERC20(p.debtToken).approve(AAVE_POOL, totalOwed); + + return true; } // ───────────────────────────────────────────────────────────────────────── @@ -217,8 +360,8 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// - 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. + /// no-external-dependency build; fee-on-transfer tokens may transfer + /// less than `amount` — that edge case is acceptable in rescue context. /// - Native transfer uses Solidity's `transfer` which forwards 2300 gas /// and reverts on failure — appropriate for a trusted owner address. /// diff --git a/contracts/src/interfaces/ISwapRouter.sol b/contracts/src/interfaces/ISwapRouter.sol new file mode 100644 index 0000000..ff06c6d --- /dev/null +++ b/contracts/src/interfaces/ISwapRouter.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title ISwapRouter +/// @notice Minimal interface for the PancakeSwap V3 SwapRouter on BNB Chain. +/// @dev PancakeSwap V3 is a fork of Uniswap V3; the SwapRouter ABI is identical. +/// Only exactInputSingle is needed by CharonLiquidator v0.1 — the single-hop +/// swap from seized collateral back into the debt token. +/// BNB Chain mainnet SwapRouter: 0x13f4EA83D0bd40E75C8222255bc855a974568Dd4 +interface ISwapRouter { + /// @notice Parameters for a single-pool exact-input swap. + /// @dev Mirrors IV3SwapRouter.ExactInputSingleParams from Uniswap V3 / PCS V3. + struct ExactInputSingleParams { + /// @dev Token being sold (the collateral underlying recovered after redemption). + address tokenIn; + /// @dev Token being bought (the debt token needed to repay Aave). + address tokenOut; + /// @dev Pool fee tier in hundredths of a bip (e.g. 3000 = 0.30 %). + uint24 fee; + /// @dev Recipient of the output tokens. + address recipient; + /// @dev Unix timestamp after which the transaction reverts. + uint256 deadline; + /// @dev Exact amount of tokenIn to swap. + uint256 amountIn; + /// @dev Minimum acceptable amount of tokenOut; router reverts if not met. + uint256 amountOutMinimum; + /// @dev Square-root price limit in Q64.96 format. Pass 0 for no limit. + uint160 sqrtPriceLimitX96; + } + + /// @notice Swaps `amountIn` of one token for as much as possible of another token. + /// @dev The router pulls `amountIn` from msg.sender (caller must pre-approve). + /// Reverts if the resulting output is below `params.amountOutMinimum`. + /// @param params The parameters for the swap, encoded as `ExactInputSingleParams`. + /// @return amountOut The amount of `tokenOut` received. + function exactInputSingle(ExactInputSingleParams calldata params) + external + payable + returns (uint256 amountOut); +} diff --git a/contracts/src/interfaces/IVToken.sol b/contracts/src/interfaces/IVToken.sol index 089ea7d..637cf1e 100644 --- a/contracts/src/interfaces/IVToken.sol +++ b/contracts/src/interfaces/IVToken.sol @@ -29,6 +29,16 @@ interface IVToken { /// @return 0 on success, non-zero error code on failure. function redeemUnderlying(uint256 redeemAmount) external returns (uint256); + /// @notice Redeems exactly `redeemTokens` vTokens for the corresponding underlying asset. + /// @dev Compound V2 / Venus API. Transfers the caller's vTokens back to the protocol and + /// returns the proportional underlying. Returns an error code (0 = success). + /// CharonLiquidator uses this variant to drain the full seized vToken balance in one + /// call after liquidateBorrow(), avoiding any rounding that redeemUnderlying would + /// introduce when converting an inexact underlying amount to vTokens. + /// @param redeemTokens The number of vTokens to burn. + /// @return 0 on success, non-zero error code on failure. + function redeem(uint256 redeemTokens) 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. From f0553504898d02a0e6c1e3734dd6c4f292e5f6c6 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 13:07:07 +0530 Subject: [PATCH 2/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 #118 --- 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 4d4df1773d4c288f81b1d622f840a1f3dc080810 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 13:08:22 +0530 Subject: [PATCH 3/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 five 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 #119 --- 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/ISwapRouter.sol | 2 +- contracts/src/interfaces/IVToken.sol | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index 1cb6891..059cc4c 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 { IAaveV3Pool } from "./interfaces/IAaveV3Pool.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/ISwapRouter.sol b/contracts/src/interfaces/ISwapRouter.sol index ff06c6d..d7439ad 100644 --- a/contracts/src/interfaces/ISwapRouter.sol +++ b/contracts/src/interfaces/ISwapRouter.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title ISwapRouter /// @notice Minimal interface for the PancakeSwap V3 SwapRouter on BNB Chain. diff --git a/contracts/src/interfaces/IVToken.sol b/contracts/src/interfaces/IVToken.sol index 637cf1e..874ed8c 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 856e8c304a2aa13d96661cc3a9355d25f4c63b42 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 13:11:37 +0530 Subject: [PATCH 4/6] fix(contracts): route liquidation profit to cold wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hot wallet (owner) holds gas only — profit must exit the bot's operational key perimeter inside the same atomic flash-loan frame. the prior sweep targeted `owner`, violating the CLAUDE.md safety invariant and exposing accumulated profit to any compromise of the scanner/executor hot key. add an immutable `COLD_WALLET` address set at construction, validate it non-zero, and transfer `profit` to it inside executeOperation before the Aave repayment approval. extend LiquidationExecuted with an indexed `recipient` so off-chain monitors can filter by cold wallet. constructor signature gains a third arg `_coldWallet`. closes #120 --- contracts/src/CharonLiquidator.sol | 59 +++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index 059cc4c..fef1c6c 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -18,7 +18,7 @@ import { IERC20 } from "./interfaces/IERC20.sol"; // b. Call vToken.liquidateBorrow() — repay debt, seize collateral vTokens. // c. Call vToken.redeem() — convert ALL seized vTokens to underlying. // d. Swap collateral → debt asset via PancakeSwap V3. -// e. Sweep profit to owner. +// e. Sweep profit to the COLD wallet — hot wallet (owner) holds gas only. // f. Approve Aave for repayment (amount + premium); Aave pulls it after return. // // Security invariants: @@ -31,6 +31,8 @@ import { IERC20 } from "./interfaces/IERC20.sol"; // flash-loan, re-entering executeLiquidation's guard frame. The msg.sender // == AAVE_POOL gate is the equivalent protection for the callback. // - Lingering approvals zeroed after each consume point (vToken, SwapRouter). +// - Profit is swept to the immutable COLD_WALLET, never to the hot wallet. +// This enforces the CLAUDE.md safety invariant: "hot wallet holds gas only". // - No tx.origin usage. No delegatecall. No assembly. No upgradeability. // - No external library imports — all interfaces are inline/local. // ───────────────────────────────────────────────────────────────────────────── @@ -40,6 +42,7 @@ import { IERC20 } from "./interfaces/IERC20.sol"; /// 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 profit is routed to the immutable cold wallet set at construction. contract CharonLiquidator is IFlashLoanSimpleReceiver { // ───────────────────────────────────────────────────────────────────────── // Protocol ID constants — must mirror the Rust `ProtocolId` enum order. @@ -61,7 +64,8 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { // Immutable configuration — set once at construction, never changed. // ───────────────────────────────────────────────────────────────────────── - /// @notice The bot's hot wallet. Only address authorised to call executeLiquidation and rescue. + /// @notice The bot's hot wallet. Only address authorised to call executeLiquidation + /// and rescue. By policy it holds gas only — profit is never routed here. address public immutable owner; /// @notice Aave V3 Pool proxy on BNB Chain. @@ -72,6 +76,12 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// Mainnet: 0x13f4EA83D0bd40E75C8222255bc855a974568Dd4 address public immutable SWAP_ROUTER; + /// @notice Cold wallet — sole recipient of liquidation profit. + /// @dev Profit is transferred here inside executeOperation, never to the hot + /// wallet. Enforces the CLAUDE.md safety invariant that the bot wallet + /// holds gas only. Set once at construction and immutable thereafter. + address public immutable COLD_WALLET; + // ───────────────────────────────────────────────────────────────────────── // Structs // ───────────────────────────────────────────────────────────────────────── @@ -108,9 +118,15 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// @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. + /// @param profit Net profit in debtToken units swept to the cold wallet. + /// @param recipient The cold wallet address that received `profit` (indexed + /// so off-chain monitors can filter by destination). event LiquidationExecuted( - address indexed borrower, address indexed debtToken, uint256 repayAmount, uint256 profit + address indexed borrower, + address indexed debtToken, + uint256 repayAmount, + uint256 profit, + address indexed recipient ); /// @notice Emitted when the owner recovers tokens or native BNB via rescue(). @@ -145,18 +161,23 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { // Constructor // ───────────────────────────────────────────────────────────────────────── - /// @notice Deploys CharonLiquidator and permanently binds it to one Aave Pool - /// and one PancakeSwap V3 router. + /// @notice Deploys CharonLiquidator and permanently binds it to one Aave Pool, + /// one PancakeSwap V3 router, and one cold-wallet profit recipient. /// @dev msg.sender becomes the immutable owner (the bot's hot wallet). - /// Both addresses are validated non-zero at construction. + /// All three addresses are validated non-zero at construction. + /// The cold wallet is required: the CLAUDE.md safety invariant forbids + /// parking profit in the hot wallet. /// @param _aavePool Aave V3 Pool proxy address on BNB Chain. /// @param _swapRouter PancakeSwap V3 SwapRouter address on BNB Chain. - constructor(address _aavePool, address _swapRouter) { + /// @param _coldWallet Cold-wallet address that receives all liquidation profit. + 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; } // ───────────────────────────────────────────────────────────────────────── @@ -224,7 +245,7 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// f. Swap collateral underlying → debt token via PancakeSwap V3. /// g. Zero out SwapRouter approval (consumed). /// h. Verify post-swap balance covers totalOwed. - /// i. Sweep profit to owner. + /// i. Sweep profit to COLD_WALLET (NEVER to the hot wallet / owner). /// j. Emit LiquidationExecuted. /// k. Approve Aave Pool for totalOwed (Aave pulls this after we return). /// l. Return true. @@ -324,19 +345,23 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { // minSwapOut was set below totalOwed by the caller. require(finalBal >= totalOwed, "swap output below repayment"); - // ── Step 7: sweep profit to owner ───────────────────────────────────── - // Profit must leave this contract before we approve Aave, otherwise Aave - // could theoretically pull more than totalOwed if the token has quirks. + // ── Step 7: sweep profit to COLD WALLET ─────────────────────────────── + // Profit must leave this contract to the cold wallet (NOT the hot-wallet + // owner) before we approve Aave. This enforces the CLAUDE.md safety + // invariant: hot wallet holds gas only. Sweeping before approval also + // prevents Aave from pulling more than totalOwed if the debt token has + // quirks (fee-on-transfer, rebasing, etc.). uint256 profit = finalBal - totalOwed; if (profit > 0) { - // transfer return value not checked: owner is a trusted address set at - // construction; a failure here reverts the whole tx (excess funds stay - // in the contract until rescued). Standard ERC-20s revert on failure. - IERC20(p.debtToken).transfer(owner, profit); + // transfer return value not checked: COLD_WALLET is a trusted address + // set at construction; a failure here reverts the whole tx (excess + // funds stay in the contract until rescued). Standard ERC-20s revert + // on failure. + IERC20(p.debtToken).transfer(COLD_WALLET, profit); } // ── Step 8: emit before the final approval so logs reflect the full state ─ - emit LiquidationExecuted(p.borrower, p.debtToken, p.repayAmount, profit); + emit LiquidationExecuted(p.borrower, p.debtToken, p.repayAmount, profit, COLD_WALLET); // ── Step 9: approve Aave to pull totalOwed ──────────────────────────── // Aave pulls amount + premium from this contract after executeOperation From b189447f6ac07a474a522954846be73e2b0c60be Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 13:14:37 +0530 Subject: [PATCH 5/6] fix(contracts): wrap native BNB into WBNB on vBNB collateral path vBNB is the only Venus vToken whose underlying is native BNB. its `redeem()` transfers native BNB to msg.sender via a low-level call rather than emitting an IERC20.transfer, so the existing `IERC20(collateralToken).balanceOf(address(this))` read returned zero after redemption and the PancakeSwap V3 swap leg reverted with zero amountIn, stranding every seized vBNB position. add an IWETH interface and hard-code the BSC mainnet vBNB and WBNB addresses as internal constants. after redeem(), if the seized vToken is vBNB, wrap address(this).balance into WBNB via IWETH.deposit before the balance-of read. require callers that seize vBNB to declare WBNB as collateralToken so the swap routes through a real pool. closes #121 --- contracts/src/CharonLiquidator.sol | 44 ++++++++++++++++++++++++++++-- contracts/src/interfaces/IWETH.sol | 21 ++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 contracts/src/interfaces/IWETH.sol diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index fef1c6c..7c11037 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -6,6 +6,7 @@ import { IAaveV3Pool } from "./interfaces/IAaveV3Pool.sol"; import { IVToken } from "./interfaces/IVToken.sol"; import { ISwapRouter } from "./interfaces/ISwapRouter.sol"; import { IERC20 } from "./interfaces/IERC20.sol"; +import { IWETH } from "./interfaces/IWETH.sol"; // ───────────────────────────────────────────────────────────────────────────── // CharonLiquidator — multi-chain flash-loan liquidation engine, v0.1 @@ -17,6 +18,7 @@ import { IERC20 } from "./interfaces/IERC20.sol"; // a. Approve Venus vToken to spend the debt asset. // b. Call vToken.liquidateBorrow() — repay debt, seize collateral vTokens. // c. Call vToken.redeem() — convert ALL seized vTokens to underlying. +// Special case: vBNB returns native BNB, which we wrap into WBNB. // d. Swap collateral → debt asset via PancakeSwap V3. // e. Sweep profit to the COLD wallet — hot wallet (owner) holds gas only. // f. Approve Aave for repayment (amount + premium); Aave pulls it after return. @@ -51,6 +53,22 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// @dev ProtocolId::Venus = 3 in the Rust enum (0-indexed: Aave=0, Compound=1, ...). uint8 internal constant PROTOCOL_VENUS = 3; + // ───────────────────────────────────────────────────────────────────────── + // BNB Chain canonical addresses — hard-coded for the v0.1 BSC-only scope. + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Venus vBNB market — the only vToken whose underlying is native BNB. + /// Venus `redeem()` on this market transfers native BNB to msg.sender + /// rather than calling `IERC20.transfer`, so the standard ERC-20 + /// balance read used for every other vToken returns zero here. + /// Mainnet: https://bscscan.com/address/0xA07c5b74C9B40447a954e1466938b865b6BBea36 + address internal constant VBNB = 0xA07c5b74C9B40447a954e1466938b865b6BBea36; + + /// @notice Canonical Wrapped BNB (WBNB). PancakeSwap V3 pools are quoted in WBNB, + /// so any vBNB-seized position must be wrapped before the swap leg. + /// Mainnet: https://bscscan.com/address/0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c + address internal constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c; + // ───────────────────────────────────────────────────────────────────────── // Reentrancy guard — simple two-state lock. // Stored as uint256 rather than bool to match the Solidity optimizer's @@ -97,7 +115,9 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { 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). + /// @dev Underlying ERC-20 token posted as collateral (e.g., WBNB for vBNB). + /// For the vBNB path this MUST be WBNB — the contract wraps the + /// native BNB returned by Venus into WBNB before the swap. address collateralToken; /// @dev Venus vToken representing the debt side (e.g., vUSDT). address debtVToken; @@ -208,6 +228,13 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { require(params.debtVToken != address(0), "!debtVToken"); require(params.collateralVToken != address(0), "!collateralVToken"); require(params.repayAmount > 0, "!repayAmount"); + // On the vBNB path the underlying returned by Venus is native BNB, which + // the contract wraps into WBNB before swapping. Enforce that the caller + // declared WBNB as collateralToken so the swap leg routes through a real + // pool and post-swap balance checks read the correct token. + if (params.collateralVToken == VBNB) { + require(params.collateralToken == WBNB, "vBNB requires WBNB"); + } // ── Encode params and request the flash loan ────────────────────────── // Aave forwards `encoded` verbatim to executeOperation as the `data` @@ -242,6 +269,7 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// c. Approve debtVToken and call liquidateBorrow on Venus. /// d. Zero out debtVToken approval (consumed). /// e. Redeem all seized collateral vTokens for underlying. + /// If the seized vToken is vBNB, wrap the returned native BNB into WBNB. /// f. Swap collateral underlying → debt token via PancakeSwap V3. /// g. Zero out SwapRouter approval (consumed). /// h. Verify post-swap balance covers totalOwed. @@ -312,8 +340,20 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { uint256 redeemErr = IVToken(p.collateralVToken).redeem(vBal); require(redeemErr == 0, "venus redeem failed"); + // vBNB returns NATIVE BNB, not an ERC-20. Wrap the full native balance + // into WBNB so the swap leg can treat it uniformly with every other + // vToken underlying. Reading IERC20(vBNB-underlying).balanceOf would + // return zero and the swap would revert. Wrapping before the balance + // read ensures `collateralBal` picks up the full seized amount. + if (p.collateralVToken == VBNB) { + uint256 nativeBal = address(this).balance; + require(nativeBal > 0, "no native BNB redeemed"); + IWETH(WBNB).deposit{ value: nativeBal }(); + } + // ── Step 5: swap collateral underlying → debt token via PancakeSwap V3 ─ - // Read the full collateral balance just redeemed; use it as exact amountIn. + // Read the full collateral balance just redeemed (or wrapped, for vBNB); + // use it as exact amountIn. uint256 collateralBal = IERC20(p.collateralToken).balanceOf(address(this)); // Approve the router for the exact amount we are about to swap. diff --git a/contracts/src/interfaces/IWETH.sol b/contracts/src/interfaces/IWETH.sol new file mode 100644 index 0000000..60464be --- /dev/null +++ b/contracts/src/interfaces/IWETH.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +/// @title IWETH +/// @notice Minimal interface for the Wrapped BNB (WBNB) contract on BNB Chain. +/// @dev Canonical WBNB address on BSC mainnet: 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c. +/// Identical ABI to canonical WETH9 — `deposit()` wraps native BNB into the ERC-20 +/// representation that DEX pools expect; `withdraw()` unwraps it back to native. +/// CharonLiquidator uses this exclusively on the vBNB redemption path: Venus +/// `vBNB.redeem()` returns native BNB, so the contract wraps the balance into +/// WBNB before forwarding to the PancakeSwap V3 router. +interface IWETH { + /// @notice Wraps the attached native BNB into an equivalent WBNB balance for msg.sender. + /// @dev MUST be called with non-zero `msg.value` — the contract mints 1:1 to the sender. + function deposit() external payable; + + /// @notice Unwraps `amount` WBNB of the caller back to native BNB. + /// @dev Reverts if the caller's WBNB balance is below `amount`. + /// @param amount The number of WBNB to unwrap. + function withdraw(uint256 amount) external; +} From 19bce9af68bbfd9137ac3d2b1e0f8c89bc444c46 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 13:15:31 +0530 Subject: [PATCH 6/6] fix(contracts): make PancakeSwap V3 fee tier per-opportunity configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the swap leg hard-coded `fee: 3000` (0.30 %). that tier does not exist or has near-zero liquidity for several Venus-collateral pairs on PCS V3: BTCB/USDT sits in the 0.05 % (500) pool, ETH/USDT in the 0.01 % (100) pool, XVS/WBNB in the 1 % (10000) pool. routing those through the 0.30 % pool would revert on `SPL` or eat unbounded slippage. add `uint24 swapPoolFee` to LiquidationParams, validate non-zero in executeLiquidation, and pass it to ExactInputSingleParams.fee. the off-chain opportunity router now selects the deepest pool per pair. abi layout note: this extends LiquidationParams with a new tail field. the companion Rust `LiquidationParams` builder in the charon-executor crate (not yet present on this branch — lands with PR #41) must mirror the added field. closes #122 --- contracts/src/CharonLiquidator.sol | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index 7c11037..2caea60 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -19,7 +19,8 @@ import { IWETH } from "./interfaces/IWETH.sol"; // b. Call vToken.liquidateBorrow() — repay debt, seize collateral vTokens. // c. Call vToken.redeem() — convert ALL seized vTokens to underlying. // Special case: vBNB returns native BNB, which we wrap into WBNB. -// d. Swap collateral → debt asset via PancakeSwap V3. +// d. Swap collateral → debt asset via PancakeSwap V3 at the caller-supplied +// fee tier (500 / 3000 / 10000 depending on the pool). // e. Sweep profit to the COLD wallet — hot wallet (owner) holds gas only. // f. Approve Aave for repayment (amount + premium); Aave pulls it after return. // @@ -108,6 +109,9 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// @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. + /// NOTE: the companion Rust `LiquidationParams` builder lives in the + /// charon-executor crate (landing in a later PR). When that crate is added + /// it must mirror this layout exactly, including `swapPoolFee`. struct LiquidationParams { /// @dev Protocol identifier. Must equal PROTOCOL_VENUS (3) for v0.1. uint8 protocolId; @@ -128,6 +132,13 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// @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; + /// @dev PancakeSwap V3 pool fee tier (hundredths of a bip) for the + /// collateral → debt swap. Live tiers on PCS V3: 100 / 500 / 2500 / + /// 10000; Uniswap-equivalent 3000 is also deployed. BTCB, ETH and XVS + /// deep pools are at 500 or 10000, not 3000 — hardcoding would route + /// through an empty pool and revert. Supplied per-opportunity by the + /// off-chain router. Must be non-zero. + uint24 swapPoolFee; } // ───────────────────────────────────────────────────────────────────────── @@ -228,6 +239,7 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { require(params.debtVToken != address(0), "!debtVToken"); require(params.collateralVToken != address(0), "!collateralVToken"); require(params.repayAmount > 0, "!repayAmount"); + require(params.swapPoolFee > 0, "!swapPoolFee"); // On the vBNB path the underlying returned by Venus is native BNB, which // the contract wraps into WBNB before swapping. Enforce that the caller // declared WBNB as collateralToken so the swap leg routes through a real @@ -270,7 +282,8 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// d. Zero out debtVToken approval (consumed). /// e. Redeem all seized collateral vTokens for underlying. /// If the seized vToken is vBNB, wrap the returned native BNB into WBNB. - /// f. Swap collateral underlying → debt token via PancakeSwap V3. + /// f. Swap collateral underlying → debt token via PancakeSwap V3 at the + /// caller-supplied pool fee tier. /// g. Zero out SwapRouter approval (consumed). /// h. Verify post-swap balance covers totalOwed. /// i. Sweep profit to COLD_WALLET (NEVER to the hot wallet / owner). @@ -364,7 +377,7 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { ISwapRouter.ExactInputSingleParams({ tokenIn: p.collateralToken, tokenOut: p.debtToken, - fee: 3000, // 0.30 % pool — most liquid tier on PCS V3 for major pairs + fee: p.swapPoolFee, // caller-supplied — 500 / 2500 / 3000 / 10000 depending on pool recipient: address(this), deadline: block.timestamp, amountIn: collateralBal,