Skip to content

test(contracts): CharonLiquidator fork-test suite across 5 Venus markets#53

Merged
obchain merged 7 commits intomainfrom
feat/25-foundry-fork-tests
Apr 24, 2026
Merged

test(contracts): CharonLiquidator fork-test suite across 5 Venus markets#53
obchain merged 7 commits intomainfrom
feat/25-foundry-fork-tests

Conversation

@obchain
Copy link
Copy Markdown
Owner

@obchain obchain commented Apr 22, 2026

Summary

  • New contracts/test/CharonLiquidatorFork.t.sol: 4 tests, 5-market parametric happy path + 2 slippage edge cases + 1 fork sanity check
  • Runs against a live BSC mainnet fork (unpinned block; BNB_HTTP_URL aliased as bnb in contracts/foundry.toml)
  • Exercises the real Aave V3 flash-loan callback loop; mocks Venus + PancakeSwap to keep the suite deterministic

Why fork tests at all

Pool.flashLoanSimple walks proxy → implementation → aToken.transfer → executeOperation → pull-back. That sequence is the single piece of CharonLiquidator that can't be meaningfully tested against inline mocks — if Aave changes its callback wiring we need the fork to catch it. Unit tests in CharonLiquidator.t.sol stay authoritative for access control, input validation, rescue(), and reentrancy.

Markets covered

# Debt Collateral Rationale
0 USDT USDC stable/stable — baseline sanity
1 USDT BTCB stable/volatile
2 USDC BTCB second stablecoin-debt path
3 USDT ETH larger-decimal debt
4 BTCB USDT volatile/stable — direction symmetry

BUSD is intentionally excluded — Aave V3 BSC has deactivated the BUSD reserve, so flashLoanSimple(BUSD, …) reverts with ReserveInactive() and the error is not related to contract logic. vBNB is excluded because its redemption yields native BNB, which the contract treats as ERC-20 (a wrap-path follow-up).

Slippage edges

  • Router reverts with PancakeSwap's "Too little received" → the revert must propagate
  • Surplus seeded as 0 → the contract's own "swap output below repayment" guard fires

Test plan

  • forge build clean (same pre-existing 2 lint warnings on CharonLiquidator.sol, unchanged)
  • forge test — 24 pass + 1 skip (pre-existing PCS research placeholder)
  • Fork suite: 4/4 pass against https://bsc-rpc.publicnode.com
  • Workspace cargo sweep still green
  • Follow-up: un-skip test_executeLiquidation_endToEndOnFork once a stable PCS V3 pair + block is identified (outside M4 scope)

Stacked PR

Base is feat/24-anvil-fork (PR #52). Merge order: #46#50#51#52 → this.

Closes #23.

New `contracts/test/CharonLiquidatorFork.t.sol` exercises the
liquidator against a BNB Smart Chain mainnet fork. The real value
these tests add over the pre-existing unit-mock suite is the
integration with the live Aave V3 Pool — the only environment where
`Pool.flashLoanSimple` (proxy → implementation → aToken.transfer →
`executeOperation` → pull-back via approval) runs unmodified.

Suite shape:
- A. Parametric happy-path (`test_forkHappyPath_acrossAllMarkets`)
  iterates five (debt, collateral) pairs spanning stable/stable,
  stable/volatile, volatile/stable, and mixed-decimal combinations.
  Each iteration deploys a clean liquidator, seeds the Aave premium
  plus a small profit buffer, mocks Venus (liquidateBorrow,
  balanceOf, redeem) and PancakeSwap (exactInputSingle), and asserts
  the owner received profit and that `LiquidationExecuted` fired
  with matching topics. Event matching uses `vm.recordLogs` + topic
  walk rather than `vm.expectEmit` — more resilient inside a loop
  where non-target logs fire between setup and the emit of interest.

- B. Slippage edge cases:
  - `test_fork_slippage_tooTight_reverts` mocks the router to revert
    with PancakeSwap's canonical `"Too little received"` string and
    expects that revert to propagate.
  - `test_fork_underRepayment_reverts` seeds zero surplus so the
    post-swap balance falls short of `amount + premium` and the
    contract's `"swap output below repayment"` defensive guard
    fires.

- C. `test_fork_realContractsHaveCode` is a front-door sanity check
  that surfaces a clear failure message if the configured BSC RPC
  endpoint doesn't expose the pinned mainnet addresses.

Notable exclusions:
- BUSD is deliberately absent. Its Aave V3 reserve on BSC has been
  deactivated; `flashLoanSimple(BUSD, …)` now reverts with
  `ReserveInactive()` regardless of the contract under test.
- vBNB is excluded because its redemption yields native BNB, which
  `CharonLiquidator` assumes is ERC-20 — a separate WBNB-wrap path
  is needed before the contract can serve that market.

Mock strategy (Venus + PCS) trades realism for determinism:
identifying an underwater borrower + a stable PCS V3 pair at a
pinned block would need ongoing archive research that doesn't
belong in a regression suite. The previously-skipped
`test_executeLiquidation_endToEndOnFork` is left in place as the
marker for that future unmocked-PCS work.

Closes #23.
This was referenced Apr 22, 2026
obchain added a commit that referenced this pull request Apr 23, 2026
add in-file Venus vToken, pcs v3 swap router, and ERC-20 mocks so
executeOperation can be driven end-to-end without a mainnet fork.
new coverage:

- full happy-path callback: liquidateBorrow + redeem + swap + profit
  sweep, with vm.expectEmit on LiquidationExecuted asserting exact
  profit value and indexed topics.
- asset/debt and amount/repay mismatch guards both revert.
- two slippage paths: router-side `amountOutMinimum` enforcement with
  a nonzero minSwapOut, plus the defensive
  `swap output below repayment` check when minSwapOut is zero.
- constructor zero-address guards for aavePool and swapRouter.
- rescue() to a contract recipient whose receive() reverts — pins
  the 2300-gas `transfer` behaviour (issue #135) and documents the
  loss-of-funds safety of the current path.

extend MockERC20 with allowance-aware `approve` and `transferFrom`
(needed by the mock router's real `transferFrom` pull).

retarget the skipped fork test to issue #53 (feat/25) — the
placeholder pointed at a non-existent #22.x.
obchain added 3 commits April 23, 2026 16:35
solc 0.8.24 defaults to Shanghai codegen and emits PUSH0 (0x5f).
BSC mainnet runs a pre-Shanghai EVM, so every deploy of the fork
suite's CharonLiquidator lands bytecode that would fault on BSC.
forge-test runs against revm which accepts PUSH0, so every passing
fork test was a false signal against the wrong artifact.

Add `evm_version = "paris"` to contracts/foundry.toml and strip the
caret from every `pragma solidity ^0.8.24;` so a future solc patch
cannot silently shift codegen under the same source tree. Fork
tests now exercise the same bytecode shape that would deploy to
BSC mainnet.

Closes #263
setUp() called vm.createSelectFork("bnb") with no block, so every
CI run forked whatever head the archive RPC exposed. Aave V3
reserve state, vToken exchange rates, and ERC-20 balances drift
block-to-block — a green run one day was not a guarantee of a
green run the next, and a broken fix could slip through on a
favourable fork height.

Pin FORK_BLOCK = 94_000_000 (captured 2026-04-23; every referenced
Aave V3 reserve and Venus vToken is live at that height). Keep an
escape hatch via `vm.envOr("BSC_FORK_BLOCK", FORK_BLOCK)` so ad-hoc
investigation against a different block does not require editing
the source. Bump the constant in a dedicated, reviewed commit when
refreshing the baseline.

Closes #264
split the single five-market loop into five named per-market tests
so a regression in one pool no longer masks the other four (#272).
drop the inline keccak selector comparison and use the compiler-
checked `CharonLiquidator.LiquidationExecuted` / `.BatchExecuted`
event emitters; renaming the event now breaks the tests at compile
time rather than at runtime (#273).

stop mocking the pancakeswap v3 router on the happy path. the real
on-fork router executes the collateral -> debt swap so the
`amountOutMinimum` slippage floor, post-swap balance check and
router-side pool accounting are all exercised end-to-end (#266).
size `minSwapOut` from the real pancakeswap quoter v2 with a 50bps
floor; add a dedicated slippage-revert test that sets `minSwapOut`
one wei above the live quote and expects the router's canonical
`"Too little received"` revert (#267).

add `test_forkBatchExecute_uniqueCollateralMarkets_happyPath` to
drive four markets with distinct collateral tokens through
`CharonLiquidator.batchExecute` in one transaction. asserts one
`LiquidationExecuted` per iteration, a single terminal
`BatchExecuted(n)` with matching count, zero dust across every
touched erc-20, and the full batch under the batch gas ceiling
(#268). markets that share collateral underlyings (usdt/btcb and
usdc/btcb) are deliberately excluded so the first iteration's real
swap does not drain the shared seeded balance before the second
iteration runs.

snapshot gas around every liquidation call, log
`gas_used_liquidation` / `gas_used_batch_unique`, and assert
against per-market and per-batch ceilings sized at ~1.25x observed
usage. fail fast on regressions (#274).

probe for a real bsc archive rpc in `setUp` by reading multicall3
bytecode at `FORK_BLOCK - 5000`. if the endpoint is non-archive,
`vm.skip` the entire suite with an operator-facing reason string
so ci logs never misattribute a non-archive failure to contract
logic (#269).

scope note: #265 (cold-wallet profit sweep) and #270 (vbnb -> wbnb
wrap) depend on contract changes that have not yet landed on this
branch and are left for a follow-up commit once those contract
changes arrive.
obchain added a commit that referenced this pull request Apr 23, 2026
Wraps Batcher::encode_calldata's output in an opaque
UnsimulatedBatchCalldata newtype. The only promotion path is
Batcher::simulate, which runs the buffer through Simulator::simulate
and returns SimulatedBatchCalldata on success. A broadcaster written
against this crate that accepts only SimulatedBatchCalldata cannot
be handed raw encoder output by mistake, so the CLAUDE.md invariant
"no broadcast without a passing eth_call" becomes a compile-time
guarantee for the batch path (mirroring the UnverifiedPreSigned
guard on the mempool pre-sign path).

BatcherError gains a SimulationFailed variant carrying the node's
revert string.

Foundry-side batchExecute fork test stays on feat/25 / PR #53.

Closes #298
CharonLiquidator gains an immutable `coldWallet` constructor arg.
Profit is swept to coldWallet instead of owner on every successful
executeOperation (and therefore every executeLiquidation and every
iteration of batchExecute). Enforces the CLAUDE.md safety invariant
("profit must not park at the hot wallet") at the contract layer so
a compromised bot key cannot drain accumulated earnings.

Constructor requires coldWallet != address(0) and
coldWallet != msg.sender — immutability makes the check one-shot
so the runtime cost is zero per liquidation.

Fork test now deploys every liquidator with a distinct coldWallet
(makeAddr) and the happy-path assertion checks BOTH that the cold
wallet received profit AND that the hot wallet balance did NOT
grow. The double-sided check catches any future contract change
that would split profit between wallets (a single-sided assert
would silently pass that regression).

Also adds a fail-loud `vm.skip(true)` placeholder for vBNB native
collateral fork coverage so #121's eventual fix lands on a visible
scaffold instead of yet another missing-regression-guard gap. Body
documents the wrap-to-WBNB plus cold-wallet profit-sweep assertion
expected of the real test.

Unit suite green (20 pass), fork test setUp correctly errors on an
unset BNB_HTTP_URL when run without a live archive RPC.

Closes #265
Closes #270
@obchain obchain changed the base branch from feat/24-anvil-fork to main April 24, 2026 14:32
…tests

# Conflicts:
#	contracts/foundry.toml
#	contracts/src/CharonLiquidator.sol
#	contracts/test/CharonLiquidator.t.sol
- Add `swapPoolFee: 3000` to `_params` so the on-fork swap tier matches
  the tier quoted via Quoter V2 in `_minOutFromQuoter`. Without it, the
  post-#38 `LiquidationParams` struct literal is missing a field and
  `_validate`'s `swapPoolFee > 0` check rejects the call.
- Expect the post-#38 `LiquidationExecuted` event shape (3 indexed
  topics including `recipient`, 2 data fields). Asserting the
  `recipient` topic is load-bearing: it pins the CLAUDE.md cold-wallet
  invariant at the log level, mirroring the runtime balance assertions.
- Gate `setUp` on `BNB_HTTP_URL` before calling `vm.createSelectFork`.
  The `bnb` RPC alias resolves the env var at fork-create time and
  raises a hard failure when missing; the gate mirrors the skip-on-env
  pattern used by the unit suite so CI without the env var skips
  cleanly.
@obchain obchain merged commit e6f6b5f into main Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[test] Foundry fork tests for CharonLiquidator (all Venus markets + slippage edge cases)

1 participant