[VPD-1598] BStockLiquidator: Liquid Mesh hop-1 source, VAI debt support, and HashDit audit mitigations#696
[VPD-1598] BStockLiquidator: Liquid Mesh hop-1 source, VAI debt support, and HashDit audit mitigations#696Debugger022 wants to merge 36 commits into
Conversation
…swaps Some aggregators (e.g. Liquid Mesh) pull the input token through a settlement contract distinct from the call target. Add a routerSpender mapping so _swap approves the puller, defaulting to the router itself so Native is unchanged. Gap shrinks 50->49; storage-safe.
Price Native and Liquid Mesh per liquidation and settle through the higher-out source. The LM client uses disableSimulate to build swap calldata for the not-yet-holding contract, keeping seize->sell atomic; sources sit behind a registry so future ones need no contract change
There was a problem hiding this comment.
Claude Code Review
Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.
Tip: disable this comment in your organization's Code Review settings.
Couple the spender lifecycle to the router allowlist: - setRouterSpender reverts unless the router is currently allowlisted - a non-zero spender must be a deployed contract (it receives a live exact-amount approval during _swap; an EOA is always a misconfig) - setRouter(router, false) clears any configured spender so a stale entry cannot silently reactivate on a later re-allowlist The spender was previously unvalidated: a fat-fingered address would silently receive the hop approval on every swap through that router.
… build Liquid Mesh's /quote number is indicative — the separately built /swap order only guarantees its own floor — while Native's firm quote executes at exactly its amountOut. Comparing the two raw outs let an optimistic LM quote win the hop-1 selection and then fill worse than Native guaranteed. Tag each SourceQuote as firm/indicative and expose the built order's builtFloor from build(); when an indicative winner's floor undercuts the best firm loser, execute the firm quote instead.
LM RFQ orders are short-lived; an order already too tight to survive signing + submission + inclusion would only fail on-chain with DeadlineExpired, mid-incident. Abort at build time instead when fewer than LM_MIN_TTL seconds (default 15) remain on the order.
Replace the two any-typed JSON parses with a typed LmResponse<T> envelope, and reject an LM_PRIVATE_KEY_SEED that does not decode to exactly 32 bytes with a legible error instead of the cryptic createPrivateKey DER failure.
Runbook lagged two behavior changes: - indicative LM winner is reconciled against the best firm quote after build; operators see an expected `hop-1 reconcile:` source switch - setRouterSpender now requires an allowlisted router and a contract spender; de-allowlisting clears the spender entry
Zero-context operators had to infer the run order from scattered hints; give them a mermaid decision tree (smoke -> dry-run -> send, Safe fallback branches) plus rules of thumb up front.
There was a problem hiding this comment.
Pull request overview
This PR implements the HashDit audit mitigations for BStockLiquidator, spanning on-chain hardening (swap accounting + ownership footgun prevention), off-chain liquidation tooling correctness (borrower-aware seize estimation + quote freshness checks), and corresponding tests/mocks to validate the new behaviors.
Changes:
- Adds
PartialSwapLeftoverobservability for partial-fill swap hops and updates swap accounting to measure actual input consumption. - Disables
renounceOwnership()to prevent bricking owner-only recovery/admin controls; addsnonReentranttosweepNativefor defense-in-depth consistency. - Updates scripts and fork helpers to use the borrower-aware 4-arg
liquidateCalculateSeizeTokensoverload and adds a pre-submit Native-quote TTL margin check (with tests).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/hardhat/Fork/helpers/bstock.ts | Fork helper updated to use borrower-aware 4-arg seize calculation and thread borrower through two-hop builder. |
| tests/hardhat/Fork/BStockLiquidatorFork.ts | Updates fork test call sites to pass borrower into the two-hop helper. |
| tests/hardhat/BStockLiquidator.ts | Adds unit tests for partial-fill leftover event + sweep recovery and renounceOwnership no-op semantics. |
| tests/hardhat/BStockAtomicLiquidate.ts | Adds tests for SETTLE_TTL_MARGIN validation and TTL-below-margin abort behavior. |
| scripts/bstock/safe-fallback.ts | Switches to 4-arg seize overload and adds seizeTokens > 0 sanity guard. |
| scripts/bstock/atomic-liquidate.ts | Switches to 4-arg seize overload, adds seizeTokens > 0 guard, and re-checks quote TTL against SETTLE_TTL_MARGIN immediately before submit. |
| deploy/019-deploy-bstock-liquidator.ts | Documents governance-gated flash-loan whitelist step required for FLASH mode. |
| contracts/test/BStockLiquidationMocks.sol | Extends MockComptrollerLite with the 4-arg liquidateCalculateSeizeTokens overload. |
| contracts/BStock/IBStockLiquidator.sol | Introduces PartialSwapLeftover event to surface unconsumed input residuals. |
| contracts/BStock/BStockLiquidator.sol | Implements leftover emission + swap accounting, adds nonReentrant to sweepNative, and disables renounceOwnership. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Promise.allSettled waits for the slowest source, so a hung API blocked the live one. Add fetchWithTimeout (SOURCE_TIMEOUT_MS, default 8s) to the Native and LM clients so a hung source aborts and drops out.
Surface the swap router's own revert reason instead of an opaque SwapFailed(), so a failed hop reports the actual cause (e.g. insufficient allowance). SwapFailed() is kept only as the empty-returndata fallback.
An LM /quote is indicative, but hop-2 calldata bakes in a fixed amountIn while the contract approves only the real hop-1 delta — an underfill left the AMM pulling past the approval. Sizing off the guaranteed floor keeps the pull covered; the surplus stays sweepable. Native unchanged (floor==out).
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
Summary
Three related bodies of work on the bStock backstop liquidator, on top of the base branch:
routerSpendermapping to the contract so an aggregator whose settlement contract differs from the call target (Liquid Mesh pulls the input via a separate spender) is supported, keeping seize-then-sell atomic.swapStableForVAI), with calldata built locally inlib/psm.ts. No aggregator; the contract stays route-agnostic.Contract (
contracts/BStock/)routerSpendermapping +setRouterSpender(owner-only,address(0)clears)._swapapproves the router's configured spender, defaulting to the router itself so Native is unchanged. Hardened: revertsRouterNotAllowedunless the router is allowlisted,SpenderNotContractfor a non-zero spender with no code;setRouter(router, false)also clears the configured spender so a stale entry can't silently reactivate.RouterSpenderSetevent.__gap50 -> 49, storage-safe.isVaidetection off the VAIController;InvalidIntermediateguard requires a hop-2router2for VAI (and native BNB) debt; VAI flash mode is rejected (no vVAI to flash from)._swapmeasures input actually consumed (balBefore - balAfter) and emitsPartialSwapLeftoverwhen a router fills less than approved (partial RFQ fill), leaving the residual observable + sweepable; overstated NatSpec corrected.renounceOwnership()overridden to a no-op, so an accidental call cannot zero the owner and brick this fund-custody contract.sweepNativegivennonReentrantfor consistency with the liquidation entrypoints (defense-in-depth).Off-chain scripts (
scripts/bstock/)Hop-1 source registry
lib/sources.ts: pluggable registry the driver is generic over.SOURCE=autoprices every source whose creds are present and takes the higher out; a comma-separated subset restricts it.lib/liquidmesh.tsis the LM client (Ed25519 JWT;/quote, and/swapwithdisableSimulate:trueso calldata can be built for a contract that doesn't yet hold the input). Firm/indicative reconcile: Native is firm (executes at exactly itsamountOut), LM/quoteis indicative and its built order only guarantees a floor; if an indicative winner's floor undercuts the best firm quote it beat, the driver executes the firm quote instead. Order-liveness guards:LM_MIN_TTL(default 15s) and an implausible-expiry check reject a stale order before the settle tx is built; hop-1 API calls time out instead of hanging.VAI debt (hop-2 via the PSM)
lib/psm.tsencodesswapStableForVAI(USDT -> VAI at the oracle rate) and pre-flights the PSM: aborts legibly (use the Safe fallback) if itisPaused()or if mint-cap headroom (vaiMintCap - vaiMinted) cannot cover the pull, rather than shipping calldata that reverts on-chain.expectedOutfrom the realpreviewSwapStableForVAI(USDT prices under $1, not 1:1).MODE=flashrejected for VAI.Seize / proceeds sizing
atomic-liquidate.ts/safe-fallback.tsuse the borrower-aware 4-argliquidateCalculateSeizeTokensfor ERC20 debt (matchesvToken.liquidateBorrowFresh) and the VAI-specificliquidateVAICalculateSeizeTokensfor VAI, withseizeTokens > 0sanity guards; error messages name the actual function per branch.getEffectiveLiquidationIncentive(borrower, vBStock)for every debt type (mirrorsLiquidator._splitLiquidationIncentive; the live BSC gate keeps 50% of the bonus). For VAI the borrower is always core-pool-locked so effective == core there — using effective is gate-parity + future-proofing, and the genuinely divergent case (a non-core ERC20 borrower) is exercised in the fork suite.minOutderived from the built order's guaranteed floor, not the indicative quote, so an in-slippage LM fill below the quote no longer revertsInsufficientOut. Safe-fallback amounts are haircut bySEIZE_BUFFER(default 0.1%) so oracle price drift between batch generation and signer quorum leaves sweepable dust rather than reverting the redeem.Guards / driver
atomic-liquidate.tsre-verifies the hop-1 quote's remaining TTL immediately before submission, aborting below a configurableSETTLE_TTL_MARGIN(default 10s).SLIPPAGE/MIN_OUT_BUFFERvalidated to[0,100); thegetAccountLiquidityerror slot is read and an oracle failure reported distinctly (not mislabelled "no shortfall"); the no-shortfall abort is gated behindALLOW_NO_SHORTFALL=1so a forced liquidation of a healthy account is supported.Tooling & docs
native-smoke.ts/lm-smoke.ts— read-only firm/indicative quote probes with aligned output (shared amountIn/out + px/token, then a mirroredfirm-onlyvsindicativeblock).verify-lm-fork.ts— on-demand fork proof that an LMdisableSimulateblob executes on-chain (dev-time only).setWhiteListFlashLoanAccountstep required before FLASH mode.liquidation-flowchart.svg): pre-flight gates, hop-2 routing by debt type (single-hop USDT, AMM for other ERC20, WBNB unwrap for native BNB, PSM for VAI), and the fallback triggers (all RFQ sources down, VAI PSM paused/capped, or dry-run fail)..env.exampledocuments the required secrets (NATIVE_API_KEY,LM_API_KEY,LM_PRIVATE_KEY_SEED) + optionalRPC_URL.Tests & mocks
BStockLiquidatorLiquidMesh.ts: split-spender atomic execution + spender-hardening (unallowlisted router, EOA spender, spender cleared on de-allowlist).BStockAtomicLiquidate.ts: source-registry selection, firm/indicative reconcile, LM min-TTL + ms-epoch expiry rejection,SLIPPAGE/MIN_OUT_BUFFERvalidation,getAccountLiquidityerror code,ALLOW_NO_SHORTFALL, single-hop minOut-from-floor, VAI PSM hop-2 (viaMockPSM), VAI flash-reject, effective-incentive cut (defensive), and the L06 TTL-margin cases.BStockSafeFallback.ts:SEIZE_BUFFERhaircut + range check, VAI debt batch shape.BStockLiquidator.ts: contract unit tests incl.PartialSwapLeftoveremit + sweep recovery / no-emit on full fill (L02),renounceOwnershipno-op (L03),RouterSpenderSet+ hardening.Fork/BStockLiquidatorFork.ts: LM split-spender against the real gate; two-hop and script-driven VAI via the real PSM; paused-PSM refusal; forced liquidation underALLOW_NO_SHORTFALL; and effective-incentive divergence on a non-core e-mode ERC20 borrower (provesgetEffectiveLiquidationIncentive != getLiquidationIncentiveon the live diamond). Mocks:MockSpender,MockSplitRouter,MockPSM.Test plan
npx hardhat compile— clean.npx hardhat test tests/hardhat/BStock*.ts— 106 passing.ARCHIVE_NODE_bscmainnet):FORKED_NETWORK=bscmainnet npx hardhat test tests/hardhat/Fork/BStockLiquidatorFork.ts— LM split-router, VAI (real PSM), effective-incentive divergence, forced-liquidation, and paused-PSM scenarios.disableSimulateproof (fork at head, chainId 56, LM creds):npx hardhat run scripts/bstock/verify-lm-fork.ts.Audit disposition
swapStableForVAI,lib/psm.ts) and fork-tested against the real PegStability module + a script-driven scenario, so VAI debt is liquidatable end-to-end. Commits:feat(bstock): build the VAI hop-2 PSM calldata in the script,test(bstock): fork-test the VAI PSM hop against the real PegStability module,test(bstock): script-driven VAI liquidation fork scenario(from VPD-1589 Add Liquid Mesh as a hop-1 liquidation source).forceVAILiquidateremains governance-gated on-chain.