Skip to content

feat(zest): liquidation keeper bot -- mode-mapping + execution-wiring - #102

Open
mattglory wants to merge 11 commits into
mainfrom
zest-liquidation-keeper
Open

mattglory wants to merge 11 commits into
mainfrom
zest-liquidation-keeper

Conversation

@mattglory

Copy link
Copy Markdown
Owner

Summary

Wires scan-zest-positions.mjs's verified candidates into zest-v2-liquidation-receiver's set-target/set-price-feed/flash-loan execution flow -- the "mode-mapping and execution-wiring" piece of the Zest keeper bot.

Depends on two other open PRs, neither merged yet:

This branch merges both (clean, zero conflicts -- verified with git merge-tree before merging) since the keeper needs both pieces and neither has landed on main yet.

What's in here

  • 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 reuses the scanner's own verified formula directly -- the two scripts can never silently disagree about who's liquidatable. Added collateralDetail (mirroring the existing debtDetail) since the keeper needs a per-asset collateral breakdown, not just the aggregate USD total.
  • 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 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.
    • Sizes the flash loan: exact for the two no-swap modes (4/5 -- the borrowed asset IS the debt payment, 1:1); a conservative USD-value estimate for the swap modes (1-3) -- not a reverse quote solve, same floor-estimate philosophy the scanner's own header already documents. The receiver's own balance/slippage asserts are the real backstop on a wrong guess.
    • Sequences 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.
    • Dry-run by default, same EXECUTE=true + DEPLOYER_MNEMONIC gate as the ALEX/Bitflow monitors. Broadcast/nonce-sequencing copied from the already-reviewed set-min-alex-out fix (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 --check syntax
  • Scanner's export/main-guard via a live module import (confirmed no scan side-effect on import)
  • pickMode/sizeLoan logic against synthetic candidates, cross-checked against the live ASSET_TABLE's real collateral/debt flags for every asset id used in the mode-map keys
  • The Lazer "evm" response shape (JsonBinaryData { encoding, data }) against the SDK's own .d.ts, not assumed -- added an explicit jsonBinaryEncoding: "hex" since the default wasn't documented and Cl.bufferFromHex needs hex, not base64
  • flashstack-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 alone

Not yet runnable end-to-end

  • zest-v2-liquidation-receiver isn't deployed yet (PR fix(zest): migrate zest-v2-liquidation-receiver to v0-8-market #98)
  • The live scan currently finds 0 liquidatable accounts -- nothing to act on today regardless
  • Modes 3/4 price wSTX debt via Lazer feed 45 (STX); the active Demo plan doesn't grant that feed (Starter upgrade tracked separately, deferred). Modes 1/2/5 aren't affected -- they only need feeds 1/7, which Demo does grant.

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 -- clean
  • Scanner module import confirmed side-effect-free (no scan triggered) after adding the main-guard
  • pickMode/sizeLoan unit-tested against synthetic candidates (modes 1/4/5 + an unsupported pair), all correct
  • Not tested: a live end-to-end run against real Lazer prices (no LAZER_TOKEN in this environment) or a real broadcast (receiver not deployed, no mnemonic provided here by design)

🤖 Generated with Claude Code

mattglory and others added 3 commits October 10, 2026 20:07
… 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>
@vercel

vercel Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
web Ready Ready Preview Oct 12, 2026 3:18pm UTC

Request Review

mattglory and others added 2 commits October 11, 2026 09:09
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>
@mattglory

Copy link
Copy Markdown
Owner Author

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: resolveAllPrices() (and therefore this keeper's own candidate-scanning loop) now only needs LAZER_TOKEN for sBTC/USDC (feeds 1/7, which Demo already grants) — the STX entitlement gap no longer blocks scanning at all.

It does not change the execution-side blocker: modes 3/4 still need a genuine signed Lazer buffer for the real liquidate() submission (Zest's own contract requirement, not swappable), so those two modes are still gated on the Starter plan decision. Modes 1/2/5 were never affected either way.

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>
mattglory added a commit that referenced this pull request Oct 11, 2026
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>
mattglory added a commit that referenced this pull request Oct 11, 2026
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>
@mattglory

Copy link
Copy Markdown
Owner Author

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>
@mattglory

Copy link
Copy Markdown
Owner Author

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:

```
WATCH=true LAZER_TOKEN="..." node scripts/keeper-zest-liquidations.mjs
```

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>
mattglory added a commit that referenced this pull request Oct 12, 2026
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>
mattglory added a commit that referenced this pull request Oct 12, 2026
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 unixwhisperer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

  1. Fetch the feed before arming. set-target is broadcast (L224-229) before fetchLazerEvmBuffer (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 for set-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.
  2. Nonces. executeLiquidation re-reads the confirmed nonce for every candidate (L212). A second candidate in the same pass collides with the first one's pending transactions, broadcastOne throws (L203), and the rest of the pass is abandoned. Use possible_next_nonce from /extended/v1/address/{addr}/nonces, or carry the nonce forward locally.
  3. Minimum profit. The keeper never calls set-min-profit-stx/-sbtc, so the floor stays at the contract default of u1. 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.
  4. Hardening:
    • Add an Allow-mode post-condition on flash-loan that the signer sends 0 STX/sBTC (L259).
    • Rename DEPLOYER_MNEMONIC to KEEPER_MNEMONIC, matching the ownership handoff we agreed on #98, and assert signer == get-owner before broadcasting.
    • Port #101's Lazer diagnostics and .catch fix into fetchLazerEvmBuffer (L157-164), which still hides errors.

No secrets are logged. Dependency Audit is red only because of the known #94 issue.

This branch was successfully deployed

1 active deployment
Preview — eaa70403 Deployed Oct 12, 2026 by vercel[bot]
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.

2 participants