Repository navigation
Conversation
… accounts
Enumeration: positions live in v0-market-vault (the persistent shared
vault contract), not the market contract itself -- that's why v0-4 ->
v0-8 never needed a position migration. get-nr() = total registered
accounts (1,334), lookup(id) gives {account, mask, ...} for each.
Pricing architecture, confirmed by reading resolve-pyth/resolve-callcode
directly rather than guessed: only 3 real Pyth Lazer feeds exist in the
whole protocol (u1 sBTC, u7 USDC, u45 STX) -- every other asset (the 7
v0-vault-* zToken/share tokens, stSTX, stBTC) derives its price from one
of these via a callcode multiplier (ztoken index, ststx ratio, stbtc
ratio), not an independent feed. USDH prices via a separate DIA oracle
call -- its "ident" field is a Clarity-serialized string ("USDh/USD"),
not a Pyth hash.
Liquidation eligibility read directly from liquidate()'s own health
check, not inferred: current-ltv = debt-usd * 10000 / collateral-usd,
liquidatable when current-ltv >= LTV-LIQ-PARTIAL (the egroup's own
threshold for that exact position mask).
Real bugs found and fixed while building and testing this against live
data, not just syntax-checked:
- cvToValue(cv, true)'s envelope behavior is genuinely inconsistent: a
value bare at a function's own top-level return type (e.g. get-nr's
plain uint, lookup's plain tuple) decodes with no {type,value}
envelope; a value recovered from inside an unwrapped response/optional
keeps one. Several decode sites assumed one pattern or the other
without checking -- normalized with a single uw() helper after
confirming the actual shape of every call site empirically, not by
further guessing.
- STSTX-RATIO-DECIMALS was assumed to be 1e8 by analogy with STBTC's own
ratio scale -- wrong, it's 1e6, caught because the resulting stSTX
price came out ~84x too low ($0.0046 instead of ~$0.46). Re-read the
real constant directly rather than keep guessing from the wrong one.
- A full 1,334-account scan hit a bare ETIMEDOUT partway through -- a
transient network read timeout, not a rate limit, which threw directly
out of fetch() and bypassed the JSON-parse-step retry entirely,
killing the whole scan. Retry now wraps the fetch() call itself too.
Also made the main loop resilient to a single account failing after
retries are exhausted (logged and skipped, not fatal to the whole run).
Run against the complete live dataset, not a sample: all 1,334 accounts,
1,039 correctly skipped (no debt), 295 fully evaluated with real
collateral/debt/price/LTV computation, 0 failures, 0 liquidatable right
now -- a plausible healthy state, not an untested claim.
One real, external blocker, not a code issue: this account's Pyth Lazer
plan grants feeds 1 (sBTC) and 7 (USDC) but not 45 (STX) -- confirmed by
testing each feed individually ("Not entitled: feed 45"). STX is the
base price for wSTX debt and everything that derives from it (stSTX,
every zstx/zststx/zststxbtc vault share), so a fully live scan needs
that grant before this can be trusted for a real decision.
STX_PRICE_USD8_FALLBACK is an explicit, loudly-warned escape hatch for
testing everything else in the meantime -- every real run above used it
and says so in its own output.
Scope: this scans and flags candidates only -- it does not liquidate
anything. Exact liquidation sizing (how much debt to repay, the real
bonus) uses Zest's own curve-based calc-liquidation-params, which this
does not replicate; the receiver's own min-collateral-expected/
min-profit checks are the real safety net for sizing precision, same as
every other receiver built this session. Wiring a found candidate into
zest-v2-liquidation-receiver's set-target/set-price-feed/flash-loan
execution flow is a separate next step.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The keeper bot needs both the fixed receiver (collateral-ft/redeem, this branch) and the real position scanner (v0-8-market enumeration + exact on-chain liquidation formula, zest-position-scanner). Neither PR has merged to main yet, so combining them here on a dedicated branch rather than on either feature branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires scan-zest-positions.mjs's verified candidates into zest-v2-liquidation-receiver's set-target/set-price-feed/flash-loan execution flow (the piece item 4 of this session's roadmap was reaching for, after the collateral-ft/redeem fix landed first). scan-zest-positions.mjs: exported its pricing/eligibility internals (ASSET_BY_ID, DEBT_OFFSET, resolveAllPrices, getNr, lookup, maskHasDebt, evaluateAccount) and guarded main() behind an entry-point check, so the keeper can reuse the scanner's own verified formula directly instead of re-deriving it -- the two scripts can never silently disagree about who's liquidatable. Also added collateralDetail (mirroring the existing debtDetail) since the keeper needs a per-asset collateral breakdown, not just the aggregate USD total the scanner already printed. keeper-zest-liquidations.mjs (new): maps each candidate's largest-USD collateral/debt asset pair to one of the receiver's 5 modes via a "<collateral-id>:<debt-id>" table (collateral is always the vault-share id, 1 or 3 -- matches the fix just made to the receiver itself), flags any unsupported pair instead of guessing, sizes the flash loan (exact for the two no-swap modes 4/5; a conservative USD-value estimate -- not a reverse quote solve -- for the swap modes 1-3, same floor-estimate philosophy the scanner's own header already documents), and sequences set-target -> set-price-feed (fetching a fresh signed Lazer update in "evm"/hex format, covering every price source the account's full collateral+debt stack actually touches, not just the targeted pair -- liquidate() reprices the whole account to verify LTV) -> flash-loan. Dry-run by default, same EXECUTE=true + DEPLOYER_MNEMONIC gate as the ALEX/Bitflow monitors; broadcast/nonce-sequencing pattern copied from the already-reviewed set-min-alex-out fix (PR #99). Verified without a live LAZER_TOKEN (not available in this environment): clarinet-independent syntax check, the scanner's export/main-guard via a live module import, and pickMode/sizeLoan's logic via synthetic candidates cross-checked against the live ASSET_TABLE's real collateral/debt flags. The evm-format Lazer response shape (JsonBinaryData { encoding, data }) and the sbtc-core flash-loan signature were both confirmed against the SDK's own type definitions / the cached deployed source, not assumed from the receiver's header comment alone. Not yet runnable end-to-end: zest-v2-liquidation-receiver isn't deployed (PR #98, open), the live scan currently finds 0 liquidatable accounts, and modes 3/4 need Lazer feed 45 (STX), which the active Demo plan doesn't grant (tracked separately, Starter upgrade deferred -- see project memory). All logged loudly by the script itself, not silently worked around. Built on a new branch merging zest-liquidation-receiver-v0-8 (PR #98) and zest-position-scanner (PR #101) since the keeper needs both and neither has merged to main yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The scanner's STX price was blocked on Lazer feed 45, which this account's Demo plan doesn't grant (confirmed live: "Not entitled: feed 45"), forcing a manual STX_PRICE_USD8_FALLBACK escape hatch for any real use. Checked whether a cheaper Lazer tier exists instead of paying for Starter ($500/mo): it doesn't -- Pyth Core (their free pull-oracle, Hermes) dropped Stacks support entirely on 2026-08-26, so Lazer is now the only Pyth product live on Stacks at all, and Lazer's own Free tier has no API access regardless. But this scanner never submits anything on-chain -- it only estimates risk off-chain -- so it never needed Lazer's entitled STX feed in the first place. Zest's own v0-8-market already reads a second independent oracle (DIA) for USDH; confirmed live that DIA also carries an independent STX/USD feed (currently $0.3947, same USD x 1e8 scale as USDh/USD), free and with no entitlement gate. Swapped fetchLazerFeeds([45]) for a new fetchDiaStx(), removed the STX_PRICE_USD8_FALLBACK hatch entirely (no longer needed), and updated the header's pricing architecture section to match. This does NOT help the real on-chain liquidate() submission (modes 3/4 of zest-v2-liquidation-receiver) -- Zest's own contract hardcodes verification against a genuine signed Lazer buffer for that call, so there's no substitute there regardless of cost. Only this scanner's off-chain estimation needed an alternative, and now has one for free. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pulls in PR #101's fix (price STX via DIA instead of Pyth Lazer's gated feed 45) so the keeper bot's resolveAllPrices() call gets a real STX price without needing the Lazer Starter plan upgrade. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Update: merged in PR #101's fix — the scanner's STX price now comes from DIA oracle (free, no entitlement gate) instead of Pyth Lazer's feed 45, which this account's Demo plan doesn't grant. Checked whether a cheaper Lazer tier exists first: it doesn't — Pyth Core (the free pull-oracle) dropped Stacks support entirely on 2026-08-26, so Lazer is now the only Pyth product on Stacks at all. Full reasoning on #101. This changes this PR's "not yet runnable" story: It does not change the execution-side blocker: modes 3/4 still need a genuine signed Lazer buffer for the real |
External review (unixwhisperer, PR #98) found: flashstack-stx-core.flash-loan has no caller restriction at all -- anyone can trigger it for any approved receiver, with any amount, once set-target/set-price-feed are visible on-chain. Combined with a keeper sending those as separate transactions, a third party could fire the loan themselves. The sBTC->STX swap's old minimum-output floor was computed in sats (the input token's units) instead of microSTX (what Velar's amt-out-min actually checks) -- effectively zero slippage protection. And the final balance check only verified the core got repaid, not that the liquidation was profitable -- letting a manipulated swap or an already-unswept balance mask a loss-making execution. Same outcome as F-10, a different route. Verified each claim independently before fixing, same discipline as the rest of this session: - Read flashstack-stx-core's deployed flash-loan: confirmed zero caller-identity checks. - Read Velar's deployed univ2-router: confirmed amt-out-min is checked against the real output amount in the output token's own units. Fix, porting the bitflow-arb-receiver-v5 pattern: - armed-amount: set alongside the target via set-target's new 4th argument, asserted equal to the real flash-loan amount in both callbacks (ERR-AMOUNT-MISMATCH otherwise), reset after use. - min-stx-out: owner-set in the correct units, required nonzero for the swap modes, reset after use. - min-profit-stx / min-profit-sbtc: the final check now captures this transaction's own starting balance first and requires the ending balance to exceed it by fee + min-profit, independent of any stale leftover balance already sitting in the contract. Second, independent finding while wiring the keeper bot's live Velar quote (verified directly against chain state, not assumed): VELAR-POOL-SBTC-STX (u70) is NOT the sBTC/STX pool -- it's an unrelated wstx/meme-coin pool. Scanned all 87 Velar pools: the only wstx-paired pool with "sbtc" in its name (id 60) trades a different sBTC contract than the one this receiver actually redeems from Zest's vault. There is no known Velar route for the real sBTC this receiver holds. Modes 1-3 (every mode needing that swap) are now blocked at set-target (ERR-NO-SWAP-ROUTE) and defended again at execute-stx-flash, rather than left reachable against a route that can't work. Their swap-logic scaffold (now with the fixes above applied) is left in place for whenever a real route is found. The same wrong u70 assumption is also hardcoded in contracts/velar-sbtc-arb-receiver.clar and contracts/hk-sbtc-real-receiver-v1.clar -- flagged separately, not fixed here. Only modes 4/5 (no swap, no DEX dependency) are live now. Keeper bot and scanner updated to match: MODE_MAP only proposes 4/5, set-target's call includes the new amount argument, sizeLoan() simplified now that it's always exact. clarinet check clean (226 contracts), 15/15 tests passing (one new test for ERR-NO-SWAP-ROUTE, others updated for the new set-target signature and tuple field ordering). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
External review (unixwhisperer) found: flashstack-stx-core.flash-loan has no caller restriction at all -- anyone can trigger it for any approved receiver, with any amount, once set-target/set-price-feed are visible on-chain. The sBTC->STX swap's old minimum-output floor was computed in sats (the input token's units) instead of microSTX (what Velar's amt-out-min actually checks) -- effectively zero slippage protection. And the final balance check only verified the core got repaid, not that the liquidation was profitable. Same outcome as F-10, a different route. Verified each claim independently before fixing: read flashstack-stx-core's deployed flash-loan (confirmed zero caller-identity checks) and Velar's deployed univ2-router (confirmed amt-out-min is checked in the output token's own units). Fix, porting the bitflow-arb-receiver-v5 pattern: - armed-amount: set alongside the target via set-target's new 4th argument, asserted equal to the real flash-loan amount in both callbacks (ERR-AMOUNT-MISMATCH otherwise), reset after use. - min-stx-out: owner-set in the correct units, required nonzero for the swap modes, reset after use. - min-profit-stx / min-profit-sbtc: the final check now captures this transaction's own starting balance first and requires the ending balance to exceed it by fee + min-profit, independent of any stale leftover balance already sitting in the contract. Second, independent finding while wiring up the keeper bot's live Velar quote (PR #102), verified directly against chain state: VELAR-POOL-SBTC-STX (u70) is NOT the sBTC/STX pool -- it's an unrelated wstx/meme-coin pool. Scanned all 87 Velar pools: the only wstx-paired pool with "sbtc" in its name (id 60) trades a different sBTC contract than the one this receiver actually redeems from Zest's vault. There is no known Velar route for the real sBTC this receiver holds. Modes 1-3 are now blocked at set-target (ERR-NO-SWAP-ROUTE) and defended again at execute-stx-flash, rather than left reachable against a route that can't work. Their swap-logic scaffold (now with the fixes above applied) is left in place for whenever a real route is found. The same wrong u70 assumption is also hardcoded in contracts/velar-sbtc-arb-receiver.clar and contracts/hk-sbtc-real-receiver-v1.clar -- flagged separately, not fixed here. Only modes 4/5 (no swap, no DEX dependency) are live now. clarinet check clean (226 contracts), 15/15 tests passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The liquidation keeper bot (PR #102) needs this to decode Velar pool reads the same way the scanner already decodes Zest's own contract calls -- was a plain top-level function, not exported. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Update: merged in the security fix from #98 (armed-amount/min-stx-out/min-profit checks, full writeup there) plus the mode 1-3 disabling (Velar pool-id finding). This keeper now only ever proposes modes 4/5 -- `MODE_MAP` dropped the 1/2/3 entries, `set-target`'s call includes the new `amount` argument, `sizeLoan()` simplified since both remaining modes are exact (no-swap). Also pulled in `uw`'s export from #101 (needed it transiently while investigating the Velar pool, no longer used now that modes 1-3 are disabled, but harmless to keep exported). |
…watches The ALEX and Bitflow arb monitors ran live for 48+ hours straight and found zero opportunities on either pool -- low conviction, no further reason to keep watching them ahead of this. Redirecting that attention to the wedge that's actually validated: Zest liquidations. Refactored main()'s single scan pass into scan() (now returns the count of actionable candidates found), with a new WATCH=true loop mode on top -- same shape as monitor-alex-arb.mjs/monitor-bitflow-arb.mjs (interval loop, a single bad scan logged and retried rather than crashing the watch). Actionable candidates print a grep-able "*** LIQUIDATION OPPORTUNITY ***" marker, matching those bots' "*** ARB OPPORTUNITY ***" convention, so a Monitor watch can grep this script's stdout the same way. Not started live from this session -- needs a real LAZER_TOKEN, which isn't available here. Matt starts it with his own token, same as every other live script this session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Added a `WATCH=true` loop mode, replacing the ALEX/Bitflow arb monitors as the thing actually being watched -- those ran 48+ hours straight with zero opportunities on either pool, low conviction to keep going. This keeper now supports: ``` Same shape as the arb monitors (interval loop, a bad scan logged and retried rather than crashing the watch), with a grep-able `*** LIQUIDATION OPPORTUNITY ***` marker so a notification watch can be set up on it the same way. Not started live from this session -- needs a real `LAZER_TOKEN`, not available here. |
Caught live: the watch loop hung completely for 37+ minutes on a single stuck call -- 0% CPU, no open socket, zero log output, stuck on an await that was never going to resolve or reject on its own. fetch() has no default timeout, and nothing here was bounding it. Wrapped the call in an AbortController, 20s. Verified in isolation against a deliberately unroutable address (the exact hang shape, not a refused connection) that this actually throws a catchable AbortError instead of hanging forever -- the existing retry logic below already handles that case correctly, it just never got the chance to before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Primary fix: readOnly()'s fetch() call had no timeout at all. Caught live -- a long-running watch loop (PR #102's keeper) hung completely for 37+ minutes on a single stuck call: 0% CPU, no open socket, zero log output, stuck on an await that was never going to resolve or reject on its own. Wrapped the call in an AbortController, 20s. Verified in isolation against a deliberately unroutable address (the exact hang shape, not a refused connection) that this actually throws a catchable AbortError instead of hanging forever -- the existing retry logic already handles that case correctly, it just never got the chance to before. Also pulls in the rest of this file's `export` surface from the keeper branch (BPS, DEBT_OFFSET, ASSET_TABLE, ASSET_BY_ID, readOnly, resolveAllPrices, getNr, lookup, maskHasDebt, maskAssetIds, getCollateral, getDebtScaled, getCachedIndex, getEgroup, evaluateAccount) -- only `uw` had been synced back here before. No behavior change, keeps this branch as the single source of truth for the file both #101 and #102 depend on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Caught live: a Lazer feed 1/7 timeout killed an entire WATCH-mode keeper process outright, seconds after restart. fetchLazerPrices() called process.exit(1) on that failure -- appropriate for the scanner's own one-shot script (nothing to retry, exit loudly), but process.exit() isn't a catchable error, so it bypassed the keeper's own loop try/catch entirely instead of letting it log and retry on the next cycle like any other transient failure. Now throws instead. The scanner's own one-shot main() still exits the same way via its existing top-level .catch(...) -> process.exit(1) wrapper -- unchanged behavior there. The missing-LAZER_TOKEN case stays a hard process.exit(1): that one's a permanent config error, not transient, so hard-exiting immediately is correct in both contexts. Also fixed a stale URL in the same error message (pythdata.app -> app.pyth.com, corrected earlier this session but missed here). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Caught live: a Lazer feed 1/7 timeout killed an entire WATCH-mode keeper process outright (PR #102), seconds after restart. fetchLazerPrices() called process.exit(1) on that failure -- appropriate for this script's own one-shot main() (nothing to retry, exit loudly), but process.exit() isn't a catchable error, so it bypassed the keeper's loop try/catch entirely instead of letting it log and retry like any other transient failure. Now throws instead. This script's own one-shot main() still exits the same way via its existing top-level .catch(...) -> process.exit(1) wrapper -- unchanged behavior there. The missing-LAZER_TOKEN case stays a hard process.exit(1): a permanent config error, not transient, so hard-exiting immediately is correct in both contexts. Also fixed a stale URL in the same error message (pythdata.app -> app.pyth.com). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Caught live: feeds 1/7 (sBTC/USDC) -- previously confirmed working -- timed out twice in a row on a fresh restart, with nothing but a generic "timed out waiting for Lazer feeds" message either time. Looked at why: fetchLazerFeeds muted the SDK's own logger entirely (including error/warn) and the WebSocket pool's onError callback, so whatever the REAL cause is (auth rejection, DNS, TLS, a protocol-level issue) had no way to surface -- a connection that fails outright and one that just never responds look identical from the caller's side. error/warn now print (debug/info stay muted, too noisy), onError prints the real error instead of discarding it, and PythLazerClient.create() itself gets an explicit .catch() in addition to the existing unhandledRejection safety net. Diagnostic-only change -- doesn't fix whatever the underlying issue actually is, just stops hiding it so the next failure (live, via the keeper's watch loop) is actually informative instead of generic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
unixwhisperer
left a comment
There was a problem hiding this comment.
The interface matches #98's final receiver: set-target takes 4 arguments, in the same order and with the same types, and the contract on this branch is identical to 272cdb2. Sizing with the full debt is fine, since Zest clamps it to max-debt-usd.
Four things before this runs with EXECUTE=true:
- Fetch the feed before arming.
set-targetis broadcast (L224-229) beforefetchLazerEvmBuffer(L243). Mode 4 needs feed 45, which the header (L44-47, L329-331) says isn't granted, yet the keeper still tries it. So every watch cycle pays forset-target, the fetch times out, and the receiver is left armed with nothing behind it. The same happens on any feed failure. Fetch first, broadcast after, and skip mode 4 until feed 45 is available. - Nonces.
executeLiquidationre-reads the confirmed nonce for every candidate (L212). A second candidate in the same pass collides with the first one's pending transactions,broadcastOnethrows (L203), and the rest of the pass is abandoned. Usepossible_next_noncefrom/extended/v1/address/{addr}/nonces, or carry the nonce forward locally. - Minimum profit. The keeper never calls
set-min-profit-stx/-sbtc, so the floor stays at the contract default ofu1. Each attempt costs about 0.4 STX in fees (50k + 50k + 300k), so a liquidation earning less than that still executes, at a net loss. Set the floor to gas plus a margin on each attempt. - Hardening:
- Add an Allow-mode post-condition on
flash-loanthat the signer sends 0 STX/sBTC (L259). - Rename
DEPLOYER_MNEMONICtoKEEPER_MNEMONIC, matching the ownership handoff we agreed on #98, and assertsigner == get-ownerbefore broadcasting. - Port #101's Lazer diagnostics and
.catchfix intofetchLazerEvmBuffer(L157-164), which still hides errors.
- Add an Allow-mode post-condition on
No secrets are logged. Dependency Audit is red only because of the known #94 issue.
Summary
Wires
scan-zest-positions.mjs's verified candidates intozest-v2-liquidation-receiver'sset-target/set-price-feed/flash-loanexecution flow -- the "mode-mapping and execution-wiring" piece of the Zest keeper bot.Depends on two other open PRs, neither merged yet:
zest-v2-liquidation-receivermigrated tov0-8-market, plus the critical collateral-ft/redeem fix (vault shares, not raw underlying)scan-zest-positions.mjs, the realv0-8-marketposition scannerThis branch merges both (clean, zero conflicts -- verified with
git merge-treebefore merging) since the keeper needs both pieces and neither has landed onmainyet.What's in here
scan-zest-positions.mjs: exported its pricing/eligibility internals (ASSET_BY_ID,DEBT_OFFSET,resolveAllPrices,getNr,lookup,maskHasDebt,evaluateAccount) and guardedmain()behind an entry-point check, so the keeper reuses the scanner's own verified formula directly -- the two scripts can never silently disagree about who's liquidatable. AddedcollateralDetail(mirroring the existingdebtDetail) since the keeper needs a per-asset collateral breakdown, not just the aggregate USD total.keeper-zest-liquidations.mjs(new):"<collateral-id>:<debt-id>"table. Collateral is always the vault-share asset id (1 or 3), matching PR fix(zest): migrate zest-v2-liquidation-receiver to v0-8-market #98's fix. Any unsupported pair is flagged and skipped, never guessed at.set-target->set-price-feed(fetching a fresh signed Lazer update in"evm"/hex format, covering every price source the account's entire collateral+debt stack touches --liquidate()reprices the whole account to verify LTV, not just the targeted pair) ->flash-loan.EXECUTE=true+DEPLOYER_MNEMONICgate as the ALEX/Bitflow monitors. Broadcast/nonce-sequencing copied from the already-reviewedset-min-alex-outfix (PR fix(alex-monitor): set min-alex-out before every live execution attempt #99).Verified without a live LAZER_TOKEN (not available in this environment)
node --checksyntaxpickMode/sizeLoanlogic against synthetic candidates, cross-checked against the liveASSET_TABLE's realcollateral/debtflags for every asset id used in the mode-map keys"evm"response shape (JsonBinaryData { encoding, data }) against the SDK's own.d.ts, not assumed -- added an explicitjsonBinaryEncoding: "hex"since the default wasn't documented andCl.bufferFromHexneeds hex, not base64flashstack-sbtc-core.flash-loan's real signature ((amount uint) (receiver <sbtc-flash-receiver-trait>)) against its cached deployed source, not the receiver's header comment aloneNot yet runnable end-to-end
zest-v2-liquidation-receiverisn't deployed yet (PR fix(zest): migrate zest-v2-liquidation-receiver to v0-8-market #98)All of the above is logged loudly by the script itself when it runs, not silently worked around.
Test plan
node --check scripts/keeper-zest-liquidations.mjs-- cleanpickMode/sizeLoanunit-tested against synthetic candidates (modes 1/4/5 + an unsupported pair), all correctLAZER_TOKENin this environment) or a real broadcast (receiver not deployed, no mnemonic provided here by design)🤖 Generated with Claude Code