Skip to content

[REF ONLY] Add BadDebtHelper for atomic bad debt repayment (VIP-604)#666

Draft
Debugger022 wants to merge 20 commits intodevelopfrom
feat/VPD-854
Draft

[REF ONLY] Add BadDebtHelper for atomic bad debt repayment (VIP-604)#666
Debugger022 wants to merge 20 commits intodevelopfrom
feat/VPD-854

Conversation

@Debugger022
Copy link
Copy Markdown
Contributor

@Debugger022 Debugger022 commented Mar 25, 2026

Summary

  • Add BadDebtHelper contract that atomically repays bad debt across 19 BEP20 tokens + native BNB in the BSC Core Pool
  • The helper temporarily accepts vTHE admin to sweep & repay THE bad debt, then hands admin back to the Normal Timelock
  • Includes BSC mainnet fork test validating the full end-to-end flow

Changes

Contract (BadDebtHelper.sol)

  • Upgradeable contract (Ownable2StepUpgradeable + ReentrancyGuardUpgradeable)
  • _repayTHE(): accepts vTHE admin → sweeps THE → repays 8 borrowers → transfers remaining THE to target receiver → returns admin
  • _repayBEP20(): repays bad debt for 17 BEP20 tokens across 20+ borrowers using live borrowBalanceStored, returns unused tokens to timelock
  • _repayBNB(): repays 5 BNB borrowers with fixed amounts, returns unused BNB to timelock
  • Handles partial repayment gracefully when available balance < debt

Fork test (badDebtHelper.ts)

  • 28 test cases against BSC mainnet fork
  • Verifies: all THE borrower debts cleared, THE transferred to receiver, vTHE admin returned, all BEP20/BNB debts reduced, no tokens left in helper, unused tokens returned to timelock

Test plan

  • FORKED_NETWORK=bscmainnet npx hardhat test tests/hardhat/Fork/badDebtHelper.ts — 28 passing

Summary by CodeRabbit

  • New Features

    • Added BadDebtHelper to automate recovery and repayment of bad debt across THE, BEP20 tokens, and native BNB, with controlled execution, targeted repayments, and post-operation fund returns.
  • Chores

    • Added deployment scripts and mainnet deployment metadata for the new helper.
  • Tests

    • Added a mainnet-fork test suite validating recovery flows, repayments, refunds, and access controls.

Helper contract that accepts temporary vTHE admin to sweep, repay
bad debt across 19 tokens + BNB, and return admin to the timelock
in a single atomic transaction.
Verifies end-to-end execution: THE sweep/repay/transfer, BEP20
repayments across 17 markets, BNB repayments, and admin handback.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f450116a-8b7e-4c9a-ab55-8bb4d1414428

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new BadDebtHelper contract, deploy script, artifacts, and a mainnet-fork test. The helper (callable only by NORMAL_TIMELOCK) performs three sequential recovery phases: vTHE admin/sweep/repay, BEP20 token repay loop with allowance handling, and native BNB repay/refund; also adds sweep and receive functions.

Changes

Cohort / File(s) Summary
Core contract
contracts/Tokens/VTokens/BadDebtHelper.sol
New contract implementing execute() (payable) with three phases: vTHE admin/sweep/repay for predefined THE borrowers, BEP20 underlying/vToken repay loop with per-token allowance management and finalization, native BNB repay/refund logic, plus sweep(...), constants (NORMAL_TIMELOCK, THE_TARGET_RECEIVER, TREASURY), events, errors, and receive().
Deployment script
deploy/019-deploy-bad-debt-helper.ts
New Hardhat deploy function registering metadata (id, tags, skip) and deploying BadDebtHelper.
Deployment manifests / artifacts
deployments/bscmainnet.json, deployments/bscmainnet_addresses.json, deployments/bscmainnet/solcInputs/...cdb75d6193...json
Added deployed address, ABI (errors, events, functions), and solc input bundle embedding OpenZeppelin interfaces, approveOrRevert.sol, and BadDebtHelper.sol.
Fork tests
tests/hardhat/Fork/badDebtHelper.ts
New mainnet-fork test that upgrades vTHE, funds the helper via storage writes, runs execute({ value }) from the timelock, and asserts THE debts cleared, BEP20/BNB debts decreased, tokens/BNB returned/refunded, helper holds no THE/BNB, admin restored, and access control enforced.

Sequence Diagram

sequenceDiagram
    actor Timelock as NORMAL_TIMELOCK
    participant Helper as BadDebtHelper
    participant vTHE
    participant BEP20 as BEP20_Markets
    participant vBNB
    participant Treasury

    Timelock->>Helper: execute() {value: BNB}

    rect rgba(100,150,200,0.5)
    Note over Helper,vTHE: Phase 1 — THE repayment
    Helper->>vTHE: _acceptAdmin()
    Helper->>vTHE: sweepToken(THE)
    Helper->>vTHE: approve(THE, amount)
    loop THE borrowers
        Helper->>vTHE: repayBorrowBehalf(borrower)
    end
    Helper->>vTHE: sweepToken(THE)
    Helper->>Helper: transfer remaining THE -> THE_TARGET_RECEIVER
    Helper->>vTHE: _setPendingAdmin(NORMAL_TIMELOCK)
    end

    rect rgba(150,200,100,0.5)
    Note over Helper,BEP20: Phase 2 — BEP20 repayments
    loop for each underlying token
        Helper->>Token: approve(underlying, MAX) (once)
        loop borrowers for token
            Helper->>vToken: repayBorrowBehalf(borrower)
        end
        Helper->>Token: approve(underlying, 0)
        Helper->>Timelock: transfer remaining tokens
    end
    end

    rect rgba(200,150,100,0.5)
    Note over Helper,vBNB: Phase 3 — BNB repayments
    loop BNB borrowers
        Helper->>vBNB: repayBorrowBehalf{value: debt}(borrower)
    end
    Helper->>Timelock: refund remaining BNB
    Helper->>Treasury: transfer remaining ERC20 (non-THE)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

Suggested reviewers

  • fred-venus

Poem

🐰 I hopped in with a pocket full of care,
Swept THE neat, repaid the weary share,
Tokens sent back, BNB returned with cheer,
Timelock restored — all tidy, no fear. ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers the key changes and includes a comprehensive summary, but the checklist is incomplete—none of the required checkbox items are marked. Complete the checklist by marking applicable items (documentation updates, tests added/verified, and contribution guidelines compliance) to confirm all requirements are met.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding a BadDebtHelper contract for atomic bad debt repayment, with reference to VIP-604.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/VPD-854

Comment @coderabbitai help to get the list of available commands and usage tips.

Debugger022 and others added 4 commits March 25, 2026 19:05
Drop upgradeable proxy — unnecessary for a one-shot helper. Fix approval
ceiling (use type(uint256).max) and stale event emission (use balance
diff). Remove redundant balanceOf call in _repayToken for gas savings.
@Debugger022 Debugger022 marked this pull request as ready for review March 25, 2026 13:54
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review.

Tip: disable this comment in your organization's Code Review settings.

@Debugger022
Copy link
Copy Markdown
Contributor Author


Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ca6d45c7-89bd-4774-ab76-916f97f8e715

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

✨ Finishing Touches
🧪 Generate unit tests (beta)

  • Create PR with unit tests
  • Commit unit tests in branch feat/VPD-854

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai review this now^

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

@Debugger022: Sure! Triggering a review of this PR now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/Tokens/VTokens/BadDebtHelper.sol`:
- Around line 355-361: Replace uses of vToken.borrowBalanceStored(borrower) with
vToken.borrowBalanceCurrent(borrower) in the BadDebtHelper repayment logic (both
the block at the shown lines and the similar block at 381-386) so the repay
branch uses the up-to-date debt before deciding repayAmount and calling
repayBorrowBehalf; i.e., call borrowBalanceCurrent, return if zero, compute
balance = underlying.balanceOf(address(this)), then compute repayAmount based on
the current debt (debt <= balance ? type(uint256).max : balance) and call
repayBorrowBehalf(borrower, repayAmount).

In `@deploy/019-deploy-bad-debt-helper.ts`:
- Line 24: The deploy script currently sets func.skip = skipRemoteNetworks(),
which still permits bsctestnet and hardhat; change the skip predicate so the
script runs only on BSC mainnet (e.g., require network name equals "bscmainnet")
and optionally allow local forked hardhat runs (e.g., allow when network is
"hardhat" and FORK env is set); update the assignment to func.skip to check for
those exact network names instead of calling skipRemoteNetworks(), so this
helper cannot run on testnets by accident.

In `@tests/hardhat/Fork/badDebtHelper.ts`:
- Around line 421-433: The current checks only ensure timelock balances didn't
decrease but don't confirm refunds went to NORMAL_TIMELOCK; update the BEP20
loop (using BEP20_MARKETS, ERC20__factory, NORMAL_TIMELOCK, preTimelockBalances)
to assert that postTimelockBalance is strictly greater than preTimelockBalance
(i.e., a positive refund delta) rather than just >=; likewise for the BNB path
(the assertions around the helper/BNB zero-balance at lines ~464-467),
deliberately overfund the BNB test by a known surplus and assert that
NORMAL_TIMELOCK's native balance increased by the expected refund amount (not
just that the helper balance is zero) so the refund recipient is validated.
- Around line 322-325: When findBalanceSlot(market.underlying) returns null for
a market, do not silently continue; either throw or record the skipped market so
the later repayment assertions do not include it. Update the block around
findBalanceSlot/market.name to (a) throw a descriptive error (including
market.name and market.underlying) so the suite fails fast, or (b) push market
identifier into a skippedMarkets array and modify the later repayment assertion
logic (the code that iterates borrowers to verify post-debt balances) to ignore
any markets present in skippedMarkets. Ensure the chosen approach updates all
references to repayment assertions so they only evaluate markets that were
actually funded.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a74402af-5ee0-47c0-8b3e-c243a8505d72

📥 Commits

Reviewing files that changed from the base of the PR and between 3c4d06f and 022ad22.

📒 Files selected for processing (7)
  • contracts/Tokens/VTokens/BadDebtHelper.sol
  • deploy/019-deploy-bad-debt-helper.ts
  • deployments/bscmainnet.json
  • deployments/bscmainnet/BadDebtHelper.json
  • deployments/bscmainnet/solcInputs/cdb75d619363c63d9052ded803cba631.json
  • deployments/bscmainnet_addresses.json
  • tests/hardhat/Fork/badDebtHelper.ts

Comment on lines +355 to +361
uint256 debt = vToken.borrowBalanceStored(borrower);
if (debt == 0) return;

uint256 balance = underlying.balanceOf(address(this));
uint256 repayAmount = debt <= balance ? type(uint256).max : balance;

uint256 err = vToken.repayBorrowBehalf(borrower, repayAmount);
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

borrowBalanceStored() makes the max repayment path unsafe.

Lines 355-361 and 381-386 branch on a stale stored debt value, then call repayBorrowBehalf(), which accrues again. If the helper balance only matches the stored snapshot, the type(uint256).max path can still revert once the live debt is slightly higher, which defeats the timelock-delay tolerance this helper is trying to provide. borrowBalanceCurrent() is already available on the shared interface and should drive both branches.

Patch sketch
-        uint256 debt = vToken.borrowBalanceStored(borrower);
+        uint256 debt = vToken.borrowBalanceCurrent(borrower);
         if (debt == 0) return;

         uint256 balance = underlying.balanceOf(address(this));
+        if (balance == 0) return;
         uint256 repayAmount = debt <= balance ? type(uint256).max : balance;
@@
-        uint256 debt = vToken.borrowBalanceStored(borrower);
+        uint256 debt = vToken.borrowBalanceCurrent(borrower);
         if (debt == 0) return;

         uint256 balanceBefore = THE.balanceOf(address(this));
-        uint256 err = vToken.repayBorrowBehalf(borrower, type(uint256).max);
+        if (balanceBefore == 0) return;
+        uint256 repayAmount = debt <= balanceBefore ? type(uint256).max : balanceBefore;
+        uint256 err = vToken.repayBorrowBehalf(borrower, repayAmount);

Also applies to: 381-386

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/Tokens/VTokens/BadDebtHelper.sol` around lines 355 - 361, Replace
uses of vToken.borrowBalanceStored(borrower) with
vToken.borrowBalanceCurrent(borrower) in the BadDebtHelper repayment logic (both
the block at the shown lines and the similar block at 381-386) so the repay
branch uses the up-to-date debt before deciding repayAmount and calling
repayBorrowBehalf; i.e., call borrowBalanceCurrent, return if zero, compute
balance = underlying.balanceOf(address(this)), then compute repayAmount based on
the current debt (debt <= balance ? type(uint256).max : balance) and call
repayBorrowBehalf(borrower, repayAmount).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a false positive. borrowBalanceStored is intentionally used here rather than borrowBalanceCurrent for the following reasons:

  1. repayBorrowBehalf already calls accrueInterest() internally (via repayBorrowBehalfInternal), so the actual repayment always operates on the fully up-to-date debt.

  2. The borrowBalanceStored call is only used for pre-checks — the zero-debt early return and the type(uint256).max vs balance decision. Any interest delta between the stale read
    and the accrued value inside repayBorrowBehalf is at most one block of interest within the same transaction, which is negligible.

  3. Using borrowBalanceCurrent would redundantly accrue interest twice — once explicitly here, and once inside repayBorrowBehalf — wasting gas for no practical benefit.

  4. The helper is funded with margin from the timelock to absorb any tiny interest accrual difference.

No changes needed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Debugger022, you're right — the reasoning is sound on all four points. Since repayBorrowBehalf already calls accrueInterest() internally, the stored value is only used as a lightweight pre-check for the branching decision, and any single-block interest delta is covered by the funding margin. Calling borrowBalanceCurrent would just burn extra gas with a redundant accrual. I'll retract this finding.


✏️ Learnings added
Learnt from: Debugger022
Repo: VenusProtocol/venus-protocol PR: 666
File: contracts/Tokens/VTokens/BadDebtHelper.sol:355-361
Timestamp: 2026-03-25T14:48:13.207Z
Learning: In BadDebtHelper.sol (contracts/Tokens/VTokens/BadDebtHelper.sol), `borrowBalanceStored` is intentionally used (instead of `borrowBalanceCurrent`) for the zero-debt early-return check and the `type(uint256).max` vs `balance` branching decision. This is by design: `repayBorrowBehalf` already calls `accrueInterest()` internally (via `repayBorrowBehalfInternal`), so the actual repayment always operates on the fully up-to-date debt. Using `borrowBalanceCurrent` would redundantly accrue interest twice and waste gas. The helper is also funded with margin to absorb any tiny single-block interest delta. Do not flag this as an issue in future reviews.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


func.id = "bad_debt_helper_deploy";
func.tags = ["BadDebtHelper"];
func.skip = skipRemoteNetworks();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Narrow this deploy to mainnet-only.

Line 24 reuses skipRemoteNetworks(), which still allows bsctestnet and hardhat. This helper is wired to BSC mainnet markets/borrowers, so a testnet deployment would produce a contract that looks valid but points at the wrong pool state and timelock. Please gate this script to bscmainnet only, or explicitly to forked hardhat if you need local smoke deployments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@deploy/019-deploy-bad-debt-helper.ts` at line 24, The deploy script currently
sets func.skip = skipRemoteNetworks(), which still permits bsctestnet and
hardhat; change the skip predicate so the script runs only on BSC mainnet (e.g.,
require network name equals "bscmainnet") and optionally allow local forked
hardhat runs (e.g., allow when network is "hardhat" and FORK env is set); update
the assignment to func.skip to check for those exact network names instead of
calling skipRemoteNetworks(), so this helper cannot run on testnets by accident.

Comment on lines +322 to +325
const slot = await findBalanceSlot(market.underlying);
if (slot === null) {
console.log(` WARNING: Could not find balance slot for ${market.name}, skipping funding`);
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't silently skip a market that the suite still expects to repay.

If Line 323 returns null and Line 325 continues, the later debt assertions still require every non-zero borrower in that market to have lower post-debt. That turns a known setup failure into a downstream, misleading test failure. Fail fast here, or record skipped markets and exclude them from the repayment assertions.

Suggested fix
           const slot = await findBalanceSlot(market.underlying);
           if (slot === null) {
-            console.log(`      WARNING: Could not find balance slot for ${market.name}, skipping funding`);
-            continue;
+            throw new Error(
+              `Could not find balance slot for ${market.name}; cannot fund helper or verify this market on the fork`,
+            );
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const slot = await findBalanceSlot(market.underlying);
if (slot === null) {
console.log(` WARNING: Could not find balance slot for ${market.name}, skipping funding`);
continue;
const slot = await findBalanceSlot(market.underlying);
if (slot === null) {
throw new Error(
`Could not find balance slot for ${market.name}; cannot fund helper or verify this market on the fork`,
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 322 - 325, When
findBalanceSlot(market.underlying) returns null for a market, do not silently
continue; either throw or record the skipped market so the later repayment
assertions do not include it. Update the block around
findBalanceSlot/market.name to (a) throw a descriptive error (including
market.name and market.underlying) so the suite fails fast, or (b) push market
identifier into a skippedMarkets array and modify the later repayment assertion
logic (the code that iterates borrowers to verify post-debt balances) to ignore
any markets present in skippedMarkets. Ensure the chosen approach updates all
references to repayment assertions so they only evaluate markets that were
actually funded.

Comment on lines +421 to +433
it("should return unused BEP20 tokens to timelock", async () => {
// For each token, timelock should have gotten back any excess
for (const market of BEP20_MARKETS) {
const token = ERC20__factory.connect(market.underlying, ethers.provider);
const postTimelockBalance = await token.balanceOf(NORMAL_TIMELOCK);
const preTimelockBalance = preTimelockBalances[market.underlying];

// Timelock balance should be >= pre-balance (got back unused tokens)
expect(postTimelockBalance).to.be.gte(
preTimelockBalance,
`Timelock ${market.name} balance should not decrease`,
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These assertions don't actually verify the refund recipient.

Because Line 320 intentionally overfunds each BEP20 market by 2x, the refund path should be exercised for every funded market. But Lines 428-432 only check that the timelock did not lose tokens, which still passes if the surplus is sent somewhere other than NORMAL_TIMELOCK. Lines 464-467 have the same blind spot for BNB: zero helper balance only proves the funds left the helper. Please assert a positive/refund delta to the timelock for BEP20, and deliberately overfund the BNB path by a known surplus so the BNB refund recipient is validated too.

Also applies to: 464-467

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 421 - 433, The current
checks only ensure timelock balances didn't decrease but don't confirm refunds
went to NORMAL_TIMELOCK; update the BEP20 loop (using BEP20_MARKETS,
ERC20__factory, NORMAL_TIMELOCK, preTimelockBalances) to assert that
postTimelockBalance is strictly greater than preTimelockBalance (i.e., a
positive refund delta) rather than just >=; likewise for the BNB path (the
assertions around the helper/BNB zero-balance at lines ~464-467), deliberately
overfund the BNB test by a known surplus and assert that NORMAL_TIMELOCK's
native balance increased by the expected refund amount (not just that the helper
balance is zero) so the refund recipient is validated.

// Part 3: Native BNB bad debt repayments
// ══════════════════════════════════════════════════════════

function _repayBNB() internal {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cant we

uint256 debt = IVBnb(address(V_BNB)).borrowBalanceCurrent(ACCOUNT_4);                                                                                            
V_BNB.repayBorrowBehalf{value: debt}(ACCOUNT_4);    

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

984237b - Replaced 5 hardcoded BNB_REPAY_* constants with dynamic borrowBalanceCurrent calls
via a new _repayBNBFor helper

event BadDebtRepaid(address indexed vToken, address indexed borrower, uint256 repayAmount);
event RemainingTHETransferred(address indexed receiver, uint256 amount);
event TokensReturnedToTimelock(address indexed token, uint256 amount);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls also add a sweep function just in case

function sweep(token, amt){
  require(msg.sender == NORMAL_TIMELOCK, "only timelock");

  if (token == addr(0) -> bnb withdraw
  otherwise erc20 withdraw
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0c633af Added timelock-only sweep(address, uint256) supporting both BNB and ERC20

_repayTHE();
_repayBEP20();
_repayBNB();
}
Copy link
Copy Markdown
Contributor

@fred-venus fred-venus Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets have last step here, walk through all the tokens involved (except THE which will flow to recipient)

for each, trasnfert token.balanceOf(addr(this)) to treasury including bnb

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this contract should be completely empty before exit

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8fbb8c8 — sweeping all 17 BEP20 tokens + BNB back to timelock

Replace hardcoded BNB_REPAY_* constants with dynamic
borrowBalanceCurrent calls to fetch exact debt amounts on-chain.
Add timelock-only sweep function to recover any stuck BNB or ERC20
tokens from the contract.
…ecution

Add _returnAllRemaining() as final step in execute() to sweep all 17
BEP20 tokens and any remaining BNB back to the Normal Timelock,
ensuring the contract is completely empty after execution.
Compute BNB funding dynamically from on-chain borrowBalanceStored
instead of using hardcoded amounts, matching the contract change to
use borrowBalanceCurrent.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
tests/hardhat/Fork/badDebtHelper.ts (1)

317-321: ⚠️ Potential issue | 🟡 Minor

Silent skip can cause misleading downstream test failures.

If findBalanceSlot returns null, the market is skipped without funding, but the later assertions (lines 396-410) still iterate over all BEP20_MARKETS and expect debt reduction. This causes a confusing test failure instead of a clear setup error.

Suggested fix: Fail fast on setup failure
           const slot = await findBalanceSlot(market.underlying);
           if (slot === null) {
-            console.log(`      WARNING: Could not find balance slot for ${market.name}, skipping funding`);
-            continue;
+            throw new Error(
+              `Could not find balance slot for ${market.name} (${market.underlying}), cannot fund helper`,
+            );
           }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 317 - 321, The setup
silently skips funding when findBalanceSlot(market.underlying) returns null
(slot === null), which leads to confusing downstream assertion failures over
BEP20_MARKETS; change this to fail fast by throwing or asserting with a clear
error (including market.name and underlying) inside the loop (the block that
currently checks slot === null) so tests abort immediately on setup failure
instead of continuing to the debt-reduction assertions.
🧹 Nitpick comments (3)
contracts/Tokens/VTokens/BadDebtHelper.sol (1)

424-434: _repayIfDebt for THE always uses type(uint256).max without checking available balance.

Unlike _repayToken (lines 362-366) which conditionally uses type(uint256).max only when debt <= balance, _repayIfDebt unconditionally passes type(uint256).max to repayBorrowBehalf. If the helper's THE balance is insufficient to cover a borrower's full debt (after interest accrual), the call may revert.

This appears intentional since THE is swept from vTHE first (line 181-182), so the helper should have sufficient THE. However, if the swept amount is unexpectedly low, the entire transaction would revert rather than performing partial repayment.

Optional: Add partial repayment support for consistency
 function _repayIfDebt(IVBep20 vToken, address borrower) internal {
     uint256 debt = vToken.borrowBalanceStored(borrower);
     if (debt == 0) return;

     uint256 balanceBefore = THE.balanceOf(address(this));
-    uint256 err = vToken.repayBorrowBehalf(borrower, type(uint256).max);
+    uint256 repayAmount = debt <= balanceBefore ? type(uint256).max : balanceBefore;
+    if (repayAmount == 0) return;
+    uint256 err = vToken.repayBorrowBehalf(borrower, repayAmount);
     if (err != NO_ERROR) revert VTokenError(address(vToken), err);
     uint256 actualRepaid = balanceBefore - THE.balanceOf(address(this));

     emit BadDebtRepaid(address(vToken), borrower, actualRepaid);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/Tokens/VTokens/BadDebtHelper.sol` around lines 424 - 434, The
_repayIfDebt function currently always calls vToken.repayBorrowBehalf(borrower,
type(uint256).max) which can revert if THE balance is insufficient; change it to
compute repayAmount = min(debt, balanceBefore) where balanceBefore =
THE.balanceOf(address(this)), skip the repay if repayAmount == 0, call
vToken.repayBorrowBehalf(borrower, repayAmount), check err and revert on error
(VTokenError), and compute actualRepaid using the difference of THE balances
after the call before emitting BadDebtRepaid; reference functions/variables:
_repayIfDebt, vToken.repayBorrowBehalf, THE.balanceOf, debt variable, and
BadDebtRepaid event.
tests/hardhat/Fork/badDebtHelper.ts (2)

336-345: Test BNB funding uses borrowBalanceStored while contract uses borrowBalanceCurrent.

The test computes totalBNB using borrowBalanceStored (line 339) and multiplies by 2 for margin. However, the contract's _repayBNBFor uses borrowBalanceCurrent (line 341 in contract) which accrues interest and returns a higher value. The 2x margin should be sufficient to cover single-block interest accrual, but this subtle difference is worth noting for clarity.

Optional: Use borrowBalanceCurrent in test for consistency
         for (const borrower of BNB_BORROWERS) {
-          const debt = await vBNB.borrowBalanceStored(borrower);
+          const debt = await vBNB.borrowBalanceCurrent(borrower);
           totalBNB = totalBNB.add(debt);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 336 - 345, The test
computes totalBNB using vBNB.borrowBalanceStored but the contract repays using
borrowBalanceCurrent (which includes accrued interest); update the loop to call
vBNB.borrowBalanceCurrent for each borrower (or use callStatic if necessary)
when summing totalBNB so the test funding matches the contract’s current-balance
semantics, and keep the existing multiply-by-2 margin before calling
helper.connect(timelock).execute({ value: totalBNB }).

477-494: Consider adding test coverage for the sweep() function.

The sweep() function was added for emergency recovery but isn't tested in this suite. Consider adding tests to verify:

  1. Timelock can sweep stuck tokens
  2. Timelock can sweep stuck BNB
  3. Non-timelock callers are rejected
Example test cases
describe("sweep function", () => {
  it("should allow timelock to sweep stuck tokens", async () => {
    // Fund helper with some token
    // Call sweep from timelock
    // Verify token transferred to timelock
  });

  it("should allow timelock to sweep stuck BNB", async () => {
    // Send BNB to helper
    // Call sweep(address(0), amount) from timelock
    // Verify BNB transferred to timelock
  });

  it("should revert if called by non-timelock", async () => {
    const [randomSigner] = await ethers.getSigners();
    await expect(helper.connect(randomSigner).sweep(THE, 0))
      .to.be.revertedWith("only timelock");
  });
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 477 - 494, Add a new "sweep
function" test block that covers three cases: (1) timelock can sweep stuck ERC20
tokens — fund helper with a small THE/ERC20 balance via
ERC20__factory.connect(THE, ...).transfer(helper.address, amount) or mint, then
call helper.connect(timelockSigner).sweep(THE, amount) and assert the timelock
balance increased and helper balance decreased; (2) timelock can sweep BNB —
send ETH to helper via ethers.provider.getSigner().sendTransaction({to:
helper.address, value}), call
helper.connect(timelockSigner).sweep(ethers.constants.AddressZero, amount) and
assert timelock ETH balance increased; (3) non-timelock callers are rejected —
pick a random signer and assert helper.connect(randomSigner).sweep(THE, amount)
reverts with "only timelock". Use existing symbols helper, sweep(), THE,
timelockSigner (or timelock address), ERC20__factory, and ethers.provider for
locating code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@tests/hardhat/Fork/badDebtHelper.ts`:
- Around line 317-321: The setup silently skips funding when
findBalanceSlot(market.underlying) returns null (slot === null), which leads to
confusing downstream assertion failures over BEP20_MARKETS; change this to fail
fast by throwing or asserting with a clear error (including market.name and
underlying) inside the loop (the block that currently checks slot === null) so
tests abort immediately on setup failure instead of continuing to the
debt-reduction assertions.

---

Nitpick comments:
In `@contracts/Tokens/VTokens/BadDebtHelper.sol`:
- Around line 424-434: The _repayIfDebt function currently always calls
vToken.repayBorrowBehalf(borrower, type(uint256).max) which can revert if THE
balance is insufficient; change it to compute repayAmount = min(debt,
balanceBefore) where balanceBefore = THE.balanceOf(address(this)), skip the
repay if repayAmount == 0, call vToken.repayBorrowBehalf(borrower, repayAmount),
check err and revert on error (VTokenError), and compute actualRepaid using the
difference of THE balances after the call before emitting BadDebtRepaid;
reference functions/variables: _repayIfDebt, vToken.repayBorrowBehalf,
THE.balanceOf, debt variable, and BadDebtRepaid event.

In `@tests/hardhat/Fork/badDebtHelper.ts`:
- Around line 336-345: The test computes totalBNB using vBNB.borrowBalanceStored
but the contract repays using borrowBalanceCurrent (which includes accrued
interest); update the loop to call vBNB.borrowBalanceCurrent for each borrower
(or use callStatic if necessary) when summing totalBNB so the test funding
matches the contract’s current-balance semantics, and keep the existing
multiply-by-2 margin before calling helper.connect(timelock).execute({ value:
totalBNB }).
- Around line 477-494: Add a new "sweep function" test block that covers three
cases: (1) timelock can sweep stuck ERC20 tokens — fund helper with a small
THE/ERC20 balance via ERC20__factory.connect(THE, ...).transfer(helper.address,
amount) or mint, then call helper.connect(timelockSigner).sweep(THE, amount) and
assert the timelock balance increased and helper balance decreased; (2) timelock
can sweep BNB — send ETH to helper via
ethers.provider.getSigner().sendTransaction({to: helper.address, value}), call
helper.connect(timelockSigner).sweep(ethers.constants.AddressZero, amount) and
assert timelock ETH balance increased; (3) non-timelock callers are rejected —
pick a random signer and assert helper.connect(randomSigner).sweep(THE, amount)
reverts with "only timelock". Use existing symbols helper, sweep(), THE,
timelockSigner (or timelock address), ERC20__factory, and ethers.provider for
locating code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bfce8492-bfb9-4cfa-9cc6-c3457bffaa12

📥 Commits

Reviewing files that changed from the base of the PR and between 022ad22 and 7425796.

📒 Files selected for processing (2)
  • contracts/Tokens/VTokens/BadDebtHelper.sol
  • tests/hardhat/Fork/badDebtHelper.ts

function _returnIfBalance(IERC20Upgradeable token) internal {
uint256 balance = token.balanceOf(address(this));
if (balance > 0) {
token.safeTransfer(NORMAL_TIMELOCK, balance);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

treasury pls

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed - b78c003


uint256 remainingBNB = address(this).balance;
if (remainingBNB > 0) {
(bool success, ) = NORMAL_TIMELOCK.call{ value: remainingBNB }("");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

treasury

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed - b78c003

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
tests/hardhat/Fork/badDebtHelper.ts (1)

318-322: ⚠️ Potential issue | 🟡 Minor

Silently skipping markets can cause misleading test failures.

If findBalanceSlot returns null, the test silently skips funding that market but the later debt assertions at lines 396-411 still check those borrowers. A setup failure becomes a misleading downstream test failure.

🔧 Suggested fix: fail fast
           const slot = await findBalanceSlot(market.underlying);
           if (slot === null) {
-            console.log(`      WARNING: Could not find balance slot for ${market.name}, skipping funding`);
-            continue;
+            throw new Error(
+              `Could not find balance slot for ${market.name} (${market.underlying}); cannot fund helper`,
+            );
           }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 318 - 322, The code
currently logs and continues when findBalanceSlot(market.underlying) returns
null, which silently skips funding a market and leads to misleading downstream
debt assertions; change this to fail fast by throwing or asserting when slot is
null (e.g., replace the console warning + continue with a thrown Error or assert
that includes market.name and market.underlying), so the test setup stops
immediately and reports which market failed to find a balance slot; locate this
in the funding loop where findBalanceSlot is called and modify the control flow
accordingly.
🧹 Nitpick comments (2)
tests/hardhat/Fork/badDebtHelper.ts (2)

425-438: Consider strengthening refund assertions.

The current check (postTimelockBalance >= preTimelockBalance) verifies the timelock didn't lose tokens but doesn't confirm refunds actually went there vs. somewhere else. Since _finalizeToken sends unused tokens to NORMAL_TIMELOCK, consider asserting a positive delta for markets with excess funding:

-            // Timelock balance should be >= pre-balance (got back unused tokens)
-            expect(postTimelockBalance).to.be.gte(
-              preTimelockBalance,
-              `Timelock ${market.name} balance should not decrease`,
-            );
+            // Timelock should have received refunded surplus
+            expect(postTimelockBalance).to.be.gt(
+              preTimelockBalance,
+              `Timelock ${market.name} balance should increase (received refund)`,
+            );

Alternatively, track the expected refund amount (fundAmount - actualRepaid) and assert against it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 425 - 438, The test
currently only asserts timelock balances didn't decrease; update the loop in the
"should return unused BEP20 tokens to timelock" test to assert that markets
which had excess funding actually increased the timelock balance: for each
market in BEP20_MARKETS use ERC20__factory.connect(market.underlying,
ethers.provider) to get pre and post balances (preTimelockBalances and
postTimelockBalance) and assert postTimelockBalance.gt(preTimelockBalance) when
the market had excess, or compute the expected refund (e.g., fundAmount -
actualRepaid retrieved from the market/funding records) and assert
postTimelockBalance.sub(preTimelockBalance).gte(expectedRefund); ensure you
reference NORMAL_TIMELOCK, preTimelockBalances, and the logic in _finalizeToken
to determine which markets should see a positive delta.

468-471: Consider adding BNB refund recipient verification.

The test confirms helper ends with 0 BNB but doesn't verify where unused BNB went. Since the contract sends unused BNB to NORMAL_TIMELOCK, consider tracking pre/post timelock BNB balance to confirm the refund destination.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 468 - 471, Add a
verification that unused BNB was forwarded to the NORMAL_TIMELOCK by recording
the timelock balance before the helper operation (use
ethers.provider.getBalance(NORMAL_TIMELOCK) or the timelock contract address),
run the logic under test, then fetch the timelock balance again and assert the
postBalance increased accordingly (e.g., postBalance > preBalance or postBalance
=== preBalance + refundedAmount) in the same test that checks helper.address has
0 BNB; reference helper.address, NORMAL_TIMELOCK, and ethers.provider.getBalance
to locate and implement the checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/Tokens/VTokens/BadDebtHelper.sol`:
- Around line 341-347: The _repayBNBFor function currently attempts to send the
full debt and can revert if the contract BNB balance is insufficient; update it
to mirror the BEP20 path by reading address(this).balance, compute repayAmount =
debt <= balance ? type(uint256).max : balance, and call V_BNB.repayBorrowBehalf{
value: repayAmount }(borrower) so partial repayments are accepted instead of
reverting; also update the emitted BadDebtRepaid event payload to reflect the
actual repaid amount (use repayAmount instead of debt) and keep the initial
early-return when debt == 0.
- Around line 388-423: The event name TokensReturnedToTimelock is misleading
because _returnAllRemaining and _returnIfBalance send funds to TREASURY but emit
TokensReturnedToTimelock; update the contract to declare and emit a
treasury-specific event (e.g. TokensReturnedToTreasury(address indexed token,
uint256 amount)) or replace with a generic event (e.g. TokensReturned(address
indexed token, address indexed recipient, uint256 amount)) and change the emit
calls in _returnAllRemaining and _returnIfBalance to use the new event (also
update any event listeners/tests/ABI consumers to match the new event
signature).

In `@tests/hardhat/Fork/badDebtHelper.ts`:
- Line 15: Remove the unused TREASURY constant from badDebtHelper.ts or use it
in the test assertions; specifically either delete the line defining TREASURY or
add assertions that reference TREASURY to verify remaining tokens are sent to
the treasury (e.g., check token balances/transfers to the address), updating any
helper functions like those that inspect balances or transfer events so they use
the TREASURY symbol.

---

Duplicate comments:
In `@tests/hardhat/Fork/badDebtHelper.ts`:
- Around line 318-322: The code currently logs and continues when
findBalanceSlot(market.underlying) returns null, which silently skips funding a
market and leads to misleading downstream debt assertions; change this to fail
fast by throwing or asserting when slot is null (e.g., replace the console
warning + continue with a thrown Error or assert that includes market.name and
market.underlying), so the test setup stops immediately and reports which market
failed to find a balance slot; locate this in the funding loop where
findBalanceSlot is called and modify the control flow accordingly.

---

Nitpick comments:
In `@tests/hardhat/Fork/badDebtHelper.ts`:
- Around line 425-438: The test currently only asserts timelock balances didn't
decrease; update the loop in the "should return unused BEP20 tokens to timelock"
test to assert that markets which had excess funding actually increased the
timelock balance: for each market in BEP20_MARKETS use
ERC20__factory.connect(market.underlying, ethers.provider) to get pre and post
balances (preTimelockBalances and postTimelockBalance) and assert
postTimelockBalance.gt(preTimelockBalance) when the market had excess, or
compute the expected refund (e.g., fundAmount - actualRepaid retrieved from the
market/funding records) and assert
postTimelockBalance.sub(preTimelockBalance).gte(expectedRefund); ensure you
reference NORMAL_TIMELOCK, preTimelockBalances, and the logic in _finalizeToken
to determine which markets should see a positive delta.
- Around line 468-471: Add a verification that unused BNB was forwarded to the
NORMAL_TIMELOCK by recording the timelock balance before the helper operation
(use ethers.provider.getBalance(NORMAL_TIMELOCK) or the timelock contract
address), run the logic under test, then fetch the timelock balance again and
assert the postBalance increased accordingly (e.g., postBalance > preBalance or
postBalance === preBalance + refundedAmount) in the same test that checks
helper.address has 0 BNB; reference helper.address, NORMAL_TIMELOCK, and
ethers.provider.getBalance to locate and implement the checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b1ba532-f309-49b4-92fa-046b9270f7c9

📥 Commits

Reviewing files that changed from the base of the PR and between 7425796 and b78c003.

📒 Files selected for processing (2)
  • contracts/Tokens/VTokens/BadDebtHelper.sol
  • tests/hardhat/Fork/badDebtHelper.ts

Comment on lines +341 to +347
function _repayBNBFor(address borrower) internal {
uint256 debt = IVBnb(address(V_BNB)).borrowBalanceCurrent(borrower);
if (debt == 0) return;

V_BNB.repayBorrowBehalf{ value: debt }(borrower);
emit BadDebtRepaid(address(V_BNB), borrower, debt);
}
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

BNB repayment lacks partial repayment handling unlike BEP20 path.

_repayBNBFor sends exactly the full debt amount without checking if address(this).balance >= debt. If interest accrual during timelock delay exceeds the 2× funding margin, this will revert. The BEP20 path handles this gracefully:

uint256 repayAmount = debt <= balance ? type(uint256).max : balance;

Consider adding similar protection for BNB:

🛡️ Proposed fix to handle insufficient BNB balance
 function _repayBNBFor(address borrower) internal {
     uint256 debt = IVBnb(address(V_BNB)).borrowBalanceCurrent(borrower);
     if (debt == 0) return;

-    V_BNB.repayBorrowBehalf{ value: debt }(borrower);
-    emit BadDebtRepaid(address(V_BNB), borrower, debt);
+    uint256 balance = address(this).balance;
+    uint256 repayAmount = debt <= balance ? debt : balance;
+    if (repayAmount == 0) return;
+
+    V_BNB.repayBorrowBehalf{ value: repayAmount }(borrower);
+    emit BadDebtRepaid(address(V_BNB), borrower, repayAmount);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/Tokens/VTokens/BadDebtHelper.sol` around lines 341 - 347, The
_repayBNBFor function currently attempts to send the full debt and can revert if
the contract BNB balance is insufficient; update it to mirror the BEP20 path by
reading address(this).balance, compute repayAmount = debt <= balance ?
type(uint256).max : balance, and call V_BNB.repayBorrowBehalf{ value:
repayAmount }(borrower) so partial repayments are accepted instead of reverting;
also update the emitted BadDebtRepaid event payload to reflect the actual repaid
amount (use repayAmount instead of debt) and keep the initial early-return when
debt == 0.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation is correct and intentional — vBNB's
repayBorrowBehalf works fundamentally differently from the BEP20
path:

  1. vBNB accepts native BNB via msg.value and refunds any excess
    automatically. There's no type(uint256).max sentinel support — it
    uses the literal ETH value sent. The BEP20 sentinel pattern
    (type(uint256).max) is specific to transferFrom-based repayments.
  2. borrowBalanceCurrent (line 341) already accrues interest before
    returning the debt, so the amount is accurate at execution time —
    unlike borrowBalanceStored used in the BEP20 path which can be stale.
  3. Silently partial-repaying would be worse than reverting for an
    atomic bad-debt cleanup. If the contract somehow lacks sufficient
    BNB, we want a revert rather than leaving unrepaid debt behind
    unnoticed.
  4. The contract is funded with ample margin (2x estimated debt in the
    VIP), so balance insufficiency is not a realistic scenario.

No changes needed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Debugger022, thank you for the detailed explanation — all four points are well-taken:

  1. type(uint256).max is a transferFrom-based sentinel specific to ERC20 repayments and has no meaning in the native-BNB path where msg.value is the literal amount sent (and any excess is refunded by vBNB automatically).
  2. borrowBalanceCurrent already performs interest accrual, so the amount passed to repayBorrowBehalf is fresh and accurate.
  3. A silent partial repayment would undermine the atomicity guarantee of the cleanup — a revert is the correct failure mode.
  4. With a 2× funding margin baked into the VIP, a shortfall is not a realistic scenario.

My suggestion was incorrect. I'll record this for future reviews.

(ᓀ ᓀ)


✏️ Learnings added
Learnt from: Debugger022
Repo: VenusProtocol/venus-protocol PR: 666
File: contracts/Tokens/VTokens/BadDebtHelper.sol:341-347
Timestamp: 2026-03-26T04:16:18.244Z
Learning: In BadDebtHelper.sol (contracts/Tokens/VTokens/BadDebtHelper.sol), the `_repayBNBFor` function intentionally sends the exact `borrowBalanceCurrent` debt as `msg.value` to `V_BNB.repayBorrowBehalf`. This is correct because: (1) vBNB's `repayBorrowBehalf` accepts native BNB via `msg.value` and automatically refunds any excess — the `type(uint256).max` sentinel is only meaningful for ERC20 `transferFrom`-based repayments and does not apply here; (2) `borrowBalanceCurrent` already accrues interest before returning, so the amount is up-to-date at execution time; (3) silent partial repayment is undesirable for an atomic bad-debt cleanup — a revert on underfunding is the intended behavior; (4) the contract is funded with a 2× margin in the VIP, making balance insufficiency unrealistic. Do not flag this as an issue in future reviews.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: Debugger022
Repo: VenusProtocol/venus-protocol PR: 666
File: contracts/Tokens/VTokens/BadDebtHelper.sol:355-361
Timestamp: 2026-03-25T14:48:13.207Z
Learning: In BadDebtHelper.sol (contracts/Tokens/VTokens/BadDebtHelper.sol), `borrowBalanceStored` is intentionally used (instead of `borrowBalanceCurrent`) for the zero-debt early-return check and the `type(uint256).max` vs `balance` branching decision. This is by design: `repayBorrowBehalf` already calls `accrueInterest()` internally (via `repayBorrowBehalfInternal`), so the actual repayment always operates on the fully up-to-date debt. Using `borrowBalanceCurrent` would redundantly accrue interest twice and waste gas. The helper is also funded with margin to absorb any tiny single-block interest delta. Do not flag this as an issue in future reviews.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
tests/hardhat/Fork/badDebtHelper.ts (3)

317-321: ⚠️ Potential issue | 🟠 Major

Silent skip on failed slot finding causes misleading downstream test failures.

When findBalanceSlot() returns null, the market is silently skipped via continue, but the later debt assertions (lines 394-411) still run for that market. If the market wasn't funded, the assertions will either fail misleadingly or pass incorrectly depending on the fork state.

Fail fast here or track skipped markets to exclude from assertions.

🐛 Suggested fix
           const slot = await findBalanceSlot(market.underlying);
           if (slot === null) {
-            console.log(`      WARNING: Could not find balance slot for ${market.name}, skipping funding`);
-            continue;
+            throw new Error(
+              `Could not find balance slot for ${market.name} (${market.underlying}); cannot fund helper or verify this market on the fork`,
+            );
           }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 317 - 321, The loop that
calls findBalanceSlot(market.underlying) currently logs and continues when slot
=== null, which lets later debt assertions run against markets that were never
funded; change this so failures are explicit: either throw an error when
findBalanceSlot returns null to fail fast (so fix by replacing the console
warning+continue with a thrown Error referencing the market.name and
findBalanceSlot), or maintain a skipped list (e.g., skippedMarkets) when slot
=== null and then exclude any market in skippedMarkets from the later debt
assertion block; reference findBalanceSlot, the slot variable, and the debt
assertion logic to implement one of these two behaviors.

467-470: ⚠️ Potential issue | 🟡 Minor

Zero helper balance doesn't verify refund destination.

Similar to the BEP20 assertion issue, checking that the helper has 0 BNB only proves funds left the helper—not that they reached NORMAL_TIMELOCK. Since BNB is funded at 2× debt, consider asserting that timelock's BNB balance increased by the expected refund amount.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 467 - 470, The test "should
leave no BNB in the helper" only checks helper.address balance but not that
funds reached NORMAL_TIMELOCK; update the test to record the timelock balance
before the operation, compute the expected refund amount (the BNB funded to
helper, e.g., 2 * debt or the existing refundAmount variable used in the suite),
assert helper.balance === 0 as before, then assert
ethers.provider.getBalance(timelock.address) increased by the expected refund
(use timelock.address and
expect(newTimelockBalance).to.equal(oldTimelockBalance.add(expectedRefund))).
Ensure you reference helper.address, timelock.address,
ethers.provider.getBalance and the same refund/debt variable used elsewhere in
the test.

424-437: ⚠️ Potential issue | 🟡 Minor

Weak assertion doesn't verify refund recipient.

The test checks postTimelockBalance >= preTimelockBalance which only ensures timelock didn't lose tokens. Since the test funds each market with 2×debt, there should be a positive refund to timelock. Asserting a strict increase (>) for funded markets would verify the refund actually reached the timelock.

💡 Suggested improvement
         it("should return unused BEP20 tokens to timelock", async () => {
-          // For each token, timelock should have gotten back any excess
           for (const market of BEP20_MARKETS) {
             const token = ERC20__factory.connect(market.underlying, ethers.provider);
             const postTimelockBalance = await token.balanceOf(NORMAL_TIMELOCK);
             const preTimelockBalance = preTimelockBalances[market.underlying];
 
-            // Timelock balance should be >= pre-balance (got back unused tokens)
-            expect(postTimelockBalance).to.be.gte(
+            // Timelock balance should increase (got back unused tokens from 2x funding)
+            expect(postTimelockBalance).to.be.gt(
               preTimelockBalance,
-              `Timelock ${market.name} balance should not decrease`,
+              `Timelock ${market.name} balance should increase from refunded tokens`,
             );
           }
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/hardhat/Fork/badDebtHelper.ts` around lines 424 - 437, The current test
"should return unused BEP20 tokens to timelock" only asserts postTimelockBalance
>= preTimelockBalance, which doesn't verify a refund; update the assertion to
require a strict increase for markets that were funded (use BEP20_MARKETS and
preTimelockBalances to identify funded markets) by replacing the non-strict gte
check with a strict gt check (e.g.,
expect(postTimelockBalance).to.be.gt(preTimelockBalance)) when verifying
ERC20__factory.connect(...).balanceOf(NORMAL_TIMELOCK) so the test ensures
timelock actually received the refunded tokens.
🧹 Nitpick comments (1)
contracts/Tokens/VTokens/BadDebtHelper.sol (1)

363-374: Consider adding early return when balance is zero.

If balance == 0 and debt > 0, the function calls repayBorrowBehalf(borrower, 0) which is a no-op but wastes gas. An early return would optimize this edge case.

♻️ Optional gas optimization
         uint256 debt = vToken.borrowBalanceStored(borrower);
         if (debt == 0) return;
 
         uint256 balance = underlying.balanceOf(address(this));
+        if (balance == 0) return;
         uint256 repayAmount = debt <= balance ? type(uint256).max : balance;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/Tokens/VTokens/BadDebtHelper.sol` around lines 363 - 374, Add an
early return when the contract's underlying balance is zero to avoid calling
repayBorrowBehalf with a zero amount; locate the block using
vToken.borrowBalanceStored(borrower), underlying.balanceOf(address(this)), and
repayBorrowBehalf(borrower, repayAmount) and insert a check like `if (balance ==
0) return;` before computing repayAmount and calling vToken.repayBorrowBehalf,
keeping the existing error handling and emit BadDebtRepaid logic intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@tests/hardhat/Fork/badDebtHelper.ts`:
- Around line 317-321: The loop that calls findBalanceSlot(market.underlying)
currently logs and continues when slot === null, which lets later debt
assertions run against markets that were never funded; change this so failures
are explicit: either throw an error when findBalanceSlot returns null to fail
fast (so fix by replacing the console warning+continue with a thrown Error
referencing the market.name and findBalanceSlot), or maintain a skipped list
(e.g., skippedMarkets) when slot === null and then exclude any market in
skippedMarkets from the later debt assertion block; reference findBalanceSlot,
the slot variable, and the debt assertion logic to implement one of these two
behaviors.
- Around line 467-470: The test "should leave no BNB in the helper" only checks
helper.address balance but not that funds reached NORMAL_TIMELOCK; update the
test to record the timelock balance before the operation, compute the expected
refund amount (the BNB funded to helper, e.g., 2 * debt or the existing
refundAmount variable used in the suite), assert helper.balance === 0 as before,
then assert ethers.provider.getBalance(timelock.address) increased by the
expected refund (use timelock.address and
expect(newTimelockBalance).to.equal(oldTimelockBalance.add(expectedRefund))).
Ensure you reference helper.address, timelock.address,
ethers.provider.getBalance and the same refund/debt variable used elsewhere in
the test.
- Around line 424-437: The current test "should return unused BEP20 tokens to
timelock" only asserts postTimelockBalance >= preTimelockBalance, which doesn't
verify a refund; update the assertion to require a strict increase for markets
that were funded (use BEP20_MARKETS and preTimelockBalances to identify funded
markets) by replacing the non-strict gte check with a strict gt check (e.g.,
expect(postTimelockBalance).to.be.gt(preTimelockBalance)) when verifying
ERC20__factory.connect(...).balanceOf(NORMAL_TIMELOCK) so the test ensures
timelock actually received the refunded tokens.

---

Nitpick comments:
In `@contracts/Tokens/VTokens/BadDebtHelper.sol`:
- Around line 363-374: Add an early return when the contract's underlying
balance is zero to avoid calling repayBorrowBehalf with a zero amount; locate
the block using vToken.borrowBalanceStored(borrower),
underlying.balanceOf(address(this)), and repayBorrowBehalf(borrower,
repayAmount) and insert a check like `if (balance == 0) return;` before
computing repayAmount and calling vToken.repayBorrowBehalf, keeping the existing
error handling and emit BadDebtRepaid logic intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0e150114-26e6-441a-b8f7-10153339ea03

📥 Commits

Reviewing files that changed from the base of the PR and between b78c003 and edc087a.

📒 Files selected for processing (2)
  • contracts/Tokens/VTokens/BadDebtHelper.sol
  • tests/hardhat/Fork/badDebtHelper.ts

@fred-venus
Copy link
Copy Markdown
Contributor

lgtm, lets not merge it as it's one-off will keep it WIP as a good ref

@fred-venus fred-venus marked this pull request as draft March 26, 2026 04:33
@fred-venus fred-venus added the don't merge Need to merge after passing test label Mar 26, 2026
@fred-venus fred-venus changed the title [VPD-854] Add BadDebtHelper for atomic bad debt repayment (VIP-690) [REF ONLY] Add BadDebtHelper for atomic bad debt repayment (VIP-690) Mar 26, 2026
@fred-venus fred-venus changed the title [REF ONLY] Add BadDebtHelper for atomic bad debt repayment (VIP-690) [REF ONLY] Add BadDebtHelper for atomic bad debt repayment (VIP-604) Mar 26, 2026
Debugger022 and others added 8 commits March 26, 2026 10:30
…change rate

The second sweep is now limited to TOTAL_THE_SWEEP - firstSweep,
leaving sufficient internalCash in vTHE so the exchange rate stays > 1.
…lock

Excess BEP20 and BNB after repayments should go to Treasury, not
Normal Timelock. Added fork tests asserting Treasury receives funds
and Timelock balance does not increase.
@github-actions
Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Health
contracts 100% 100%
contracts.Admin 88% 41%
contracts.Comptroller 100% 90%
contracts.Comptroller.Diamond 95% 59%
contracts.Comptroller.Diamond.facets 82% 69%
contracts.Comptroller.Diamond.interfaces 100% 100%
contracts.Comptroller.Types 100% 100%
contracts.Comptroller.legacy 100% 100%
contracts.Comptroller.legacy.Diamond 0% 0%
contracts.Comptroller.legacy.Diamond.facets 0% 0%
contracts.Comptroller.legacy.Diamond.interfaces 100% 100%
contracts.DelegateBorrowers 100% 89%
contracts.FlashLoan.interfaces 100% 100%
contracts.Governance 68% 45%
contracts.InterestRateModels 74% 59%
contracts.Lens 41% 34%
contracts.Liquidator 83% 60%
contracts.Oracle 100% 100%
contracts.PegStability 88% 84%
contracts.Swap 91% 58%
contracts.Swap.interfaces 100% 100%
contracts.Swap.lib 81% 55%
contracts.Tokens 100% 100%
contracts.Tokens.Prime 96% 72%
contracts.Tokens.Prime.Interfaces 100% 100%
contracts.Tokens.Prime.libs 90% 76%
contracts.Tokens.VAI 82% 53%
contracts.Tokens.VRT 20% 9%
contracts.Tokens.VTokens 54% 44%
contracts.Tokens.VTokens.legacy 0% 0%
contracts.Tokens.VTokens.legacy.Utils 0% 0%
contracts.Tokens.XVS 19% 8%
contracts.Tokens.test 100% 100%
contracts.Utils 52% 31%
contracts.VAIVault 50% 45%
contracts.VRTVault 49% 36%
contracts.XVSVault 63% 50%
contracts.external 100% 100%
contracts.lib 89% 71%
Summary 55% (3653 / 6674) 42% (1375 / 3306)

@Debugger022 Debugger022 self-assigned this Mar 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

don't merge Need to merge after passing test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants