Skip to content

feat(llm): Yearn auction and nested-vault context, verified bodies, leaner prompts - #427

Merged
spalen0 merged 15 commits into
mainfrom
feat/llm-auction-context
Oct 20, 2026
Merged

spalen0 merged 15 commits into
mainfrom
feat/llm-auction-context

Conversation

@spalen0

@spalen0 spalen0 commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Two Strategist Safe alerts were checked against outside reviews and on-chain state:

  • Nonce 3372 (Grove auction floors, gist). The report:
    • said setMinimumAuctionPrice left the auction's pricing untouched; it sets Auction.minimumPrice;
    • described a 284k GROVE lot; the auction still held 698,582 GROVE unsold, so the lot was ~983k;
    • couldn't say where the 879.76 USDS of profit came from.
  • Nonce 3373 (DebtAllocator rebalance, gist). The amounts were right, but the report had no vault context. It missed that yvWETH-2's looper exposure goes from 70.71 to 294.67 WETH (4.7% → 19.7%), and that what ordinary withdrawals can reach falls from 571.30 to 301.30 WETH (38.2% → 20.1%), because the looper is outside the default withdraw queue and has no DebtAllocator config.

Both gists were corrected by hand, pinned to their alert blocks, and keep their original LOW tag to match the Telegram alerts. Both transactions have since executed. 3372's actual lots (1,176,467 and 162,021 GROVE) match the model: the leftover plus rewards claimed by execution time.

What changed

Evidence the LLM sees

  • Source context (utils/source_context.py):
    • Short function bodies (≤30 lines) are shown after the natspec. The prompt says the body wins when the two disagree, and that natspec describing what other code enforces is unverified unless that code is shown.
    • The overload is picked from the decoded signature, and contexts are deduplicated per signature. When the overload is ambiguous, no body is shown.
    • Fixed a natspec regex that let one comment block swallow every function declared before the target.
  • Simulation (ai_explainer.py):
    • Each event names the contract that emitted it, and emitters are labelled and linked.
    • Transfer events that restate the listed token transfers are dropped.
  • Observed vs forecast: the Protocol Context preamble and the system prompt now separate values read on-chain (verified) from the adapters' forecasts (amounts moved, lots, after-batch liquidity, exposure, ratios). Where the simulation differs, the simulation wins.

Yearn Auction adapter (new, yearn_auction_context.py)

Runs for strategies that have auction() and for direct Auction calls.

  • What it reports:
    • pricing;
    • per-lot state: live, or unsold from an expired lot;
    • strategy-held and claimable rewards, and minAmountToSell;
    • idle want, stated without guessing where it came from;
    • floor vs DeFiLlama market price;
    • for each kick: the lot, its proceeds at the floor and at market, and the time to reach the floor.
  • Trust rules: a strategy setter counts as an auction setter only when its verified body forwards the argument, and a report() only "kicks" when the verified harvest shows it.
  • Batch replay: every call across all auctions replays in transaction order, and each strategy's balances and thresholds are shared across its auctions (setAuction A → B → A). It also covers:
    • lot liveness under the running settings (a lower floor can revive a decayed lot; setters revert while a lot is live);
    • disable / enable, sweeps, and the minAmountToSell gate;
    • out-of-range setter values described as reverting;
    • disable(address,uint256) read correctly.

Yearn V3 adapter (yearn_v3_context.py, yearn_v3_batch.py)

  • DebtAllocator.update_debt(vault, strategy, target) is read as the vault's own call, with the allocator's maxDebtUpdateLoss.

  • Nested vaults: every vault is walked in one global batch order, and flows are applied to the inner vault too:

    • deposits land in idle, or go through auto_allocate within the vault's deposit limits;
    • redemptions are served from idle, then the default queue in order.

    A parent's redemption limit is refreshed from the inner vault's liquidity, keeping owner-specific restrictions. When a deposit makes that limit unknowable, the model stops forecasting from that point instead of guessing.

  • New lines:

    • exit liquidity before → after;
    • look-through exposure, with the held vault's off-queue holdings as an explicit bucket;
    • DebtAllocator config vs the debt ratio after the batch;
    • a flag for deposits into strategies outside the default queue.
  • The prompt rates a sharp exit-liquidity cut, or funds that only a manual update_debt can reach, at least MEDIUM.

Smaller prompts

Facts shared across calls are stated once per batch: V3 and auction mechanics, the report() transfer note, and role explanations. Rendered sizes, before → after:

Adapter Fixture Before → after
Permission grants 10-removal batch prompt 9,164 → 2,501; report 17,304 → 3,468
Pendle upgrades two-entry fixture 7,119 → 3,820 (each call keeps its own line and initialization payload)
Infinifi farms per-pair row; 3 pairs 999 → 555; 2,999 → 2,313
Yearn strategies 2× report() 1,780 → 1,213 (settings shown only when the calls touch them)

Telemetry

  • Each LLM call records input, cached input, cache writes, output and latency (LLMUsage). The explainer keeps one StageUsage per stage (summary, critique-N = pass / revised / empty, detail) and logs one line per alert.
  • resolve_protocol_context skips an adapter that fails while rendering, as it already did for one that fails while resolving.

Verification

  • Pinned eval: each batch was run against an anvil fork at its alert block, with Tenderly at that block and historical prices, using Venice deepseek-v4-flash, the factory default. Results:
    • 3373 is correct and rated MEDIUM in every run;
    • 3372 is rated LOW in 3 of 4 samples, the same as the baseline, after fixing a loss-framed sentence that had pushed it to 3 of 4 MEDIUM.
  • Cost: about $0.003 and 48s per alert; the detail stage takes 69% of the latency, and every critique passed in round 1.
  • Live checks: adapter output on nonce 3372 matched the Tenderly AuctionKicked lot within 0.04%, and on 3373 the yvWETH-1 queue walk matched the simulation (OG WETH 659.20 → 389.20 → 659.20).
  • Tests: two external review rounds' findings (11, then 4, then 2) are fixed, each with a regression test that fails on the previous code. uv run pytest tests/ passes; the 4 TestSafeApiQuota tests fail only locally, when a real SAFE_API_KEY is in .env, and the same on main. prek run --all-files passes.

Not in this PR

  • Larger eval set: a broader set of pinned historical cases (high-risk and missing-data), scored for factual errors and missed risks separately from the risk tag.
  • Prompt experiments: conditional system-prompt blocks, and aligning stage prompts so the shared context caches across stages. Both should be measured on that set.

🤖 Generated with Claude Code

spalen0 and others added 5 commits October 19, 2026 15:03
A Safe batch that lowered both Grove compounders' auction floor and ran
report() was explained as leaving the auction's pricing untouched, as
kicking a 284k GROVE lot, and without saying what the 880 USDS of profit
was. In fact setMinimumAuctionPrice also sets Auction.minimumPrice, the
auction held 698k GROVE unsold from an expired lot (so the lot was 983k),
and the USDS was proceeds of a lot that had sold out.

- source_context: show short function bodies next to the natspec, and stop
  a /** */ block from swallowing every earlier function in the file
- ai_explainer: name the emitter of each simulated event; the prompt
  trusts a shown body over stale natspec; repeated per-contract
  explanations are written once
- yearn_auction_context: new adapter for strategies with auction() and for
  Auction calls: pricing, pending lots, kick size and floor proceeds, floor
  vs DeFiLlama market price, idle proceeds; setters are taken from the
  strategy's verified body, never from the name alone
- yearn_strategy_context: the report() transfer note is said once
- Safe reports carry the Safe, nonce and safeTxHash (linked) in the header

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… vaults

Strategist Safe nonce 3373 moved 270 WETH of yvWETH-2 out of yvWETH-1 into
the Spark looper and yvWETH-1's 270 WETH out of the looper back into Yearn
OG WETH, all through DebtAllocator.update_debt. The V3 adapter keyed on
calls made on the vault, so the report had no vault context, and it called
the move a shift "from indirect to direct exposure" — yvWETH-2's looper
exposure went from 70.53 to 294.60 WETH, all of it outside the default
withdrawal queue.

- DebtAllocator.update_debt(vault, strategy, target) is read as the vault's
  update_debt, and the allocator's maxDebtUpdateLoss is stated
- every vault is walked in one global batch order; a deposit into or a
  redemption from a vault the batch also manages is applied to that vault
  (idle, then default queue in order, capped by what each strategy can
  return), so its later calls start from the right state
- a deposit into a strategy outside the default queue is flagged
- a vault holding another vault gets look-through exposure before → after

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oves

An outside review of Strategist Safe nonce 3373 rated it MEDIUM: moving
270 WETH of yvWETH-2 into a strategy outside its default queue cut what
ordinary withdrawals can reach from 571.29 to 301.29 WETH (38.2% → 20.1%),
and the DebtAllocator has no config for that pair, so automation ignores
it. Our report said LOW and mentioned neither.

- exit liquidity (idle + what each queue strategy can return now) before →
  after whenever a vault's debt moves
- each allocator-moved pair's getStrategyConfig against the debt ratio the
  batch leaves, using totalAssets after any redemption
- a deposit's redeemable amount is credited back in the batch model
- the prompt rates a sharp exit-liquidity cut, or funds reachable only by
  manual update_debt, at least MEDIUM

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VaultBatchState.receive() raised the first queue strategy's current_debt
when an inner vault auto-allocates a parent vault's deposit, but left
max_withdraw and max_deposit at their pre-batch values, unlike _deposit().
The inner vault's exit liquidity then capped that strategy at the stale
limit, and a later redemption in the same batch reported part of itself as
untraceable. Both paths now book the deposit through StrategyDebt.credit().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Source context
- select the overload the call decodes to (parameter types against the ABI
  signature) for the snippet and the body, and dedupe contexts by
  signature; omit the body when no definition or several differ — the
  Auction's take(address) body was shown for take(address,uint256,address,bytes)

Yearn V3 batch model
- an inner vault's auto_allocate runs _update_debt(first queue strategy,
  max) with every deposit limit and existing idle, instead of crediting the
  whole deposit to the strategy
- a parent's redemption limit on an inner vault is refreshed from the inner
  vault's running exit liquidity before each call
- look-through exposure keeps a held vault's idle and its holdings outside
  the default queue and the batch as explicit buckets, with a note, instead
  of dropping them

Yearn Auction
- lots carry through the batch: liveness is recomputed from Auction._price
  under the running settings (a lower floor can revive a decayed lot, and a
  setter while a lot is live reverts on 1.0.5), setMinAmountToSell gates
  later reports, sweeps empty the lot, kicks reset it
- kick timing uses the settings in force at that call
- setAuction routes the strategy's later calls to the new auction
- direct Auction calls take their subject from the first argument
  (disable(address,uint256) appends an index)
- idle want is stated without asserting it is sale proceeds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@spalen0
spalen0 marked this pull request as ready for review October 19, 2026 19:01
spalen0 and others added 8 commits October 19, 2026 21:10
…ering

- V3: refreshing a parent's redemption limit from the inner vault's
  liquidity discarded owner-specific limits (a withdraw_limit_module, or an
  observed maxRedeem of 0). A pre-batch limit that debt and liquidity do
  not explain, or any limit on a vault with a module, is kept as a cap.
- Auction: a strategy's held and claimable balances carry across auction
  contexts in call order, so kickAuction → setAuction → kickAuction no
  longer sells the same tokens twice.
- Auction: disable deletes the lot's state as on-chain, so a live lot stops
  blocking setters and a later kick reverts (not enabled); enable of an
  enabled token reverts.
- Auction: out-of-range setter values on 1.0.5 are described as reverting
  and leave settings unchanged; timing math guards zero/100% decay.
- protocol_context: an adapter that fails while rendering is skipped like
  one that fails while resolving, instead of escaping the alert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The auction-replacement fix carried held and claimable balances but not the
strategy's kick thresholds, so a setMinAmountToSell before setAuction was
lost and the new auction gated its kicks on the pre-batch threshold.
minAmountToSell belongs to the strategy, so it now carries with the
balances.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… caps

- Auction: a strategy's held, claimable and minAmountToSell are one shared
  state per token across its auctions, and every auction call is replayed
  in transaction order (walk_auctions). Replaying each auction's calls as a
  block gave A → B → A's rewards to A instead of B.
- V3: only a pre-batch redemption limit below min(position, liquidity) is
  an owner restriction. A withdraw_limit_module alone no longer creates a
  cap — an empty position reads 0 under any module, and after depositing
  the parent could not redeem. A deposit into a vault with a real
  restriction drops the cap and states the limit as not modelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ark forecasts

- Yearn V3 and Auction semantics are stated once per Protocol Context
  section instead of once per vault or auction; each keeps its own numbers.
- Simulation results drop Transfer events when token transfers are listed
  (they restate them), leaving the ten-event budget for DebtUpdated,
  UpdatedMinimumPrice and the like.
- The Protocol Context preamble and the system prompt separate on-chain
  observations (verified) from the adapters' forecasts (amounts moved, lots,
  after-batch liquidity, exposure, ratios): where the simulation differs,
  the simulation wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sale loss

Stating "the gap is the most a sale can give away" once at the top of the
auction context tipped a routine floor change (Safe nonce 3372, pinned to
its alert block) to MEDIUM in 3 of 4 samples, against 1 of 4 before. The
mechanics now say how a Dutch auction clears: takers buy near market as the
price falls, the floor only matters for an unbid lot, and a floor below
market is what lets lots clear. Back to 1 of 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Providers record each call's LLMUsage (input incl. cached, cached input,
cache writes, output, latency): Venice reports prefix-cache hits in
prompt_tokens_details.cached_tokens, Anthropic reports reads and writes
separately and they are folded into the same fields. The explainer keeps a
StageUsage per call on Explanation.stages — summary, critique-N
(pass/revised, with the revised TLDR) and detail — and logs one line per
alert, so cost, cache hits and critique rounds can be measured on real
alerts instead of estimated from prompt sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rendered sizes, before → after:
- Permission grants: scope and members once per (target, allowlist), then
  ordered changes. A 10-removal batch: prompt 9,164 → 2,501 chars, report
  17,304 → 3,468.
- Pendle upgrades: integration reference, proxy state and each
  implementation's source once; every upgrade call keeps its own line and
  now its initialization payload (coalescing keys on it). Two-entry
  fixture: 7,119 → 3,820.
- Infinifi farms: minimum-output semantics once, a compact row per pair.
  Pair row 999 → 555; three pairs 2,999 → 2,313 (one pair +202).
- Yearn strategies: settings shown only when the calls change or depend on
  them; role explanations shared once. Configuration fixture 2,168 →
  2,040; two report() calls 1,780 → 1,213.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… critiques apart

- V3: once a deposit invalidates a vault's owner-specific withdraw limit on
  an inner vault, a later redemption from it was still credited in full,
  and every later call built on that guess (a 10 WETH limit forecast as a
  250 WETH redemption). The redemption is now described as "would withdraw
  up to X" and not applied; both vaults' later calls and their after-batch
  liquidity, exposure and ratios are marked not forecast.
- A blank critique response keeps the draft as before but is recorded as
  `empty`, not `pass`, so the stage telemetry tells a missing review from an
  explicit PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@spalen0 spalen0 changed the title feat(llm): Yearn auction context, function bodies and event emitters feat(llm): Yearn auction and nested-vault context, verified bodies, leaner prompts Oct 19, 2026
spalen0 and others added 2 commits October 19, 2026 23:08
The Safe, nonce and safeTxHash rows only repeated what the Telegram alert
already carries (Safe address, Safe URL, nonce) and added plumbing
(report_metadata, ReportContext.metadata) used by nothing else. They never
reached the LLM prompt and made no Safe API calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a frozen vault moves

- Auction: a setter that Auction 1.0.5 rejects while a lot is live was
  described as reverting but still written into the running settings, so
  later kicks used a floor or liveness that never landed. It now leaves the
  settings unchanged and runs no revival check.
- V3: after an unknowable redemption freezes a vault, its later update_debt
  into another vault the batch reports left that vault's liquidity,
  exposure and ratios forecast without the move. That vault is now frozen
  too, with a line saying the amount is not forecast.
- Pendle: upgrades are rendered per proxy, each with its own owner, current
  implementation and source. Only the published PendleSwap proxy resolves
  today, so this guards an assumption rather than a reachable case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@spalen0
spalen0 merged commit 5ccb90e into main Oct 20, 2026
3 checks passed
@spalen0
spalen0 deleted the feat/llm-auction-context branch October 20, 2026 10:03
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.

1 participant