[VPD-1430, 1431, 1432] feat(bstock): atomic backstop liquidator + off-chain scripts #683
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.
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
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.
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.
A borrower whose only debt is VAI was unliquidatable: _liquidate called underlying(), which the VAIController does not have. Resolve VAI via getVAIAddress() and route it through the gate's _liquidateVAI branch, two-hop bStock->USDT->VAI with the PSM as hop 2. INVENTORY only — VAI is burned on repay, so there is no market to flash from.
It detected vBNB by catching underlying(), which the VAIController also
lacks, so a VAI debt built a {value: repay} batch the gate rejects. Detect
VAI explicitly and use its own seize math (liquidateVAICalculateSeizeTokens,
borrower-agnostic incentive). This path ships the seized bStock instead of
swapping it, so it is the fallback when the atomic path's PSM hop is down.
The contract and its tests already specified the Peg Stability Module (swapStableForVAI) as router2 for a VAI debt, but atomic-liquidate.ts had no code to construct that calldata — a VAI run fell through to the AMM aggregator path. Add lib/psm.ts: encodes swapStableForVAI locally (receiver = the liquidator, amountIn = the hop-1 floor), derives expectedOut from previewSwapStableForVAI, and pre-flights isPaused and mint-cap headroom. Reject MODE=flash for VAI before quoting, mirroring the contract's FlashNotSupportedForVai.
… module Settles a real underwater VAI position on a bscmainnet fork: bStock collateral on the live diamond, VAI minted through the real VAIController (prime-only flag flipped via the real ACM + timelock), repay through the real gate's VAI branch, and hop 2 executed with the exact swapStableForVAI calldata lib/psm.ts builds — asserting the real PSM mints exactly its previewSwapStableForVAI amount to the contract. A fresh proxy is used because the deployed one predates VAI support. Also proves getPsmSwap refuses to build when the real PSM is paused.
Runs atomicLiquidate() itself end-to-end on the bscmainnet fork for a constructed VAI-debt borrower (prime-only mint flag flipped through the real ACM + timelock, bStock collateral gapped into shortfall): the script detects the VAIController debt, sizes the seize with the VAI overload, builds the swapStableForVAI calldata against its real default PSM address, derives minOut from the live preview, and settles — the real PSM mints exactly the previewed amount and the undershot hop-1 surplus stays as sweepable USDT. Also asserts the script refuses MODE=flash for VAI before quoting. VAI scaffolding extracted into shared helpers.
…ht guards Correctness and safety fixes to the off-chain liquidation scripts, each with tests (mocked script suites + a bscmainnet-fork scenario): - Size the Venus Liquidator treasury cut off getEffectiveLiquidationIncentive for every debt type, VAI included, mirroring Liquidator._splitLiquidationIncentive exactly. The VAI path previously used the borrower-agnostic core incentive, which diverges when the borrower sits in a non-core pool. Correct the stale "cut is 0 today" comments (the live BSC gate keeps 50% of the bonus) and log the resolved cut. - Derive the single-hop minOut from the built order's guaranteed floor instead of the indicative quote, so an in-slippage Liquid Mesh fill below the quote no longer reverts InsufficientOut (matches the two-hop sizing). - Buffer the safe-fallback redeem/transfer amounts by SEIZE_BUFFER so ordinary oracle price drift between batch generation and signer quorum leaves sweepable dust rather than reverting the redeem. - Validate SLIPPAGE and MIN_OUT_BUFFER to [0,100) like SEIZE_BUFFER. - Read the getAccountLiquidity error slot and report an oracle failure distinctly; gate the no-shortfall abort behind ALLOW_NO_SHORTFALL=1 so forced liquidations of healthy accounts are supported. - Sanity-bound the Liquid Mesh order expiry to reject a millisecond-epoch value that would silently disable the off-chain TTL guards. Test mocks gain a divergent effective-vs-core incentive and a getAccountLiquidity error slot to exercise the above.
…omments - README: add SETTLE_TTL_MARGIN and ALLOW_NO_SHORTFALL to the atomic env table, SEIZE_BUFFER to the safe-fallback table, an advanced/override env subsection, and correct "4 txs" to "3-4" (native BNB drops the approve). Note the single-hop minOut is derived from the guaranteed floor and that price drift alone can invalidate a safe-fallback batch. - amm.ts: correct the stale comments that described exact-match hop-2 sizing; the caller passes the hop-1 floor and relies on floor <= midDelta.
…n scripts The treasury-cut comments claimed getEffectiveLiquidationIncentive diverges from the core getLiquidationIncentive "whenever the borrower sits in a non-core pool" — false for VAI. A VAI borrower is core-pool-locked (VAIController.mintVAI requires the core pool and MarketFacet.hasValidPoolBorrows bars leaving it while mintedVAIs>0), so userPoolId is always 0 and effective == core for VAI. Divergence is only reachable for a non-VAI ERC20 borrower in a non-core pool. Both scripts already call effective for the cut (gate-parity with Liquidator._splitLiquidationIncentive + future-proofing); only the rationale was wrong. Comments updated in atomic-liquidate.ts and safe-fallback.ts.
…e ERC20 borrower Add a bscmainnet-fork test that stands up a real non-core e-mode pool listing vBStock at a divergent liquidation incentive (1.25x vs core 1.1x), puts a USDT borrower in it, and gaps the stock into shortfall. It proves (a) getEffectiveLiquidationIncentive and getLiquidationIncentive actually diverge on the live diamond for a non-VAI debt, (b) atomic-liquidate.ts sizes the treasury cut off the effective incentive (the hop-1 quote amount matches the effective-cut seize, not the core one), and (c) it settles end-to-end through the real gate. Also mark the VAI treasury-cut unit test as defensive: its forced core-vs-effective divergence is on-chain-impossible for VAI (core-pool-locked), so it guards the script's cut formula, not a reproducible mainnet state; the genuine case is the new fork test.
…fallback framing Rework the operator flowchart to reflect the current multi-source, VAI-capable scripts: pre-flight gates (debt-type detect, shortfall/ALLOW_NO_SHORTFALL, VAI-forces-inventory), hop-2 routing by debt type (single-hop USDT, AMM for other ERC20, WBNB unwrap for native BNB, PSM swapStableForVAI for VAI), and three distinct fallback triggers merging into one rail (no hop-1 source fills, VAI PSM paused/capped, dry-run fails). native-smoke probes Native ONLY — there is no Liquid Mesh liveness probe, so LM is only validated inside atomic-liquidate. The chart now says "Native quote alive?" and routes a Native-down case into atomic (SOURCE=liquidmesh) rather than straight to the Safe fallback. Also correct the safe-fallback.ts and README framing: the fallback triggers when the whole quote path is down (every RFQ source, or the VAI PSM hop), and it is RFQ/PSM-independent because it ships bStock to the CEX with no on-chain swap.
…label Split the group header so "2 · atomic-liquidate.ts" stays left and "DRY_RUN=1" is right-aligned, leaving the vertical "alive · auto" edge a clear center gap.
The seize error strings hardcoded liquidateCalculateSeizeTokens even on the isVai branch, which calls liquidateVAICalculateSeizeTokens — misleading when a VAI liquidation or Safe-batch build fails. Derive the function name from isVai and interpolate it into both throws in atomic-liquidate.ts and safe-fallback.ts.
…and docs - Add lm-smoke.ts: a read-only Liquid Mesh /quote probe, the counterpart to native-smoke.ts (never /swap; one best-effort symbol() label read, reusing ARCHIVE_NODE_bscmainnet). Both smokes now print the same shape (amountIn/out, px/token) with a mirrored firm-only / indicative block. - Rename the hop-1 output var nativeOut -> usdtOut in atomic-liquidate.ts (it holds the USDT intermediate; "native" collided with native-BNB debt and the Native source) and de-stale "Native quote" -> "hop-1 quote" wording now that hop-1 is multi-source. - Document the VAI path in the atomic header (PSM swapStableForVAI hop-2, inventory-only) and the test/fork env overrides (MOCK_NATIVE/MOCK_OUT/IMPERSONATE/USDT_ADDR/WBNB_ADDR). - Refresh the flowchart + README: box 1 shows native-smoke and lm-smoke, the decision is "any RFQ source live?", and the Safe fallback trigger is the whole quote path (all RFQ sources down, or the VAI PSM paused/capped) rather than "Native unavailable". - Add the bStock secrets to .env.example (NATIVE_API_KEY, LM_API_KEY, LM_PRIVATE_KEY_SEED, optional RPC_URL). Unit: tests/hardhat/BStock*.ts - 106 passing.
[VPD-1589] Add Liquid Mesh as a hop-1 liquidation source
… matrix
Completes BStockLiquidatorFork.ts coverage across
{inventory, flash} × {native-style router-pulls, LM-style split-spender} ×
{USDT, CAKE (real PCS), BNB (WBNB unwrap), VAI (real PSM)}:
- adds the 8 previously untested positive cells — inv×LM×{CAKE,BNB,VAI},
flash×native×{CAKE,BNB}, flash×LM×{USDT,CAKE,BNB} — plus an on-fork
flash×VAI FlashNotSupportedForVai rejection check
- flash×two-hop and flash×split-spender existed nowhere: these are the cells
where the flash principal+premium repay must come out of the swap chain
- hop-2 legs sized exactly like atomic-liquidate.ts (4-arg seize, gate cut off
the effective incentive, redeem treasuryPercent), so flash settles at the
real ~4% incentive margin instead of the main helper's blanket -10%
- pins WBNB as a ChainlinkOracle direct price: fork clock drift stales the
live 3-oracle BNB config and reverts any vBNB borrow/liquidity check
(the main suite's BNB cell hits this at its default block)
10 passing at FORK_BSTOCK_BLOCK=110490000.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ResilientOracle maps the native market to NATIVE_TOKEN_ADDR (0xbBbB…BBbB), not WBNB (0xbb4CdB…095c), so the matrix suite's price pin never reached vBNB. Every vBNB read stayed on the live feed and reverted 'invalid resilient oracle price' once a run drifted past its ~30min staleness window, failing the BNB cells intermittently.
[VPD-1598] BStockLiquidator: Liquid Mesh hop-1 source, VAI debt support, and HashDit audit mitigations
test(bstock): fork-prove the full mode × hop-1 source × debt scenario matrix
Feat/vpd 1598
[VPD-1636] BStockLiquidator deployment
|
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).