[VPD-1589] Add Liquid Mesh as a hop-1 liquidation source#694
[VPD-1589] Add Liquid Mesh as a hop-1 liquidation source#694Debugger022 wants to merge 26 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
Adds Liquid Mesh as an additional hop-1 RFQ liquidity source for bStock liquidations, extending the existing BStockLiquidator to support “split spender” routers (where the approval target differs from the call target) and updating the off-chain liquidation tooling to select the best available quote source.
Changes:
- On-chain: introduce
routerSpender+setRouterSpenderand update_swapto approve the configured spender (defaulting to the router). - Off-chain: add a Liquid Mesh API client and a hop-1 source registry (
SOURCE=auto|native|liquidmesh|...) with firm-vs-indicative reconciliation and LM TTL guarding. - Tests/tools/docs: add unit + fork coverage for split-spender routing, plus a fork verification script and updated README/flowchart.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/hardhat/Fork/BStockLiquidatorFork.ts | Fork test covering split-router (separate spender) atomic settle via a freshly deployed proxy. |
| tests/hardhat/BStockLiquidatorLiquidMesh.ts | New unit suite validating Liquid Mesh split-spender semantics and spender hardening behavior. |
| tests/hardhat/BStockAtomicLiquidate.ts | Extends script tests to cover source registry selection, reconcile behavior, and LM TTL guard. |
| scripts/bstock/verify-lm-fork.ts | New fork-only diagnostic proving disableSimulate:true LM calldata executes on-chain. |
| scripts/bstock/README.md | Updates operational documentation for source registry, LM prerequisites, and incident flow. |
| scripts/bstock/liquidation-flowchart.svg | New decision flowchart asset referenced by the README. |
| scripts/bstock/lib/sources.ts | New hop-1 source registry (Native + Liquid Mesh) with lazy build and reconcile logic. |
| scripts/bstock/lib/liquidmesh.ts | New Liquid Mesh API client with typed responses and Ed25519 JWT auth. |
| scripts/bstock/atomic-liquidate.ts | Updates atomic liquidation script to price/select hop-1 sources via the registry and adds percent validation. |
| contracts/test/BStockLiquidationMocks.sol | Adds MockSpender / MockSplitRouter to model split-spender aggregators in tests. |
| contracts/BStock/IBStockLiquidator.sol | Extends interface with RouterSpenderSet event, SpenderNotContract error, and setRouterSpender. |
| contracts/BStock/BStockLiquidator.sol | Implements routerSpender, clears spender on de-allowlist, and approves spender in _swap. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| // Price every selected source in parallel; a source that errors (API down / no route) drops out. | ||
| const settled = await Promise.allSettled(sources.map(async s => ({ name: s.name, quote: await s.getQuote(args) }))); |
There was a problem hiding this comment.
should we have a timeout here, otherwise like comment describes if api pending it could block both sources ?
There was a problem hiding this comment.
ae6b50f
Added a shared fetchWithTimeout (AbortSignal.timeout, env SOURCE_TIMEOUT_MS, default 8s) to both clients, so a hung source aborts and drops out of the race instead of blocking the live one. Covered by BStockSourceTimeout.ts.
| deadline = hop1.deadline; // settle tx reverts on-chain past the quote's expiry | ||
| router = hop1.router; | ||
| swapCalldata = hop1.calldata; | ||
| const midOut = hop1.out; // USDT out of hop 1 |
There was a problem hiding this comment.
i understood for that native, output from quote always equal to the amt we received, is it the same case for liquidMesh ? Unsure if there is a minimumReceived or should we use hop1.out * (100% - slippage)
There was a problem hiding this comment.
Native's out is firm (MM-signed) so received == quoted. LM's /quote out is indicative, the binding number is the built order's floor, builtFloor = out * (1 - slippageBps) (sources.ts:120), so your out * (1 - slippage) instinct is right. No underfill is possible either way: the contract reverts if debtOut < minOut (BStockLiquidator.sol:459), and at default config minOut already lands on 0.995·out == LM's floor. The only caveat is that this holds because MIN_OUT_BUFFER == SLIPPAGE; if someone sets the buffer below slippage on a sole-LM run it would false-revert (fails safe, no loss). Can harden minOut to derive from builtFloor directly if we want to decouple those knobs, but nothing is at risk as-is.
There was a problem hiding this comment.
lets just keep it then.
In fact, my original thought is that the actual amount of tokens we received from hop1 could be slightly smaller than this value. This could cause the hop2 swap to fail because the amount we used in the call request is more than we can actually provide. But yeah, like u said, no harm, maybe add a comment here indicating if this fail can slightly lower this value or use builtFloor value ?
| ## Prerequisites | ||
|
|
||
| - **Native API key** (`NATIVE_API_KEY`) for anything that fetches a quote. Never commit it. | ||
| - **Native API key** (`NATIVE_API_KEY`) for anything that fetches a Native quote. Never commit it. |
There was a problem hiding this comment.
- lets confirm is the api key here (both native / lm) ok for long term use, or do we need to reply a new once
- once done, create a new page here, deploy the contract and update everything there so that the script is ready to use out of box
There was a problem hiding this comment.
Checked both vendors' docs, neither documents a key expiry or rotation policy. Liquid Mesh issues the key via support, bound to your registered Ed25519 pubkey, and says to store it in a vault (i.e. long-lived); the only short TTL is the per-request JWT (<=2s), which we already mint fresh per call and not the key.
Native's docs only cover endpoints, nothing on key lifetime. So both read as long-term keys, but since neither publishes a written SLA, we will confirm with each provider before prod.
There was a problem hiding this comment.
cool then, i forgot to attach the doc
- lets create a page here under Credentials section
- as for the format can ref other doc
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.
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.
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.
| // more than the approval — hop 2 reverts on allowance. `floor <= midDelta` always holds, so the pull | ||
| // always fits. Any surplus (`midDelta - floor`, bounded by slippage) stays as USDT inventory and the | ||
| // contract emits `PartialSwapLeftover` for it — sweepable, not lost. | ||
| const amm = await getAmmSwap( |
There was a problem hiding this comment.
for VAI, its gonna be swapStableForVAI ? But where is the code to construct the calldata ?
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.
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.
… 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.
|
@claude please review |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
scripts/bstock/atomic-liquidate.ts:291
- The seize-calculation errors still hardcode
liquidateCalculateSeizeTokensin the message, even whenisVaitakes theliquidateVAICalculateSeizeTokensbranch. That makes failures/debugging misleading for VAI liquidations.
if (!seizeErr.eq(0)) throw new Error(`liquidateCalculateSeizeTokens error ${seizeErr}`);
// A zero seize means the incentive resolved to 0 (e.g. bStock unlisted in the borrower's pool):
// surface it here rather than building a degenerate quote that reverts on-chain.
if (seizeTokens.eq(0)) throw new Error(`liquidateCalculateSeizeTokens returned 0 seize for ${borrower}`);
scripts/bstock/safe-fallback.ts:182
- The seize-calculation errors mention
liquidateCalculateSeizeTokenseven whenisVaiusesliquidateVAICalculateSeizeTokens, which makes Safe-batch generation failures confusing for VAI debt cases.
if (!seizeErr.eq(0)) throw new Error(`liquidateCalculateSeizeTokens error code ${seizeErr}`);
// A zero seize means the incentive resolved to 0 (e.g. bStock unlisted in the borrower's pool):
// surface it here rather than baking a degenerate redeem amount into the batch.
if (seizeTokens.eq(0)) throw new Error(`liquidateCalculateSeizeTokens returned 0 seize for ${borrower}`);
…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.
72bb019 to
316bbb4
Compare
Summary
Extends the atomic bStock backstop liquidator with two independent capabilities, without changing the core repay / seize / redeem /
minOutflow:swapStableForVAI), with calldata built locally inlib/psm.ts. No aggregator, no contract change.The contract gains a
routerSpendermapping 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.Contract
routerSpendermapping +setRouterSpender(owner-only,address(0)clears)._swapapproves the router's configured spender, defaulting to the router itself so Native is unchanged.__gap50 -> 49, storage-safe.setRouterSpenderis hardened against misconfiguration: revertsRouterNotAllowedunless the router is currently allowlisted, andSpenderNotContractfor a non-zero spender with no code (the spender receives a live approval during_swap, so an EOA is always a misconfig).setRouter(router, false)also clears the configured spender, so a stale entry cannot silently reactivate on re-allowlist.RouterSpenderSetevent; source-agnostic natspec.Off-chain (
scripts/bstock/)Hop-1 source registry
lib/sources.ts: a pluggable registry the driver is generic over.SOURCE=autoprices every source whose creds are present and takes the higher out;SOURCE=native,liquidmesh(or a single name) restricts the set. Adding a source is one adapter and no contract change.lib/liquidmesh.ts: the Liquid Mesh client (Ed25519 JWT signed over the full path;/quote, and/swapwithdisableSimulate: trueso calldata can be built for a contract that does not yet hold the input). Responses are fully typed andLM_PRIVATE_KEY_SEEDis validated to decode to exactly 32 bytes with a legible error.amountOut), while Liquid Mesh's/quoteis indicative and the built/swaporder only guarantees its own floor. Each quote is taggedfirm, andbuild()exposes the order'sbuiltFloor; if an indicative winner's floor undercuts the best firm quote it beat, the driver executes the firm quote instead (logged ashop-1 reconcile:).LM_MIN_TTLseconds (default 15) left at build time, or with an implausible millisecond-epoch expiry, is rejected before the settle tx is built rather than reverting on-chain mid-incident. Hop-1 source API calls time out instead of hanging.VAI debt (hop-2 via the Peg Stability Module)
swapStableForVAI(USDT -> VAI at the oracle rate), with calldata encoded locally inlib/psm.ts. The PSM is just another allowlistedrouter2; the contract is unchanged.isPaused(), or if the mint-cap headroom (vaiMintCap - vaiMinted) cannot cover the pull — rather than shipping calldata that reverts on-chain.expectedOutcomes from the realpreviewSwapStableForVAI(USDT prices under $1, so the swap is not 1:1).MODE=flashis rejected for VAI (there is no vVAI market to flash from).liquidateVAICalculateSeizeTokens(VAI is $1, borrower-agnostic core incentive), matching what the gate's VAI branch computes; error messages name the actual seize function called on each branch.safe-fallback.tssupports VAI as the normal approve ->liquidateBorrowbatch (the VAI token is resolved viagetVAIAddress()), detected before the vBNB native-debt fallback so a VAI debt is never misread as native BNB. Since it ships bStock to the CEX with no on-chain swap, it is the manual fallback exactly when the PSM hop is paused or cap-exhausted.Seize / proceeds sizing
getEffectiveLiquidationIncentive(borrower, vBStock)for every debt type, mirroringLiquidator._splitLiquidationIncentive; the resolved cut is logged (the live BSC gate keeps 50% of the bonus). For VAI the borrower is always core-pool-locked, so effective equals core there — using effective is gate-parity and future-proofing, and the genuinely divergent case (a non-core ERC20 borrower) is exercised in the fork suite.minOutis derived from the built order's guaranteed floor, not the indicative quote, so an in-slippage Liquid Mesh fill below the quote no longer revertsInsufficientOut. This matches the existing two-hop floor sizing.SEIZE_BUFFER(default 0.1%) so ordinary oracle price drift between batch generation and signer quorum leaves sweepable dust rather than reverting the redeem.Driver / guards
atomic-liquidate.tsprices all selected sources and settles through the winner.SLIPPAGEandMIN_OUT_BUFFERare validated to[0,100)(matchingSEIZE_BUFFER), so a typo fails loudly before a quote is burned.getAccountLiquidityerror slot is read and an oracle failure reported distinctly (no longer mislabelled "no shortfall"). The no-shortfall abort is gated behindALLOW_NO_SHORTFALL=1, so a forced liquidation of a healthy account is supported (the contract intentionally does not pre-check liquidatability).verify-lm-fork.ts: an on-demand fork check that Liquid MeshdisableSimulatecalldata executes on-chain (dev-time only, never part of an incident).Docs
setRouterSpenderprerequisite and enforced ordering, and the env knobs (LM_MIN_TTL,SETTLE_TTL_MARGIN,ALLOW_NO_SHORTFALL,SEIZE_BUFFER,PSM_ADDR).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 for VAI), and the three fallback triggers (no hop-1 source fills, VAI PSM paused/capped, dry-run fails).native-smokeis a Native-only probe; Liquid Mesh has no standalone liveness check and is validated inside the atomic dry-run.Tests
BStockLiquidatorLiquidMesh.ts: split-spender atomic execution (mocked) + spender-hardening cases (unallowlisted router, EOA spender, spender cleared on de-allowlist).BStockAtomicLiquidate.ts: source-registry selection (winner, forced source, badSOURCE), firm/indicative reconcile, LM min-TTL and ms-epoch expiry rejection,SLIPPAGE/MIN_OUT_BUFFERvalidation,getAccountLiquidityerror code,ALLOW_NO_SHORTFALLforced path, single-hop minOut-from-floor settle, VAI PSM hop-2 (viaMockPSM), and VAI flash-reject.BStockSafeFallback.ts:SEIZE_BUFFERhaircut + range check and the VAI debt batch shape.Fork/BStockLiquidatorFork.ts: Liquid Mesh split-spender against the real gate + market; two-hop and script-driven VAI liquidations through the real gate and real PSM; paused-PSM refusal; a forced-liquidation scenario underALLOW_NO_SHORTFALL; and effective-incentive divergence on a non-core e-mode ERC20 borrower (provesgetEffectiveLiquidationIncentive != getLiquidationIncentiveon the live diamond and that the driver sizes the cut off the effective incentive).MockSpender,MockSplitRouter,MockPSM, plus a divergent effective-vs-core incentive and agetAccountLiquidityerror slot.Test plan
npx hardhat test tests/hardhat/BStock*.ts— 106 passing.FORKED_NETWORK=bscmainnet npx hardhat test tests/hardhat/Fork/BStockLiquidatorFork.ts --grep "split router"... --grep "VAI"— two-hop and script-driven VAI settle through the real gate + real PSM; paused-PSM is refused.... --grep "effective-incentive divergence"... --grep "script-driven forced liquidation"— settles a healthy account underALLOW_NO_SHORTFALL.disableSimulateproof (fork at head, chainId 56, LM creds):npx hardhat run scripts/bstock/verify-lm-fork.ts.