[VPD-1430, 1431, 1432] feat(bstock): atomic backstop liquidator + off-chain scripts #683
[VPD-1430, 1431, 1432] feat(bstock): atomic backstop liquidator + off-chain scripts #683Debugger022 wants to merge 36 commits into
Conversation
- 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
trumpgpt-bot
left a comment
There was a problem hiding this comment.
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 --- |
There was a problem hiding this comment.
[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.
| ); | ||
| 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 |
There was a problem hiding this comment.
[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.
| } | ||
|
|
||
| /** Known bStock token addresses on BNB Chain (18 decimals). */ | ||
| export const BSTOCK_TOKENS = { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
| import { BigNumber, Contract, providers, utils } from "ethers"; | ||
| import { promises as fs } from "fs"; | ||
| import * as path from "path"; | ||
|
|
There was a problem hiding this comment.
[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.
| if (!_flashActive) revert NoFlashInFlight(); | ||
| if (initiator != address(this)) revert BadInitiator(initiator); |
There was a problem hiding this comment.
if (initiator != address(this)) revert BadInitiator(initiator); should be sufficient. Could we remove the _flashActive check to avoid the extra storage read?
| const seizedRaw = seizeTokens.mul(exchangeRate).div(ONE).mul(ONE.sub(treasuryPercent)).div(ONE); | ||
| const seizedHuman = ethers.utils.formatUnits(seizedRaw, bStockDec); |
There was a problem hiding this comment.
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.
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, |
There was a problem hiding this comment.
also address vDebtToken ?
| const q = await getFirmQuote({ | ||
| fromAddress: liquidator.address, | ||
| tokenIn: bStock.address, | ||
| tokenOut: usdtAddr, |
There was a problem hiding this comment.
we are still specify USDT here not debt token ?
There was a problem hiding this comment.
Something just occurred to me:
- Based on our discussion yesterday, we concluded that the swap output must be a debt token, at least at the smart contract level.
- However, with native, B-Stock can only be exchanged for USDT (or USDC maybe)
- But we can't actually guarantee that the debt token will be USDT, right?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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).minOutis 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.
| address gate = comptroller.liquidatorContract(); | ||
| ensureNonzeroAddress(gate); | ||
| debt.forceApprove(gate, params.repayAmount); | ||
| ILiquidator(gate).liquidateBorrow(address(params.vDebt), params.borrower, params.repayAmount, params.vBStock); |
There was a problem hiding this comment.
what if the debt token is vbnb market ?
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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. Bothatomic-liquidate.tsandsafe-fallback.tshandle 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.
|
|
||
| import { buildBatch, call } from "./lib/safe"; | ||
|
|
||
| const DEFAULT_SAFE = "0xdc6E047f665c3Db94292Bb7fB412B25370db2029"; |
There was a problem hiding this comment.
[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.
| 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]); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
@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.
… and safe-fallback scripts
…ved quote accuracy
- 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.
|
|
maybe also update the readme (comment) accordingly so that new comes know how to use it |
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:
BStockLiquidatorcontract + testsUpgradeable, 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).executeFlashLoan, repaid + premium in the same tx).IComptroller(InterfacesV8) withgetAccountLiquidityandexecuteFlashLoan(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 signedtxRequest+ TTL, holds the TSLAB/NVDAB/USDT constants. API key viaNATIVE_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).atomic-liquidate.ts— driver for the deployedBStockLiquidator(the normal path). Confirms shortfall, precomputes the seize (adjusted for the redeemtreasuryPercent), fetches the MM-signed firm-quote with the contract as taker, checks the router is allowlisted, computesminOut, then callsliquidate(inventory) orflashLiquidate(flash loan).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.Testing
tests/hardhat/BStockLiquidator.ts: 23 passing — both funding modes, gate + treasury-cut path, all guards and error paths.tests/hardhat/BStockAtomicLiquidate.ts: 4 passing — drivesatomic-liquidate.tsend to end (inventory + flash, healthy-borrower and router-allowlist guards).tests/hardhat/Fork/BStockLiquidatorFork.ts: passing — realliquidateBorrow→ seize → redeem through the live Venus Liquidator, with strict profit/borrow assertions (stand-in markets + mock router, since no bStock market is listed yet).Notes
setFlashLoanEnabled) is handled by a separate VIP (VPD-1433).