Skip to content

[VPD-1598] BStockLiquidator: Liquid Mesh hop-1 source, VAI debt support, and HashDit audit mitigations#696

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

[VPD-1598] BStockLiquidator: Liquid Mesh hop-1 source, VAI debt support, and HashDit audit mitigations#696
Debugger022 wants to merge 36 commits into
feat/VPD-1430from
feat/VPD-1598

Conversation

@Debugger022

@Debugger022 Debugger022 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Three related bodies of work on the bStock backstop liquidator, on top of the base branch:

  • Liquid Mesh as a second hop-1 RFQ source for the bStock sell leg, alongside Native RFQ — priced per liquidation, higher-out source wins. Adds a routerSpender mapping 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.
  • VAI debt support — for a VAI borrow the hop-2 leg settles through the Peg Stability Module (swapStableForVAI), with calldata built locally in lib/psm.ts. No aggregator; the contract stays route-agnostic.
  • HashDit audit mitigations — the Medium (M01), five Low (L02–L06) and one Informational (I01) fixed; one Low (L01) acknowledged as by-design. M01's off-chain VAI liquidation path is now implemented + fork-tested (see disposition). No fund-loss issues were found.

Contract (contracts/BStock/)

  • routerSpender mapping + setRouterSpender (owner-only, address(0) clears). _swap approves the router's configured spender, defaulting to the router itself so Native is unchanged. Hardened: reverts RouterNotAllowed unless the router is allowlisted, SpenderNotContract for a non-zero spender with no code; setRouter(router, false) also clears the configured spender so a stale entry can't silently reactivate. RouterSpenderSet event. __gap 50 -> 49, storage-safe.
  • VAI path: isVai detection off the VAIController; InvalidIntermediate guard requires a hop-2 router2 for VAI (and native BNB) debt; VAI flash mode is rejected (no vVAI to flash from).
  • L02_swap measures input actually consumed (balBefore - balAfter) and emits PartialSwapLeftover when a router fills less than approved (partial RFQ fill), leaving the residual observable + sweepable; overstated NatSpec corrected.
  • L03renounceOwnership() overridden to a no-op, so an accidental call cannot zero the owner and brick this fund-custody contract.
  • I01sweepNative given nonReentrant for 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=auto prices every source whose creds are present and takes the higher out; a comma-separated subset restricts it. lib/liquidmesh.ts is the LM client (Ed25519 JWT; /quote, and /swap with disableSimulate:true so calldata can be built for a contract that doesn't yet hold the input). Firm/indicative reconcile: Native is firm (executes at exactly its amountOut), LM /quote is 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.ts encodes swapStableForVAI (USDT -> VAI at the oracle rate) and pre-flights the PSM: aborts legibly (use the Safe fallback) if it isPaused() or if mint-cap headroom (vaiMintCap - vaiMinted) cannot cover the pull, rather than shipping calldata that reverts on-chain. expectedOut from the real previewSwapStableForVAI (USDT prices under $1, not 1:1). MODE=flash rejected for VAI.

Seize / proceeds sizing

  • L04atomic-liquidate.ts / safe-fallback.ts use the borrower-aware 4-arg liquidateCalculateSeizeTokens for ERC20 debt (matches vToken.liquidateBorrowFresh) and the VAI-specific liquidateVAICalculateSeizeTokens for VAI, with seizeTokens > 0 sanity guards; error messages name the actual function per branch.
  • The Venus Liquidator treasury cut is sized off getEffectiveLiquidationIncentive(borrower, vBStock) for every debt type (mirrors Liquidator._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.
  • Single-hop minOut derived from the built order's guaranteed floor, not the indicative quote, so an in-slippage LM fill below the quote no longer reverts InsufficientOut. Safe-fallback amounts are haircut by SEIZE_BUFFER (default 0.1%) so oracle price drift between batch generation and signer quorum leaves sweepable dust rather than reverting the redeem.

Guards / driver

  • L06atomic-liquidate.ts re-verifies the hop-1 quote's remaining TTL immediately before submission, aborting below a configurable SETTLE_TTL_MARGIN (default 10s).
  • SLIPPAGE / MIN_OUT_BUFFER validated to [0,100); the getAccountLiquidity error slot is read and an oracle failure reported distinctly (not mislabelled "no shortfall"); the no-shortfall abort is gated behind ALLOW_NO_SHORTFALL=1 so 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 mirrored firm-only vs indicative block). verify-lm-fork.ts — on-demand fork proof that an LM disableSimulate blob executes on-chain (dev-time only).
  • L05 — deploy checklist documents the governance-gated setWhiteListFlashLoanAccount step required before FLASH mode.
  • README operator runbook + decision flowchart (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.example documents the required secrets (NATIVE_API_KEY, LM_API_KEY, LM_PRIVATE_KEY_SEED) + optional RPC_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_BUFFER validation, getAccountLiquidity error code, ALLOW_NO_SHORTFALL, single-hop minOut-from-floor, VAI PSM hop-2 (via MockPSM), VAI flash-reject, effective-incentive cut (defensive), and the L06 TTL-margin cases.
  • BStockSafeFallback.ts: SEIZE_BUFFER haircut + range check, VAI debt batch shape.
  • BStockLiquidator.ts: contract unit tests incl. PartialSwapLeftover emit + sweep recovery / no-emit on full fill (L02), renounceOwnership no-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 under ALLOW_NO_SHORTFALL; and effective-incentive divergence on a non-core e-mode ERC20 borrower (proves getEffectiveLiquidationIncentive != getLiquidationIncentive on the live diamond). Mocks: MockSpender, MockSplitRouter, MockPSM.

Test plan

  • npx hardhat compile — clean.
  • Unit (deterministic): npx hardhat test tests/hardhat/BStock*.ts — 106 passing.
  • Fork (needs 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.
  • Live LM disableSimulate proof (fork at head, chainId 56, LM creds): npx hardhat run scripts/bstock/verify-lm-fork.ts.

Audit disposition

Finding Severity Status
M01 Medium Fixed — the off-chain VAI liquidation path is now implemented (hop-2 via PSM 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). forceVAILiquidate remains governance-gated on-chain.
L01 Low Acknowledged — operators are a trusted role by design; FLASH mode already enforces an operator-independent floor
L02–L06 Low Fixed
I01 Informational Fixed

Note: the currently-deployed proxy predates both routerSpender and the VAI path — a new deploy/upgrade is required before the Liquid Mesh or VAI liquidations work in production.

…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
@Debugger022 Debugger022 self-assigned this Jul 13, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 PartialSwapLeftover observability for partial-fill swap hops and updates swap accounting to measure actual input consumption.
  • Disables renounceOwnership() to prevent bricking owner-only recovery/admin controls; adds nonReentrant to sweepNative for defense-in-depth consistency.
  • Updates scripts and fork helpers to use the borrower-aware 4-arg liquidateCalculateSeizeTokens overload 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.

Comment thread contracts/BStock/BStockLiquidator.sol Outdated
Comment thread contracts/BStock/BStockLiquidator.sol
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.
Comment thread contracts/BStock/BStockLiquidator.sol Outdated
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.
Debugger022 and others added 12 commits July 16, 2026 18:27
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
@Debugger022 Debugger022 changed the title [VPD-1598] Hashdit Audit Mitigations for BstockLiquidator [VPD-1598] BStockLiquidator: Liquid Mesh hop-1 source, VAI debt support, and HashDit audit mitigations Jul 17, 2026
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.

3 participants