Skip to content

[VPD-1430, 1431, 1432] feat(bstock): atomic backstop liquidator + off-chain scripts #683

Open
Debugger022 wants to merge 36 commits into
developfrom
feat/VPD-1430
Open

[VPD-1430, 1431, 1432] feat(bstock): atomic backstop liquidator + off-chain scripts #683
Debugger022 wants to merge 36 commits into
developfrom
feat/VPD-1430

Conversation

@Debugger022

@Debugger022 Debugger022 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Description

Backstop liquidation for bStock (ERC-8056 tokenized stocks: TSLAB, NVDAB, SPCXB) collateral. bStock is RFQ-only (Native is the sole market maker, no AMM), so third-party liquidators may not show up. This adds Venus's own fallback liquidator plus the off-chain tooling to drive it. Liquidation stays permissionless; this is only the backstop so the protocol never carries bad debt on an RFQ-only asset.

Changes

VPD-1430: BStockLiquidator contract + tests

Upgradeable, owner/operator-gated atomic liquidator. In one tx it repays an underwater borrow, seizes + redeems the bStock vToken, and sells the bStock to USDT via a pre-signed Native firm-quote with a hard minOut (a bad or late quote reverts the whole tx, so the protocol never holds the RFQ-only asset across a price-drift window).

  • Two funding modes: inventory (own USDT) and flash (Venus executeFlashLoan, repaid + premium in the same tx).
  • Routes the repay through the pool-wide Venus Liquidator gate when set, else liquidates directly; seize measured by balance delta so the liquidator treasury cut is handled correctly.
  • Router-allowlisted and operator-gated (it custodies funds and forwards caller-supplied swap calldata to an external router).
  • Extends the shared IComptroller (InterfacesV8) with getAccountLiquidity and executeFlashLoan (additive).

VPD-1431: off-chain liquidation scripts

The on-chain contract only does the atomic settle; every liquidation must be paired with an off-chain Native firm-quote. These scripts are that off-chain half.

VPD-1432: Safe multisig fallback

A read-only generator for a Safe Transaction Builder batch, used to settle a liquidation manually when the Native RFQ path is unavailable.

Scripts (scripts/bstock/)

lib/native.ts — Native (nativefi) RFQ API client (library). Orderbook / indicative / firm-quote, parses the signed txRequest + TTL, holds the TSLAB/NVDAB/USDT constants. API key via NATIVE_API_KEY, never hardcoded. Not run directly.

native-quote.ts — live quote smoke test (read-only, no chain). Sanity-checks the Native integration and shows price / spread / TTL / book depth before anything goes on-chain (depth is thin, so quotes above book size fail).

NATIVE_API_KEY=... npx hardhat run scripts/bstock/native-quote.ts
# options (env): TOKEN=TSLAB|NVDAB (def TSLAB), AMOUNT=1, FROM=0x..

atomic-liquidate.ts — driver for the deployed BStockLiquidator (the normal path). Confirms shortfall, precomputes the seize (adjusted for the redeem treasuryPercent), fetches the MM-signed firm-quote with the contract as taker, checks the router is allowlisted, computes minOut, then calls liquidate (inventory) or flashLiquidate (flash loan).

NATIVE_API_KEY=... LIQUIDATOR=0x.. BORROWER=0x.. VBSTOCK=0x.. VDEBT=0x.. REPAY_AMOUNT=5000 npx hardhat run scripts/bstock/atomic-liquidate.ts --network bscmainnet
# MODE=inventory|flash (def inventory), SLIPPAGE=0.5, MIN_OUT_BUFFER=0.5, DRY_RUN=1 (callStatic only), MOCK_NATIVE=<router>:<calldata> (fork tests)

lib/safe.ts — Safe Transaction Builder JSON helper (library). Encodes calls and builds the batch JSON the Safe{Wallet} Transaction Builder imports. Not run directly.

safe-fallback.ts — manual multisig fallback (read-only, emits JSON). When the RFQ path is down (API outage, halt, weekend, thin depth), a Safe multisig settles manually. Emits a 4-tx batch (approve, liquidateBorrow, redeem, transfer raw bStock to a custody/Binance address), seize amount snapshotted from chain. Sends nothing.

RPC_URL=... BORROWER=0x.. VBSTOCK=0x.. VDEBT=0x.. REPAY_AMOUNT=5000 TARGET=0x.. npx ts-node scripts/bstock/safe-fallback.ts
# ALLOW_PLACEHOLDER=1 emits a draft with a zero TARGET; OUT=path overrides output

Testing

  • Unit (contract) tests/hardhat/BStockLiquidator.ts: 23 passing — both funding modes, gate + treasury-cut path, all guards and error paths.
  • Driver (mock) tests/hardhat/BStockAtomicLiquidate.ts: 4 passing — drives atomic-liquidate.ts end to end (inventory + flash, healthy-borrower and router-allowlist guards).
  • Fork (real Venus Core) tests/hardhat/Fork/BStockLiquidatorFork.ts: passing — real liquidateBorrow → seize → redeem through the live Venus Liquidator, with strict profit/borrow assertions (stand-in markets + mock router, since no bStock market is listed yet).
  • Native RFQ client validated against the live API (quote, TTL, router).

Notes

  • No bStock market is listed in Core yet, so a full real liquidation and live RFQ settlement can only be exercised on mainnet once a market exists. The contract is collateral-agnostic and retargets to vTSLAB/vUSDT via env once listed.
  • Flash-loan authorization for the liquidator (whitelist + per-vToken setFlashLoanEnabled) is handled by a separate VIP (VPD-1433).

- bStock is RFQ-only (no AMM), so third-party liquidators may not
  show. Add a self-liquidation backstop that, in one tx, repays the
  borrow, seizes and redeems the bStock vToken, and sells it to USDT
  via a pre-signed Native RFQ quote with a hard minOut, so the
  protocol never holds the RFQ-only asset across a drift window.
- Two funding modes: own USDT inventory, or a Venus flash loan
  repaid (+ premium) in the same tx.
- Routes the repay through the pool-wide Venus Liquidator when that
  gate is set, else liquidates directly; the seized amount is read
  as a balance delta so the liquidator treasury cut is handled.
- Operator-gated and router-allowlisted: it custodies funds and
  forwards caller-supplied swap calldata to an external router.
- Extend the shared IComptroller with getAccountLiquidity and
  executeFlashLoan (additive); split storage-light interface into
  IBStockLiquidator.
- Add mocks, a unit suite (both modes, gate + treasury-cut, all
  guards and error paths), and a bscmainnet fork test on real Core.
- Native RFQ client (orderbook / firm-quote) plus a read-only quote
  smoke test for eyeballing price, depth and TTL
- atomic liquidation driver: precompute the seize, fetch the signed
  firm-quote with the contract as taker, then call liquidate or
  flashLiquidate; warn early if inventory cannot cover the repay
- mock-based test driving the script end to end: inventory and flash
  paths, plus the healthy-borrower and router-allowlist guards
- add scripts/ to the eslint tsconfig include (it was uncovered) and
  add an exchangeRateStored getter to the collateral mock that the
  driver reads
- Safe Transaction Builder JSON helper (encode calls, build batch)
- read-only script emitting a 4-tx batch (approve, liquidateBorrow,
  redeem, transfer raw bStock) for a multisig to settle a liquidation
  when the RFQ path is unavailable; seize amount snapshotted from
  chain, sends nothing
@Debugger022 Debugger022 changed the title [VPD-1430] feat(bstock): add atomic backstop liquidator [VPD-1430, 1431, 1432] feat(bstock): atomic backstop liquidator + off-chain scripts Jun 22, 2026
@Debugger022 Debugger022 self-assigned this Jun 22, 2026
@Debugger022
Debugger022 marked this pull request as ready for review June 25, 2026 04:09

@trumpgpt-bot trumpgpt-bot left a comment

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.

Overall verdict

The BStockLiquidator contract itself is well-engineered: balance-delta accounting throughout both the seize and redeem legs, checks-effects-interactions preserved in the flash path, nonReentrant on every entry point, explicit router-approval reset to zero, and the inventory-mask guard in executeOperation (usdtOut < principal + premium rather than usdtOut < minOut) is an important correctness invariant that the tests exercise correctly. The atomic-liquidate.ts script and test suite are solid.

The blocker is in VPD-1432 (safe-fallback.ts): I verified on-chain that Comptroller.liquidatorContract() = 0x0870793286aada55d39ce7f82fb2766e8004cf43 on BSC mainnet. The generated batch calls vDebt.liquidateBorrow directly from the Safe, which will revert UNAUTHORIZED because the Safe is not the liquidatorContract. The script warns about this but still emits the batch. As a result, the entire VPD-1432 fallback is non-functional on mainnet — the safety valve for when the BStockLiquidator can't be used produces an unexecutable batch.

A secondary correctness gap is also in safe-fallback.ts: seizedRaw is computed without applying treasuryPercent (Comptroller.treasuryPercent() = 0 on mainnet today, so not currently broken, but atomic-liquidate.ts correctly adjusts for it and treasuryPercent is governance-settable).

No issues found on the contract security model, access control, reentrancy, or oracle/DeFi attack surface.

throw new Error("Missing env TARGET (Binance top-up address). Set it, or ALLOW_PLACEHOLDER=1 for a draft.");
}

// --- build batch ---

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.

[blocker] This direct liquidateBorrow call will revert UNAUTHORIZED on BSC mainnet. I verified on-chain: Comptroller.liquidatorContract() = 0x0870793286aada55d39ce7f82fb2766e8004cf43 (non-zero). Any direct call from the Safe — which is NOT that contract — is rejected by the Core gate. The script warns about this condition at lines 100-108 but still emits the batch unchanged, so the batch is unexecutable as generated. VPD-1432's stated purpose (manual fallback when the RFQ path is unavailable) is defeated.

Fix: either (a) fail hard with throw when liquidatorContract !== ZERO && liquidatorContract !== safe (force the operator to resolve the gate situation first), or (b) generate a batch that routes through the VenusLiquidator gate — replace step 2 with ILiquidator(liquidatorContract).liquidateBorrow(vDebt, borrower, repay, vBStock) and step 1's approve target with liquidatorContract.

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.

Comment thread scripts/bstock/safe-fallback.ts Outdated
);
if (!seizeErr.eq(0)) throw new Error(`liquidateCalculateSeizeTokens error code ${seizeErr}`);

// Raw bStock from redeeming seizeTokens, FLOORED at the current exchange rate (rate only grows, so

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.

[minor] seizedRaw is computed as seizeTokens * exchangeRate / 1e18 without applying Comptroller.treasuryPercent. If treasuryPercent > 0, Core's redeem routes that fraction of the redeemed underlying to the treasury, so the Safe receives less than seizedRaw. Step 4's transfer(target, seizedRaw) would then try to send more bStock than the Safe holds and revert, breaking the batch.

treasuryPercent() is 0 on mainnet today (verified), but it is governance-settable. atomic-liquidate.ts:100-101 correctly applies the same adjustment:

const seizedRaw = seizeTokens.mul(exchangeRate).div(ONE).mul(ONE.sub(treasuryPercent)).div(ONE);

Add treasuryPercent to COMPTROLLER_ABI here and apply the same correction.

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.

Comment thread scripts/bstock/lib/native.ts Outdated
}

/** Known bStock token addresses on BNB Chain (18 decimals). */
export const BSTOCK_TOKENS = {

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.

[question] The PR description lists three bStock tokens (TSLAB, NVDAB, SPCXB), but BSTOCK_TOKENS only has TSLAB and NVDAB. Is SPCXB not yet deployed on BSC mainnet, or does it use a different RFQ pair that isn't supported by the Native API? If it will be added later, a TODO comment here would help future operators know this map is incomplete.

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.

I suggest we remove this Bstock token check, because in the future we might have even more tokens, dont want to add one more place to update, and also this script is specifically for the liquidation. There will be serious checks before we really execute it, so it is unlikely this will be wrong.

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.

import { BigNumber, Contract, providers, utils } from "ethers";
import { promises as fs } from "fs";
import * as path from "path";

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.

[nit] DEFAULT_SAFE = "0xdc6E047f665c3Db94292Bb7fB412B25370db2029" — confirm this is the correct governance Safe for bStock operations. If it belongs to a different multisig (e.g. a treasury or ops Safe rather than the bStock risk/ops team), an operator running this script without the SAFE= override would generate a batch targeted at the wrong signers.

Comment thread contracts/BStock/BStockLiquidator.sol Outdated
Comment thread contracts/BStock/IBStockLiquidator.sol
Comment thread contracts/BStock/BStockLiquidator.sol Outdated
Comment thread contracts/BStock/BStockLiquidator.sol Outdated
Comment on lines +172 to +173
if (!_flashActive) revert NoFlashInFlight();
if (initiator != address(this)) revert BadInitiator(initiator);

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.

if (initiator != address(this)) revert BadInitiator(initiator); should be sufficient. Could we remove the _flashActive check to avoid the extra storage read?

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.

Comment thread contracts/BStock/BStockLiquidator.sol
Comment thread contracts/BStock/BStockLiquidator.sol Outdated
Comment thread scripts/bstock/atomic-liquidate.ts Outdated
Comment on lines +102 to +103
const seizedRaw = seizeTokens.mul(exchangeRate).div(ONE).mul(ONE.sub(treasuryPercent)).div(ONE);
const seizedHuman = ethers.utils.formatUnits(seizedRaw, bStockDec);

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.

We should also consider deducting the protocol's share taken by the Original Liquidator contract. Otherwise, this value will overestimate the amount that the BStockLiquidator contract actually receives.

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.

Comment thread contracts/BStock/BStockLiquidator.sol
Comment thread contracts/BStock/BStockLiquidator.sol Outdated
Comment thread contracts/BStock/BStockLiquidator.sol Outdated
Remove the shortfall guard from the liquidation pre-flight: it was
redundant with Core's liquidateBorrowAllowed and wrongly blocked forced
liquidations (which liquidate healthy, zero-shortfall accounts). Keep the
router allowlist and split it into _validateRouter(address). Rename the
struct param p -> params and drop the now-unused NotLiquidatable error
The Core liquidator gate is always configured on target networks, so
drop the direct-liquidateBorrow fallback and always route through the
Venus Liquidator, guarding against an unset gate. Removes the now-unused
LiquidateBorrowFailed error; tests set the gate by default.
Remove _flashActive: the FlashLoanFacet passes the executeFlashLoan
caller as initiator, and only flashLiquidate calls it, so initiator ==
this already proves the callback is ours. Saves two SSTOREs plus an
SLOAD and drops the NoFlashInFlight error. Add zero-address guards to
sweep for a clear revert.
The swap output is the generic debt asset (vDebt.underlying()), not
literally USDT, so rename usdtOut/usdtBefore to debtOut/debtBefore.
Naming only; the output-must-equal-debt invariant is unchanged.
The repay always routes through the pool-wide Venus Liquidator, which
keeps a treasury cut of the liquidation bonus, so the contract receives
fewer vTokens than seizeTokens. Deduct it before sizing the RFQ quote,
else the overstated amountIn makes the fixed-amount router pull revert.
Add mock getters so the fork test resolves the reads.
A direct vDebt.liquidateBorrow reverts UNAUTHORIZED while the pool gate
is set, so the emitted batch was unexecutable on mainnet. Route the
repay through the Venus Liquidator when set, redeem only the credited
amount (net of its bonus cut), and apply the redeem treasuryPercent so
the transfer never exceeds what the Safe holds. Add a driver test
/// @param flash True if funded by a flash loan, false if from inventory.
event Liquidated(
address indexed borrower,
address indexed vBStock,

@fred-venus fred-venus Jul 2, 2026

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.

also address vDebtToken ?

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.

Comment thread scripts/bstock/native-smoke.ts
Comment thread scripts/bstock/atomic-liquidate.ts Outdated
const q = await getFirmQuote({
fromAddress: liquidator.address,
tokenIn: bStock.address,
tokenOut: usdtAddr,

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.

we are still specify USDT here not debt token ?

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.

Something just occurred to me:

  1. Based on our discussion yesterday, we concluded that the swap output must be a debt token, at least at the smart contract level.
  2. However, with native, B-Stock can only be exchanged for USDT (or USDC maybe)
  3. But we can't actually guarantee that the debt token will be USDT, right?

@fred-venus fred-venus Jul 2, 2026

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 issue is even more obvious in Flash Mode:

Since Flash Mode strictly requires the repay token to be equal to the debt token, we run into a problem if the debt token isn't USDT. After selling the B-Stock, the USDT we receive needs to be converted into the repay token through an additional swap (but for now we dont have that swap ? unless the router is a multi-step router that supports native + pcs/uni

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.

You're right that hop 1 can only ever be bStock→USDT (Native quotes bStock against USDT only, confirmed against the live BSC order book). Resolved it with an optional second hop in 1ae2c47

  • USDT debt → single hop: Native bStock→USDT already lands the debt asset.
  • Non-USDT debt → two hops: hop 1 Native bStock→USDT, hop 2 USDT→debt via a second allowlisted router (AMM/aggregator — KyberSwap/OpenOcean/PancakeV2 in lib/amm.ts). minOut is enforced in the final debt asset across the whole chain.

The contract's invariant ("swap output == debt asset") is unchanged — it's just that the "swap" is now one or two hops, and the last hop always produces the debt token. router2 == address(0) keeps the exact single-hop behaviour for USDT debt.

This covers flash mode too, which was your sharpest point: the flash must be repaid in the debt asset, so for non-USDT debt, hop 2 converts USDT to the debt token before the callback checks debtOut >= principal + premium and approves the repayment. No multi-step router needed — the two hops are two separate allowlisted router calls, each approval capped at that hop's actual proceeds (seizedBStock for hop 1, the measured intermediate delta for hop 2, so pre-existing inventory is never exposed). Tests cover both inventory and flash with a BTCB debt market.

Comment thread contracts/BStock/BStockLiquidator.sol Outdated
address gate = comptroller.liquidatorContract();
ensureNonzeroAddress(gate);
debt.forceApprove(gate, params.repayAmount);
ILiquidator(gate).liquidateBorrow(address(params.vDebt), params.borrower, params.repayAmount, params.vBStock);

@fred-venus fred-venus Jul 2, 2026

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.

what if the debt token is vbnb market ?

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.

Comment thread scripts/bstock/safe-fallback.ts
atomic-liquidate hardcoded the firm-quote output to USDT while the
contract measures proceeds and enforces minOut in vDebt.underlying().
A non-USDT debt market would then fail with a cryptic on-chain
InsufficientOut(0, minOut) revert.

Request the quote in the debt underlying and fail fast off-chain when
it isn't the Native output token (USDT on BSC, the only bStock quote
asset), pointing operators at the Safe fallback for non-USDT debt.
… map

The smoke test hardcoded a two-entry TSLAB/NVDAB address map, but Native
lists many more bStock tokens (SPCXB, CRCLB, MSTRB, ...) and keeps adding
them. Resolve the token address from the live orderbook instead, so the
script stays correct with no edits as the listing grows.
It is a read only diagnostic, not an operational script. Rename so it is
visibly distinct from the two runnable scripts (atomic-liquidate,
safe-fallback) and update its usage examples to the new path.
- Native RFQ only quotes bStock->USDT on BSC, so only USDT-debt
  markets could be liquidated atomically; non-USDT ERC20 debt
  (BTCB, ETH, CAKE) was pushed to the manual Safe fallback.
- Add an optional second hop (intermediate -> debt) through a
  second allowlisted router. A single final minOut in the debt
  asset covers the whole chain; when router2 is unset the path is
  the original single hop, unchanged.
- Keep the contract DEX-agnostic: hop 2 is an opaque router call
  guarded by the allowlist + minOut, so V2/V3/aggregator is a
  purely off-chain choice. The script sources hop 2 from a
  pluggable provider (KyberSwap default, OpenOcean, or a local
  PancakeSwap V2 encode for CI).
- The two-hop settle must be submitted via a private RPC: hop 2 is
  a public-mempool swap, so minOut bounds loss while the private
  RPC removes sandwich-induced reverts.
- LiquidationParams gains router2/swapCalldata2/intermediateToken,
  changing the liquidate/flashLiquidate selectors. Pre-launch, and
  the only consumer (the off-chain script) is updated in lockstep.

@trumpgpt-bot trumpgpt-bot left a comment

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.

Overall verdict: COMMENT — no new blockers; previous blockers resolved; five smaller findings below.

What changed and what was verified on-chain

Every blocker from the previous review is addressed: safe-fallback.ts now correctly routes through the Venus Liquidator gate for step 2, applies both the Venus Liquidator treasury cut (treasuryPercentMantissa) and the Comptroller redeem fee (treasuryPercent) to seizedRaw, and the new two-hop swap extension (bStock → USDT → non-USDT debt) is cleanly integrated with delta accounting throughout.

On-chain verification:

  • Comptroller.liquidatorContract() = 0x0870793286aada55d39ce7f82fb2766e8004cf43 ✓ (gate set; routing path is always exercised)
  • Comptroller.treasuryPercent() = 0 (redeem fee zero today)
  • VenusLiquidator.treasuryPercentMantissa() = 0.5e18 = 50% — this is live and non-zero; the treasury-cut correction branch runs on every real liquidation. Both atomic-liquidate.ts and safe-fallback.ts handle it correctly via the !liqTreasuryPct.eq(0) conditional.
  • DEFAULT_SAFE (0xdc6E047...) is a deployed Safe proxy (5 signers, code verified).

Security model assessment

Reentrancy: liquidate and flashLiquidate are nonReentrant; executeOperation is callback-only, protected by msg.sender == comptroller && initiator == address(this) — these two guards together are sufficient. The removal of the _flashActive storage slot is correct.

Flash accounting: debtBefore is captured in _sellToDebt after liquidateBorrow has already pulled the repay, so debtOut measures swap proceeds only. The debtOut < amounts[0] + premiums[0] check in executeOperation ensures inventory cannot silently backfill an underwater swap — this invariant is sound.

Delta accounting: seize, redeem, and both swap hops all use balance-delta measurement. Pre-existing inventory (vBStock dust, intermediate USDT, debt) is correctly excluded at every step.

Access control: liquidate/flashLiquidate are onlyOperator; sweep/setRouter/setOperator are onlyOwner; the router allowlist bounds blast radius of a compromised operator key. ✓

No issues found in: reentrancy model, flash repayment correctness, intermediate validation in two-hop, approval hygiene in _swap, the WrongFlashAsset / BadInitiator guards, or the ensureNonzeroAddress(gate) guard.

Comment thread contracts/BStock/BStockLiquidator.sol
Comment thread contracts/BStock/BStockLiquidator.sol

import { buildBatch, call } from "./lib/safe";

const DEFAULT_SAFE = "0xdc6E047f665c3Db94292Bb7fB412B25370db2029";

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.

[question] DEFAULT_SAFE = "0xdc6E047f665c3Db94292Bb7fB412B25370db2029" — on-chain verified as a 5-signer Safe proxy, but the code contains no comment identifying which team or function this multisig serves. It is a different Safe from the EBrake Emergency Safe (0xCCa5a587...) that owns the contract. An operator running the script without the SAFE= override targets this address; if it is not the intended bStock ops/risk multisig they would silently generate a batch for the wrong signers. A one-line comment (// bStock ops/risk Safe — override with SAFE=<addr>) would make the identity auditable without a Notion link.

Comment thread scripts/bstock/atomic-liquidate.ts Outdated
Comment thread contracts/BStock/BStockLiquidator.sol Outdated
if (debtOut < repayAmounts[0]) revert InsufficientOut(debtOut, repayAmounts[0]);

// Approve the flashed vToken to pull back principal + premium.
IERC20Upgradeable(params.vDebt.underlying()).forceApprove(address(vTokens[0]), repayAmounts[0]);

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.

[nit] The flash-vToken approval set here is not explicitly reset to 0 after the flash mechanism pulls it, unlike _swap (line 226) which always calls forceApprove(router, 0) after every external call. In practice the allowance goes to 0 naturally when the flash provider pulls exactly repayAmounts[0]. Adding forceApprove(address(vTokens[0]), 0) before the return would be consistent with the contract's own approval-hygiene pattern and guard against any flash implementation that pulls less than the full amount.

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.

@trumpgpt-bot The approval can't be reset before return: the facet pulls the repayment after the callback. executeOperation runs in Phase 2; the transferFrom happens in Phase 3 (_handleFlashLoan → VToken.transferInUnderlyingFlashLoan → doTransferIn), which is after we've returned. Zeroing the allowance here would make that pull fail and revert every flash liquidation. And there's no residual to clean up: _handleFlashLoan caps the pull at borrowedAmount + totalFee, which is exactly the amounts[0] + premiums[0] we approve, so the allowance lands at 0 on its own. Leaving it as-is.

The event named the collateral market but not the repaid debt market,
so consumers couldn't filter liquidations by debt. Add vDebt as a third
indexed field, emitted in both inventory and flash paths.
- Rename `ours` to `treasuryCut`: it is the Venus Liquidator's treasury
  share subtracted from the seize, not the amount we receive.
- Document that hop-2 calldata encodes a fixed amountIn while the
  contract approves only the actual hop-1 delta, so an under-fill
  reverts SwapFailed (acute for the offline pcsv2 provider).
New deadline field on LiquidationParams; liquidate/flashLiquidate revert
DeadlineExpired once block.timestamp passes it, so a stale mempool tx
cannot settle against an expired quote.
forceApprove(gate, 0) after liquidateBorrow so a partial pull (close-factor
cap) leaves no standing allowance, matching the _swap invariant.
- vBNB has no underlying() and is repaid in native BNB, so the ERC20
  repay path cannot handle it. Account the debt in WBNB for both the
  inventory and flash modes, unwrap only the repay amount, and forward
  it to the gate's payable vBNB branch; swap proceeds stay in WBNB.
- Flash mode borrows vWBNB, not vBNB: vBNB's native doTransferIn can
  never satisfy the comptroller-driven flash repay.
- Keep the vBNB/vWBNB/WBNB addresses immutable so WBNB.withdraw's
  2300-gas transfer into the proxy receive() stays within budget.
- Accept native via receive() and add owner sweepNative to rescue any
  stray or refunded BNB.
- Fork tests target codeless on-chain EOAs: vBNB disburses a borrow
  with a native transfer (a CALL into the borrower), and hardhat's
  default accounts carry EIP-7702 delegation code that is an invalid
  opcode under the fork's London rules.
Document the three manual-trigger scripts (atomic, safe-fallback,
native-smoke) with commands, env tables, and operator gotchas so the
team can drive liquidations without reading the source.
Deploys a bStock ERC20 + real Venus vToken, lists it on the live Core diamond
via timelock/ACM, wires a ResilientOracle direct price, sets collateral factor,
supply cap, action-unpause, and the per-market liquidation incentive (diamond
default 0 zeroes the seize). Plus seeded PRNG + balance-slot helpers.
…fork fuzz coverage

- add MockReentrantRouter and assert nonReentrant blocks re-entry into
  liquidate/flashLiquidate while the outer settle succeeds
- add deadline-boundary and gate-approval-reset assertions
- add fork helpers: underwater-borrower factory, swap-hop builders,
  settle/rollback invariants
- add curated fork scenarios (native BNB debt, two-hop PCS, forced
  liquidation, minOut-breach rollback) + dynamic debt-market sweep
- add seeded and fast-check property fuzz over repay/minOut
…ate leak

The sweepNative test calls setBalance on default signer[3], draining it.
Without snapshot isolation this leaked into Fork/TokenRedeemer, whose
treasury reuses signer[3] and mints 3000 ETH vBNB in setup, failing under
coverage with 'Sender doesn't have enough funds'. Snapshot before the
suite and restore after for a clean signer handoff.
Comment thread contracts/BStock/BStockLiquidator.sol Outdated
Comment thread scripts/bstock/atomic-liquidate.ts Outdated
Comment thread scripts/bstock/safe-fallback.ts Outdated
Comment thread scripts/bstock/atomic-liquidate.ts Outdated
Comment thread scripts/bstock/atomic-liquidate.ts Outdated
Comment thread scripts/bstock/safe-fallback.ts Outdated
Comment thread scripts/bstock/safe-fallback.ts Outdated
Debugger022 and others added 8 commits July 3, 2026 10:59
- vBNB has no underlying(), so detect it by catching the revert and
  account the debt as native BNB (18 decimals) instead of crashing
- drop the ERC20 approve on the native path and send the repay as
  msg.value, matching the Liquidator's msg.value == repayAmount check
- read the Safe's native balance for the pre-execution fund check
- extend the Safe-tx helper to carry an optional native value
Add the 019 deploy script (owner Safe via initialize, timelock proxy
admin, BscScan verify) and record the bscmainnet deployment artifacts
and migration entry.
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Health
contracts 100% 100%
contracts.Admin 88% 41%
contracts.BStock 100% 97%
contracts.Comptroller 100% 90%
contracts.Comptroller.Diamond 95% 64%
contracts.Comptroller.Diamond.facets 87% 73%
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 43% 46%
contracts.Liquidator 83% 60%
contracts.Oracle 100% 100%
contracts.PegStability 88% 84%
contracts.Swap 87% 58%
contracts.Swap.interfaces 100% 100%
contracts.Swap.lib 81% 55%
contracts.Tokens 100% 100%
contracts.Tokens.Prime 97% 81%
contracts.Tokens.Prime.Interfaces 100% 100%
contracts.Tokens.Prime.libs 90% 77%
contracts.Tokens.VAI 82% 52%
contracts.Tokens.VRT 20% 9%
contracts.Tokens.VTokens 70% 52%
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 61% (4462 / 7282) 47% (1715 / 3616)

@fred-venus

Copy link
Copy Markdown
Contributor

maybe also update the readme (comment) accordingly so that new comes know how to use it

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.

4 participants