feat(vault-v2-reallocation): migrate vault v2 reallocation bot - #166
feat(vault-v2-reallocation): migrate vault v2 reallocation bot#166haydenshively wants to merge 11 commits into
Conversation
d5e7bb6 to
03a8fa7
Compare
💡 Codex Reviewmorpho-bots/bots/vault-v2-reallocation/scripts/deploy-railway.ts Lines 49 to 52 in 03a8fa7 When required configuration is absent—and throughout the other expected Railway CLI failure paths—this script throws plain AGENTS.md reference: AGENTS.md:L43-L50 morpho-bots/bots/vault-v2-reallocation/scripts/deploy-railway.ts Lines 49 to 52 in 03a8fa7 This newly added deployment script declares AGENTS.md reference: AGENTS.md:L51-L52 morpho-bots/packages/bot-kit/src/policy.ts Line 130 in 03a8fa7 The newly added AGENTS.md reference: AGENTS.md:L41-L42 morpho-bots/bots/vault-v2-reallocation/src/math.ts Lines 150 to 152 in 03a8fa7 When an adapter or collateral allocation is already above its buffered cap—because interest accrued or a curator reduced the cap— morpho-bots/bots/vault-v2-reallocation/src/math.ts Lines 48 to 50 in 03a8fa7 When a market on the supported original morpho-bots/bots/vault-v2-reallocation/src/index.ts Lines 220 to 223 in 03a8fa7 When startup fails during an RPC-backed vault check, a viem transport error’s full AGENTS.md reference: AGENTS.md:L48-L50 morpho-bots/bots/vault-v2-reallocation/scripts/deploy-railway.ts Lines 272 to 275 in 03a8fa7 When operators follow the documented migration posture and supply morpho-bots/bots/vault-v2-reallocation/src/strategies/reconcile.ts Lines 136 to 139 in 03a8fa7 When idle parking is disabled and deallocations from multiple collateral groups exceed the allocation total, this market-order trim can retain a deallocation from collateral A while dropping the collateral B deallocation whose credit made the planned B allocation pass sizing. The emission pass then clamps the B allocation to zero but still returns and submits the retained deallocation, parking assets idle despite morpho-bots/bots/vault-v2-reallocation/src/strategies/reconcile.ts Lines 122 to 123 in 03a8fa7 When the only move with ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
03a8fa7 to
f9ed458
Compare
cashd
left a comment
There was a problem hiding this comment.
approved for OSS as a reference
cashd
left a comment
There was a problem hiding this comment.
/simplify + review pass on this one. 30-ish threads below, roughly by weight — a few real ones up top (relative-cap rounding, 3 vacuous tests, README flag described backwards), then rule hits, reuse of stuff we already have in blue-sdk / @repo/utils, per-tick RPC waste, and simplifications. Anything tagged "low pri" / "not for this PR" / "fyi" is optional; I'll ticket the cross-bot dedupe and the workflow release-job copy separately.
Suggestions are inline where the drop-in was clean.
| const bufferedAbsolute = MathLib.wMulDown(cap.absolute, capBufferWad) | ||
| const absoluteHeadroom = bufferedAbsolute > basis ? bufferedAbsolute - basis : 0n | ||
| if (cap.relative === MathLib.WAD) return absoluteHeadroom | ||
| const bufferedRelative = MathLib.wMulDown(MathLib.wMulUp(totalAssets, cap.relative), capBufferWad) |
There was a problem hiding this comment.
The contract rounds this down (firstTotalAssets.mulDivDown(relativeCap, WAD) in VaultV2), so wMulUp can put our ceiling 1 wei above the contract's. The 99.99% buffer hides it in prod, but the tests call this with capBufferWad = WAD where the mismatch is real.
| const bufferedRelative = MathLib.wMulDown(MathLib.wMulUp(totalAssets, cap.relative), capBufferWad) | |
| const bufferedRelative = MathLib.wMulDown(MathLib.wMulDown(totalAssets, cap.relative), capBufferWad) |
| rateAtTarget: RATE_AT_TARGET | ||
| }) | ||
| const result = strategy(makeVaultData([badDebtMarket, coldMarket])) | ||
| if (result) { |
There was a problem hiding this comment.
This passes vacuously if the strategy ever returns undefined here — every expect is inside the if. Same at L248. Both produce a plan today (I checked), but that's incidental.
Swap to expect(result).toBeDefined() and assert on result!.
| expect(allocation.assets).toBeGreaterThan(0n) | ||
| }) | ||
|
|
||
| it('folds idle assets into the target-utilization denominator', () => { |
There was a problem hiding this comment.
Both assertions here are toBeUndefined(), so it passes identically whether idle is folded or not. Worth a hot+cold pair asserting the allocation grows by exactly min(surplus, idle) between idleAssets: 0n and a large idle.
| AdaptiveCurveIRM curve; allocations top up from idle (`ALLOW_IDLE_REALLOCATION`). Fires only | ||
| past `MIN_APY_DELTA_BIPS`. |
There was a problem hiding this comment.
This is backwards vs the code — allowIdleParking gates whether deallocation surplus may park in idle (reconcile.ts:119); allocations always draw on idle. An operator flipping it to stop idle deployment gets nothing.
| AdaptiveCurveIRM curve; allocations top up from idle (`ALLOW_IDLE_REALLOCATION`). Fires only | |
| past `MIN_APY_DELTA_BIPS`. | |
| AdaptiveCurveIRM curve; deallocation surplus parks in idle only when `ALLOW_IDLE_REALLOCATION=true` | |
| (allocations always draw on idle). Fires only | |
| past `MIN_APY_DELTA_BIPS`. |
| const bound = | ||
| utilization > upperBound ? upperBound : utilization < lowerBound ? lowerBound : undefined |
There was a problem hiding this comment.
nested ternary — repo rule is none. Early returns read cleaner:
| const bound = | |
| utilization > upperBound ? upperBound : utilization < lowerBound ? lowerBound : undefined | |
| const boundFor = (u: bigint): bigint | undefined => { | |
| if (u > upperBound) return upperBound | |
| if (u < lowerBound) return lowerBound | |
| return undefined | |
| } | |
| const bound = boundFor(utilization) |
| export const getCapHeadroom = ( | ||
| cap: CapState, | ||
| basis: bigint, | ||
| totalAssets: bigint, | ||
| capBufferWad: bigint |
There was a problem hiding this comment.
low pri: 4 positional params here, getDepositableAmount, intEnv, resolveMinApyDeltaBips — conventions say >3 → trailing object. Churn for little value rn, up to you.
| @@ -0,0 +1,217 @@ | |||
| import type { LogLevel } from '@repo/bot-kit' | |||
There was a problem hiding this comment.
Zooming out: strategy-config.ts, build.ts, both script *.error.ts, invalid-config.error.ts, tsconfig.json are byte-identical to vault-v1; this file and deploy-railway.ts differ only in strings; the env parsers here are the 5th copy repo-wide. Drift is already visible (v2's zero-target guard isn't in v1).
Not for this PR — I'll file a ticket for env parsers → bot-kit and a shared reallocation core.
| gh release create "$tag" --target "$SHA" --title "$tag" --generate-notes \ | ||
| ${prev:+--notes-start-tag "$prev"} | ||
|
|
||
| Release-vault-v2-realloc: |
There was a problem hiding this comment.
6th verbatim copy of this job. Not for this PR, but the tag step belongs in deploy-bot.yml behind stage == 'production' so adding a bot is one case line. Will ticket.
| @@ -0,0 +1,164 @@ | |||
| # vault-v2-reallocation | |||
There was a problem hiding this comment.
The v1 TIB explicitly defers V2 "as a separate decision", and this PR also changes bot-kit's documented outer-envelope-only signer guarantee (multicall inner-call inspection). I think it needs a TIB-2026-08-17-vault-v2-reallocation-bot.md covering: the multicall policy design, delta legs, both-sides-required firing, 3-level cap pools, one-adapter assumption.
| const adapter = marketAdapters[0]! | ||
| const adapterAddress = getAddress(adapter.address) | ||
|
|
||
| const adapterMarkets = normalizeAdapterMarkets(adapter, timestamp) |
There was a problem hiding this comment.
fyi only: vaultV2.accrueInterest(timestamp) at L234 re-runs position/market.accrueInterest for every market via adapter.realAssets (blue-sdk 6.4 adapters), so each market is accrued twice per vault. CPU-only, sub-ms — no change needed, just so it's known.
4a124fc to
603ce33
Compare
Optional declarative Policy.multicall: calldata must decode as a non-empty multicall(bytes[]) whose inner selectors are allowed and whose inner calls' first address argument is registered for the outer target. Policy remains data-driven default-deny logic — no bot-supplied code runs inside the guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RPC-only block-pinned vault data via blue-sdk fetchAccrualVaultV2 (+ per-id cap/allocation reads), both strategies as delta-emitting closures with three-level cap pools, multicall encoding, simulation, dep-injected tick, and the bot-kit-wired entrypoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ations All live VaultV2s use the MorphoMarketV1AdapterV2 contract; both generations take identical allocate/deallocate calldata and cap ids, so vault-data normalizes the two SDK adapter shapes into one market list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopt bot-kit simulateCall (local simulate helper dropped), accrue snapshots to the pinned block's timestamp, exclude non-AdaptiveCurve markets from apy-range (a zero rateAtTarget degenerates the inversion and would drain the position on sim-passing calldata), and clamp equalize's target utilization at 100% for bad-debt states. The V1 role-gate widening is deliberately NOT mirrored: VaultV2.allocate requires isAllocator strictly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cap math corrected against the VaultV2 contract: relativeCap == WAD honored as the no-constraint sentinel (a fully-deployed vault previously zeroed its adapter pool and silently no-opped forever); headroom measured from the accrued position the adapter trues allocation(id) up to, with aggregate pools carrying every market's accrual drift; the plan's own deallocations (executed first) credit capacity back to the pools. Equalize now clamps allocations to deallocations + idle (allocate pulls from vault balance). Strategies restructured deallocate-first; legs carry the market id for log correlation; the tick surfaces a mid-run adapter swap as adapter.changed instead of opaque policy violations; policy evaluation deny-wraps any throw from the multicall check and gains a dirty-upper-bits padding test; stale V1 wording and README claims corrected. Verified live: a fully-deployed prod vault that previously produced no plan now emits an exactly-funded one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pass capBufferWad converted once at strategy construction, dead MarketState share fields dropped, reallocation.dry_run payload slimmed to the vault (the plan is already in reallocation.found). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…econciler Mirrors the v1 framing: a strategy answers, per market, where it should sit and whether the move clears its min-delta threshold; one reconciler owns all mechanics — sizing, three-level cap pools with the dealloc-first credit rule as one explicit step, idle-balance netting (alloc ≤ dealloc + idle; dealloc excess parks or clamps per allowIdleParking), the contributing-markets gate, budget trim in market order, and delta-leg emission. Every pre-existing strategy test passes unmodified; 8 reconciler-direct tests added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Typed RailwayDeploymentError replaces plain Errors in the deploy script with untrusted CLI stderr retained only as cause (setSecret stays detail-free); deploy helpers become arrow constants with viem key validation; compose forwards every documented runtime knob; intEnv rejects unsafe integers; the reconciler's min-delta gate is evaluated on the TRIMMED legs so a fully trimmed-out clearing market cannot authorize a plan of sub-threshold legs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilization A WAD target — an APY bound past the curve max on a decayed-rateAtTarget cold market, or a bad-debt aggregate at/above 100% — sizes a deallocation to the market's entire free liquidity: exact to the snapshot, unrealizable one accrual later, a sim-passes-then-reverts loop. The reconciler now clamps every classifier target to MAX_TARGET_UTILIZATION (99.9%, ~10 bips of borrow-side accrual margin), replacing equalize's WAD clamp; feasibility is the reconciler's job, not the classifiers'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mandatory bot-kit consumers: Policy.targets rename and boolean queue.submit (submitted counted only on real broadcasts, reallocation.not_broadcast on refusal). Read client opts into batched JSON-RPC for the per-cap-id fan-out. Whitelist dedupes case-variant addresses; vaults process concurrently via allSettled with per-vault counter folding; the role read runs alongside the fetch. SDK-first math (SECONDS_PER_YEAR, wholePercentToWAD, zeroFloorSub), wadToBips dedupes the bips idiom, CAP_BUFFER_WAD lives in math, isIdle and the foreign-IRM id list ride the snapshot. Startup checks extracted to vault-checks.ts and the time gate to interval-gate.ts, both unit-tested; strategy-config tables get shape validation; stale narration pruned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
603ce33 to
f96bc80
Compare
Stacked on #165. Migrates the standalone vault-v2-reallocation-bot into the monorepo as
bots/vault-v2-reallocation, the sibling ofbots/vault-v1-reallocation, on the same flat bots-as-programs shape and@repo/bot-kitruntime.What's here
fetchAccrualVaultV2(factory-validated — a free per-tick V2 identity check) plus per-idabsoluteCap/relativeCap/allocationreads the SDK fetcher doesn't cover for regular adapters. The old bot's graphql-request + codegen SDK +.gqlfiles and its checked-in 1754-line ABI are gone; zero new external dependencies (blue-sdk 6.5.0 was already pinned for the V1 bot).equalize-utilizations— the default, what prod ran — andapy-range) as config-injected closures emitting exact-amount deltas; idle is first-class (denominator folding / idle top-up), executed as ONEvault.multicall([deallocate…, allocate…]).Policy.multicall { innerSelectors, innerTargetsByOuter }field —evaluatePolicyitself decodes the bundle and requires every inner call to be allocate/deallocate to the adapter registered for the targeted vault. No bot-supplied code runs inside the guard, so the outer-envelope trust boundary is unchanged (hence no TIB; this is the V2 analogue of feat(vault-v1-reallocation): migrate vault v1 reallocation bot #165's target-list widening). Tested incl. smuggled-selector, cross-vault-adapter, empty-bundle, and malformed-bytes denials.allocation(id)the contract enforces — NOT accrued position assets, which drift above it (visible in live data: allocation 120000000000 vs accrued 120001419437 on Hyperithm Apex). A binding aggregate cap shrinks the plan instead of looping sim-reverts.MorphoMarketV1AdapterV2contract, not the original adapter the old bot's code nominally modeled —vault-data.tsnormalizes both SDK shapes (identical calldata + cap ids). Fail-loud typed error unless a vault has exactly one Morpho Blue market adapter.tick.endcounters, operator surface + CI wiring (vault-v2-reallocin deploy-bot/staging/production + CalVer release job).All three whitelisted addresses in the old repo's production config (mainnet
0xBEEF0173…,0x8eB67A50…; base0xbeeF010f…) are MetaMorpho V1 vaults, not VaultV2s (verified on-chain:withdrawQueueLengthanswers,adaptersLengthreverts, the V2 factory disowns them) — the old V2 bot cannot ever have reallocated anything in production. Deploy-time whitelists must be chosen fresh; the README documents this and this bot fails loud at startup on any non-V2 entry.Deliberately dropped from the old repo
GraphQL API layer, checked-in VaultV2 ABI,
capsIds.ts(SDK helpers are byte-identical), hand-rolled WAD/IRM math (blue-sdkMathLib/AdaptiveCurveIrmLib/SharesMath), multi-chain single process,setIntervalruntime,Promise.allsends, console logging, the dead apy-range override tables (their keys never matched the whitelist), and the anvil fork suites (the oldvaultSetup.tsV2 timelock harness is the seed for a future fork suite; noted in README).Verification
pnpm lint/pnpm format/pnpm knip/ typecheck (bot + bot-kit): clean. Rootpnpm test: all pass except the 4 pre-existing fork/e2e suites needingRPC_URL_8453env files. 63 new bot tests + 7 new bot-kit policy tests; break-one-assertion verified per repo rule; quoter-botjsdoc:buildgreen.DRY_RUN=true) against Hyperithm USDC Apex (~$8.5M, 20 markets): startup identity/adapter/policy wiring,allocator.missing_roleskip path, interval gate, cleantick.endcounters and SIGINT. A separate live probe exercisedfetchVaultV2Data→ strategy → (no move found) with real caps/allocations.startup.errorfactory rejection; V2 vault with an unsupported adapter shape → typedInvalidVaultError.docker buildnot verified locally (daemon not running) — the Dockerfile is the V1 bot's byte-for-byte with paths adjusted; Railway builds server-side.Follow-ups
Shared with the V1 bot: fork test suite, pure-bigint bips gate cleanup, possible extraction of the shared strategy math into a
@repo/*package once both bots merge. Railway provisioning + GitHub Environments (vault-v2-realloc-{staging,production}) are deploy-time operator steps.🤖 Generated with Claude Code
Update: restacked on #165's review-fix commit (61980bc)
Mirrored the applicable V1 review fixes in
fix(vault-v2-reallocation): mirror v1 review fixes: bot-kit's newsimulateCallreplaces the local simulate helper; snapshots accrue to the pinned block's timestamp instead of wall clock; apy-range hard-excludes non-AdaptiveCurve markets (a zerorateAtTargetdegenerates the curve inversion into 'far below range' and would drain the position on simulation-passing calldata — same hazard the V1 review found), surfaced per tick asmarket.non_adaptive_curve; equalize clamps its target utilization at 100% for bad-debt states. Deliberately NOT mirrored: the V1 allocator|curator|owner role-gate widening —VaultV2.allocaterequiresisAllocator[msg.sender]strictly (vaults-v2 source, ALLOCATOR FUNCTIONS), so curator/owner keys do not qualify on V2.Update: dual-model review pass (Opus + GPT 5.6 Sol) — 3 must-fix correctness defects found and fixed
Commit
fix(vault-v2-reallocation): apply dual-model review findings:relativeCap == WADis the contract's no-constraint sentinel (allocateInternalskips the relative check) — the bot treated it as a binding 100% ceiling, zeroing the adapter pool on any fully-deployed vault → silent permanent no-op. Confirmed live: Hyperithm USDC Apex produced no plan pre-fix and an exactly-funded 9-leg plan post-fix. The old test fixtures (allocation: 0aggregates) masked the production shape; new tests pin it.allocation(id)up toexpectedSupplyAssetson every touch, socap − storedAllocationoverstates headroom by the drift), with aggregate pools carrying every market's drift and the plan's own deallocations credited back (they execute first) — a full adapter cap no longer blocks valid rebalances.allocatepulls from vault balance; unclamped plans were guaranteed revert-loops on real shapes, e.g. a small adapter position in a big cold market).Plus should-fixes/nits from the same pass and a peer-session audit: zero-target division guards (dust borrow / sub-curve APY bounds), contribution-gated min-delta triggers,
classifyMarket/apyDeltaBipshelpers de-duplicating the two passes, throw-safe multicall policy evaluation + dirty-padding test in bot-kit,adapter.changedtick detection for mid-run adapter swaps, market ids onreallocation.foundlegs, multicall-batched cap reads + parallelgetBlock,isAddressEqualconventions, stale V1 wording, and honest README language for the both-sides leg gate (see below).Known behavior kept for old-bot fidelity (deliberate, flagged for product review): a plan fires only when both sides have ≥1 leg, so pure idle deployment (fresh deposits, every market at/above target) never fires — same gate as the old V2 bot, but in V1 idle was a market so the gate had different reach. Documented in the README as a follow-up decision.
Verification: 303 tests green across vault-v2-reallocation/bot-kit/vault-v1-reallocation/workspace; break-one-assertion on the WAD sentinel, idle clamp, zero-target guards, and pool crediting; live probe on a $8.5M 20-market prod vault validating
alloc ≤ dealloc + idleand plan encoding.