Skip to content

feat(vault-v1-reallocation): migrate vault v1 reallocation bot - #165

Open
haydenshively wants to merge 14 commits into
mainfrom
feat/blue-reallocation-bot
Open

feat(vault-v1-reallocation): migrate vault v1 reallocation bot#165
haydenshively wants to merge 14 commits into
mainfrom
feat/blue-reallocation-bot

Conversation

@haydenshively

@haydenshively haydenshively commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Migrates the standalone morpho-blue-reallocation-bot (Vault V1 / MetaMorpho reallocation) into the monorepo as bots/vault-v1-reallocation, modeled on the liquidation bots' flat bots-as-programs shape and the shared @repo/bot-kit runtime.

What's here

  • Strategy core ported faithfully (~all 53 original unit cases pass unchanged in behavior): apy-range (per-market borrow-APY ranges via the AdaptiveCurveIRM inverse, idle-market absorption) and equalize-utilizations (vault-wide average utilization), rewritten from classes to config-injected closures. Local WAD/IRM math replaced with @morpho-org/blue-sdk's MathLib / AdaptiveCurveIrmLib (SDK-first; equivalence verified numerically — the only intentional delta is clamping >100% utilization to the curve's max rate, matching the old code).
  • bot-kit runtime: block-watcher runner with per-block queue maintenance and a REALLOCATION_INTERVAL_MS time gate on the reallocation pass (resolves the old repo's seconds-vs-minutes interval bug by construction); signer with default-deny policy (targets = vault whitelist, selector = reallocate, computed from the ABI); pending queue with fee bumps/nonce reconciliation; balance + heartbeat monitors; structured JSON-lines events with a wide tick.end counters line.
  • One shared-package change (first commit): Policy.executor widened to Address | readonly Address[] with a membership check — this bot signs to N whitelisted vaults. Existing single-address consumers compile unchanged; empty list denies everything (tested).
  • Safety posture (from adversarial plan review): per-tick allocator-role re-check (missing role = loud skip, auto-resume on grant, no restart); fatal startup V1-surface identity read per whitelisted vault (the policy authorizes these as tx targets); block-pinned vault snapshots; DRY_RUN mode (full live read→strategy→encode→simulate, logs the plan, never submits) as the operator ramp-up story — compose and the Railway deploy default new services to dry-run.
  • Operator surface: README (documents that whitelisting is production-active under the 3–8% defaults, the AdaptiveCurveIRM assumption, and the bot-owns-allocations concurrency posture), Dockerfile, compose (mainnet + Base), deploy-railway.ts, CI wiring (vault-v1-realloc in deploy-bot/staging/production + CalVer release job).
  • Strategy overrides ship as an empty checked-in template (src/strategy-config.ts, market > vault > env-default precedence) — the old repo's stale per-vault entries were deliberately not carried over.

Deliberately dropped from the old repo

apps/config package, script.ts, local MorphoBlue/AdaptiveCurveIrm ABIs, multi-chain-in-one-process, Promise.all vault fan-out, bare estimateGas+writeContract tx path, console logging, and the anvil fork suites (follow-up; @morpho-org/test + vault-factory harness deliberately deferred — DRY_RUN against a live RPC is the current end-to-end check).

Also fixes two latent old-repo bugs: the equalize min-delta override was looked up by market id against a vault-keyed table (never matched), and an empty vault whitelist silently no-oped (now fails loud).

TIB decision

docs/decisions/TIB-2026-08-17-vault-v1-reallocation-bot.md — records the migration onto the flat bots-as-programs shape, SDK-first math, the Policy.executor target-set widening, template-not-verbatim strategy tables, deferred fork tests with DRY_RUN as the ramp-up story, and the enforced AdaptiveCurveIRM-only assumption. (Originally shipped without a TIB as a routine runtime extension; the review pass below surfaced enough load-bearing decisions to warrant one.)

Verification

  • pnpm lint / pnpm format / typecheck / pnpm knip: clean.
  • pnpm test: all pass except the pre-existing fork/e2e suites requiring RPC_URL_8453 in .env.test.local (fail identically on main in this checkout). 71 tests for this bot + widened bot-kit policy tests + 4 new simulateCall cases; the blue-liquidation and midnight-liquidation unit projects (239 tests) pass too, proving the shared-simulate refactor is behavior-neutral. Test-breakage verified per repo rule, including the new AdaptiveCurveIRM / idle-cap / zero-leg regression tests.
  • Live smoke on Base (public RPC, throwaway key, DRY_RUN=true): startup vault validation, allocator.missing_role skip path, per-block maintenance, clean tick.end counters, clean SIGINT. Separate live probe exercised fetchVaultData → strategy on a real vault's 3-market queue.
  • docker build not verified locally (daemon not running) — the Dockerfile is blue-liquidation's byte-for-byte with paths adjusted; Railway builds server-side.

Notes for review

  • Human review (cashd, 20 threads incl. 2 blocking) executed across ea1a907 + a92e9eb + 22befcd: reconciler targets clamped at 99.9% utilization (a WAD bound on cold markets sized withdrawals to full free liquidity and reverted on accrual); bot-kit Policy.targets: readonly Address[] (union dropped, check id target), queue.submit returns whether it broadcast, opt-in JSON-RPC-batched transport (this bot opts in); vaults now run concurrently with a snapshot-derived allocator|owner|curator check, deduped whitelist, SDK-first math adoptions, and vault-checks/interval-gate extraction with tests. All threads replied and resolved except the staging-provisioning question (answered, left open).
  • Renamed from blue-reallocation after the initial migration: the bot manages Blue markets x Vault V1 (MetaMorpho), and vault-v1-reallocation pairs with the upcoming Vault V2 bot migration. The git branch name keeps its original feat/blue-reallocation-bot (a ref rename is cosmetic); the GitHub Environments for CI deploys must be created as vault-v1-realloc-staging / vault-v1-realloc-production when Railway is provisioned.
  • Adding @morpho-org/blue-sdk/blue-sdk-viem to the catalog re-resolved quoter-bot's transitive blue-sdk from 6.4.0 → 6.5.0 (lockfile); single viem + single blue-sdk instance confirmed (test/viem-dedupe.test.ts green).
  • Strategies are now per-market target-utilization classifiers ("what utilization should this market sit at, and does the move clear the min-delta threshold?") composed over a single per-bot reconciler (src/strategies/reconcile.ts) that owns all mechanics: clamped sizing, idle netting, the firing gate, budget trimming in withdraw-queue order, and leg building. Strategy/createStrategy are unchanged, and the existing strategy tests pass unmodified as the behavioral-equivalence proof.
  • The ApyRange bips gate keeps the old float-mixing idiom (Math.abs(Number(apyDelta / 1e9)) / 1e5) for fidelity — a pure-bigint cleanup is a cheap follow-up.
  • A two-model review pass (Opus + GPT 5.6 Sol) has been applied on top of the migration:
    • AdaptiveCurveIRM enforcement (fund-safety) — a market off the canonical IRM has no rateAtTarget, and rateAtTarget = 0n makes the curve inverse return WAD for every rate, collapsing both APY bounds so the market always read "below range" and apy-range would withdraw the vault's entire position out of it on sim-passing calldata. Such markets are now classified in vault-data.ts against getChainAddresses(chainId).adaptiveCurveIrm (plus a non-zero rateAtTarget) and excluded from both legs; equalize-utilizations is utilization-only and keeps them.
    • Idle-cap clampidleCap - vaultAssets underflowed to a negative bigint when a curator dropped the cap below the current allocation, corrupting the plan; now clamped and cap-buffered like every other deposit target.
    • Equalize zero-leg guards — a no-op market no longer emits a dust leg, and a second market can no longer receive assets: maxUint256 once the deposit budget is spent.
    • Role gate widened to allocator || curator || owner, matching MetaMorpho's onlyAllocatorRole (a curator- or owner-keyed EOA was skipped forever).
    • Div-by-zero and >WAD target guards — a zero utilization target now yields zero depositable instead of throwing, and the equalize target is clamped at WAD so bad-debt states don't size withdrawals past available liquidity.
    • fetchAccrualVault fetcher — replaces the hand-rolled 1 + N + ~4N reads with one deployless SDK query; fetchVaultData's signature and the VaultData/VaultMarketData types are unchanged.
    • Shared simulateCall in bot-kit — the bot's near-duplicate of simulateLiquidationExec is gone; simulateLiquidationExec is now a thin delegating wrapper with an unchanged public API.
    • Block-timestamp accrual — interest accrues to the pinned block's own timestamp rather than Date.now(), so a snapshot is fully block-coherent and reproducible.
    • Plus nits (delta gate counts only contributing markets, deduplicated bound computation, isAddressEqual for idle detection), README fixes (correct release-vault-v1-realloc label, the vault.inflight / market.non_adaptive_curve events and full counter list, the ~100%-utilization-attracts-deposits behavior, reconciled role prose), and the TIB above.

🤖 Generated with Claude Code

haydenshively and others added 6 commits August 17, 2026 02:55
Widen Policy.executor to Address | readonly Address[] so a bot whose
transactions target a set of contracts (e.g. whitelisted vaults) can keep
the default-deny pre-broadcast guard. Single-address callers are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@haydenshively haydenshively changed the title feat(blue-reallocation): migrate vault v1 reallocation bot feat(vault-v1-reallocation): migrate vault v1 reallocation bot Aug 17, 2026
haydenshively and others added 4 commits August 17, 2026 10:35
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the dead share fields from MarketState, convert the cap buffer to a
WAD once at strategy construction, gate equalize's min-delta trigger on
actual contribution (mirroring apy-range), drop a redundant idle flag
re-check, and stop repeating the full plan in the dry-run log line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…econciler

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@haydenshively haydenshively self-assigned this Aug 17, 2026
@haydenshively
haydenshively marked this pull request as ready for review August 17, 2026 18:08
@haydenshively
haydenshively requested a review from cashd August 17, 2026 18:09

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread bots/vault-v1-reallocation/scripts/deploy-railway.ts Outdated
Comment thread bots/vault-v1-reallocation/scripts/deploy-railway.ts Outdated
Comment thread bots/vault-v1-reallocation/scripts/deploy-railway.ts Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

STRATEGY: ${STRATEGY_1:-apy-range}
REALLOCATION_INTERVAL_MS: ${REALLOCATION_INTERVAL_MS:-}

P1 Badge Forward strategy safety knobs into Compose services

When an operator sets the documented MIN_APY_DELTA_BIPS, MIN_UTILIZATION_DELTA_BIPS, ALLOW_IDLE_REALLOCATION, or MAX_FEE_GWEI before running Compose, neither service receives those variables because both environment blocks omit them. After dry-run is disabled, the bot therefore reallocates using its built-in thresholds and fee ceiling rather than the operator's requested safety policy; pass every documented runtime knob through both service definitions.


if (amount > 0n) {
didClearMinDelta ||= target.clearsMinDelta
moves.push({ marketData, side, amount })

P2 Badge Gate only on moves retained after budget trimming

When one side has more candidate liquidity than the other, a later market can have amount > 0 and clear the threshold here but be completely omitted once the earlier moves exhaust remainingDeposit or remainingWithdrawal. Its flag still arms didClearMinDelta, so the emitted transaction may contain only markets whose deltas are below the configured firing threshold, causing unintended low-value reallocations and gas spend; evaluate the gate against the legs that survive trimming.


function required(env: Env, name: string): string {
const value = env[name]
if (!value || !value.trim()) throw new Error(`Missing required env var: ${name}`)
return value.trim()

P1 Badge Replace plain Railway failures with isolated typed errors

When required configuration is missing or a Railway CLI operation fails, this new script throws plain Error here and throughout its other expected failure paths. These failures must instead use named exported subclasses, each isolated in its own kebab-case *.error.ts file, so callers and tooling can classify them consistently and operator-facing error handling remains controlled.

AGENTS.md reference: AGENTS.md:L43-L50


function required(env: Env, name: string): string {

P1 Badge Declare deployment utilities as arrow constants

This utility and the other helpers added throughout the deployment script are function declarations, contrary to the repository's mandatory arrow-utility convention. Convert them to const arrow functions so the new operator tooling follows the required declaration pattern.

AGENTS.md reference: AGENTS.md:L51-L52


const raw = env[name]?.trim()
if (!raw) return def
if (!/^\d+$/.test(raw)) {
throw new InvalidConfigError(`${name} must be a non-negative integer, got: ${env[name]}`)
}
const value = Number(raw)

P2 Badge Reject integers that overflow JavaScript numbers

When an integer environment variable contains enough digits, the regex accepts it but Number(raw) becomes Infinity or loses integer precision. In particular, an infinite REALLOCATION_INTERVAL_MS lets the first pass run and then makes every later block return from the time gate forever, while an overflowing minimum-delta value silently disables reallocations; require Number.isSafeInteger(value) before accepting the configuration so invalid inputs fail loudly.

AGENTS.md reference: AGENTS.md:L155-L156


function assertPrivateKey(key: string): void {
if (!/^0x[0-9a-fA-F]{64}$/.test(key)) {
throw new Error('REALLOCATOR_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string')

P1 Badge Validate deployment keys with viem

The deployment path validates the private key with a local hexadecimal regex even though repository policy requires viem for hex parsing, validation, and byte sizing. Use viem's hex validation and size utilities here so provisioning and runtime validation share the repository's canonical semantics instead of maintaining a second parser.

AGENTS.md reference: AGENTS.md:L53-L54

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@haydenshively

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed in e03c88e:

  • Typed errors (Devin, Codex P1): new scripts/railway-deployment.error.ts (RailwayDeploymentError, quoter-bot precedent); all 9 plain-Error sites in the deploy script converted.
  • Untrusted stderr (Devin security): stderrOf deleted; messages are now static + command/key context only, with the underlying CLI error retained as cause per the boundary-wrapper allowance. setSecret keeps its no-detail behavior.
  • Arrow constants (Devin, Codex P1): all 18 helper function declarations converted; module-level config block reordered below the helpers (TDZ verified at runtime).
  • viem key validation (Codex P1): regex replaced with isHex(key, { strict: true }) + length constant, mirroring src/config.ts.
  • Compose knobs (Codex P1): both services now forward RPC_URL_FALLBACK_<chainId>, MIN_APY_DELTA_BIPS, MIN_UTILIZATION_DELTA_BIPS, ALLOW_IDLE_REALLOCATION, MAX_FEE_GWEI; compose and the README env table now cover the same set exactly.
  • Post-trim gate (Codex P2): the reconciler arms didClearMinDelta only from moves that survive budget trimming, so a fully-trimmed-out market can no longer authorize a plan of sub-threshold legs. Regression-tested (negative case + funded positive control, break-the-fix verified).
  • Number.isSafeInteger (Codex P2): intEnv rejects overflowing digit strings with a typed InvalidConfigError.

81 bot tests green (+2), existing tests unmodified, typecheck/lint/knip clean.

@cashd cashd 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.

approved for OSS as a reference

@cashd cashd 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.

Read through the whole thing plus a pass over the shared bot-kit changes — nice port, the reconciler split and the SDK-first math read well. One real bug (the WAD withdraw target), a few efficiency things on the hot path, and a batch of nits. Numbered inline.

Deliberately not raising here, will file as follow-ups: the env-parser / Railway-helper copies (3rd/5th copy, but that's the documented per-bot convention) and the deploy/release job copies → matrix.

Comment thread bots/vault-v1-reallocation/src/strategies/apy-range.ts
Comment thread bots/vault-v1-reallocation/src/strategies/equalize-utilizations.ts
Comment thread bots/vault-v1-reallocation/src/vault-data.ts Outdated
Comment thread bots/vault-v1-reallocation/src/index.ts Outdated
Comment thread bots/vault-v1-reallocation/src/runner/tick.ts Outdated
Comment thread bots/vault-v1-reallocation/src/config.ts Outdated
Comment thread bots/vault-v1-reallocation/src/index.ts Outdated
Comment thread bots/vault-v1-reallocation/test/strategies/helpers.ts Outdated
Comment thread bots/vault-v1-reallocation/src/strategy-config.ts
Comment thread packages/bot-kit/src/policy.ts Outdated
haydenshively and others added 3 commits August 18, 2026 00:16
…zation

A WAD target sizes a withdrawal to the market's entire free liquidity,
which reverts on the first wei of accrual — and the AdaptiveCurve inverse
legitimately produces WAD bounds on cold markets. Clamp every classifier
target at 99.9% in the reconciler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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