diff --git a/CLAUDE.md b/CLAUDE.md index 1b1fa169..b64fe027 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,8 +160,9 @@ This is a **pnpm workspaces monorepo** housing off-chain Morpho curator bots: Each bot owns its own operator surface — `README.md`, `Dockerfile`, `docker-compose.yml`, and `scripts/deploy-railway.ts` — so it ships as its own image and deploys independently. `bots/blue-liquidation` and `bots/midnight-liquidation` are the live - liquidators; `bots/vault-v1-reallocation` reallocates liquidity across whitelisted MetaMorpho (Vault - V1) vaults' markets; `bots/quoter-bot` is the Midnight maker bot (setup checks, position + liquidators; `bots/vault-v1-reallocation` and `bots/vault-v2-reallocation` reallocate liquidity + across whitelisted MetaMorpho (Vault V1) and Morpho Vault V2 vaults' markets; + `bots/quoter-bot` is the Midnight maker bot (setup checks, position bootstrap, ladder quoting, combined monitoring); `bots/midnight-crossed-books` resolves crossed Midnight books; `bots/kill-switch` is a proposal bot (docs only). - `/packages/` — shared libraries: `@repo/bot-kit` (the shared bot runtime — viem @@ -252,6 +253,7 @@ All commit messages, PR titles, and Linear ticket titles use the same format: | midnight-crossed-books | `midnight-crossed-books` | | midnight-liquidation | `midnight-liquidation` | | vault-v1-reallocation | `vault-v1-reallocation` | +| vault-v2-reallocation | `vault-v2-reallocation` | **Scopes — Cross-cutting:** diff --git a/bots/vault-v2-reallocation/Dockerfile b/bots/vault-v2-reallocation/Dockerfile new file mode 100644 index 00000000..563bf69d --- /dev/null +++ b/bots/vault-v2-reallocation/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 +# Image for the vault-v2-reallocation bot. The build context MUST be the repo root so the workspace +# packages (packages/*) resolve — docker-compose.yml sets `context: ../..` and the Railway deploy +# runs `railway up` from the repo root. All data comes over RPC, so there is no indexer/database +# sidecar to build. Node only: pnpm installs, esbuild bundles, node runs. +FROM node:24.14.1-slim +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + +# The container's environment holds a funded allocator EOA key, so nothing here may run as root. +# Corepack's pnpm shim lands in /usr/local/bin, which only root may write, so enable it before +# dropping privileges; every layer after `USER node` is unprivileged. +RUN corepack enable pnpm +RUN mkdir -p /repo && chown node:node /repo +WORKDIR /repo +USER node + +# Manifests first, so the install layer is cached. `corepack install` pre-fetches the pnpm version +# pinned in package.json#packageManager. +COPY --chown=node:node package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +RUN corepack install + +# All members' package.json are needed for pnpm to resolve the `workspace:*` links. +COPY --chown=node:node packages ./packages +COPY --chown=node:node bots ./bots +RUN pnpm install --frozen-lockfile + +# Bundle at image-build time: workspace dists plus this bot's esbuild bundle, so the container +# starts a plain `node` with no runtime transform. +RUN pnpm -r --if-present run build + +WORKDIR /repo/bots/vault-v2-reallocation +CMD ["node", "dist/src/index.js"] diff --git a/bots/vault-v2-reallocation/README.md b/bots/vault-v2-reallocation/README.md new file mode 100644 index 00000000..5a9ee732 --- /dev/null +++ b/bots/vault-v2-reallocation/README.md @@ -0,0 +1,204 @@ +# vault-v2-reallocation + +Reallocates liquidity between the Morpho Blue markets of whitelisted Morpho Vault V2 vaults (via +each vault's MorphoMarketV1 adapter), migrated from the standalone +[vault-v2-reallocation-bot](https://github.com/morpho-org/vault-v2-reallocation-bot) repo onto the +shared `@repo/bot-kit` runtime. Sibling of [vault-v1-reallocation](../vault-v1-reallocation). + +> **Reference bot — not operated by Morpho.** Morpho is not a curator and publishes this bot as +> open source for curators to run themselves. It is not wired into this repo's CI deploy pipeline; +> the Dockerfile, compose file, and Railway deploy script below are worked examples of a +> deployment, not a managed service. + +**Whitelisting a vault is production-active**: once a vault is in `VAULT_WHITELIST` (and `DRY_RUN` +is off), the bot continuously manages its allocations under the strategy's defaults — the +vault-wide `equalize-utilizations` target unless `STRATEGY`/[src/strategy-config.ts](./src/strategy-config.ts) +say otherwise. There is no further per-vault opt-in. + +## How it works + +One long-running process per chain. A block watcher drives per-block queue maintenance +(receipt checks, fee bumps, nonce reconciliation); a wall-clock gate (`REALLOCATION_INTERVAL_MS`, +default 10 min) throttles the actual reallocation passes. Each pass, per whitelisted vault: + +1. Skip if a reallocation tx for this vault is in flight or cooling down. +2. Fetch a block-pinned snapshot in **one** `eth_call` via the deployless lens in + [src/state/lens.sol.ts](./src/state/lens.sol.ts): the VaultV2 factory identity, the EOA's + `isAllocator` bit, the idle balance, the (factory-classified) adapter set, the accrued + `totalAssets`, and per market the params, accrued Blue state, the adapter's position, + `rateAtTarget`, and the `absoluteCap`/`relativeCap`/`allocation` triple for the market, + collateral, and adapter cap ids. The lens calls `Morpho.accrueInterest` inside the simulation + before reading — and reads `totalAssets()` after, so the vault-level accrual folds in the + adapters' just-accrued real assets — meaning the numbers are exact on-chain state at that block + with no client-side accrual. One vault therefore costs **one billed RPC call per pass**, not the + ~40–70 the previous `fetchAccrualVaultV2` fan-out + cap multicall billed for a 20-market vault. + No Morpho API dependency. +3. Re-check the snapshot's `isAllocator` bit (`allocator.missing_role` + skip while absent — a + pending grant never crash-loops the bot, and a fresh grant is picked up without restart). +4. Run the strategy — a pure function of that snapshot, emitting **exact-amount deltas** + (`{allocations, deallocations}`). Legs need not balance: surplus deallocations park in the + vault's idle balance, and allocations may exceed deallocations by up to the idle balance. + Matching the original bot, a plan only fires when BOTH sides have at least one leg — pure idle + deployment (all markets above target, nothing to deallocate) does not fire; deploying fresh + idle is a deliberate follow-up decision, not an accident of this port: + - **`equalize-utilizations`** (default; what production runs): converge every market to the + vault-wide average utilization, `Σborrow / (Σsupply + idle)`. Fires only past + `MIN_UTILIZATION_DELTA_BIPS`. + - **`apy-range`**: keep each market's borrow APY inside its configured range by inverting the + 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`. +5. Encode ONE `vault.multicall([deallocate…, allocate…])` (deallocations strictly first so idle is + funded), simulate those exact bytes from the EOA, and on sim-ok submit through the pending queue + (or log `reallocation.dry_run` when `DRY_RUN=true`). + +**All three cap levels are enforced in sizing** — per-market cap ids plus the adapter-level +(`"this"`) and per-collateral cap ids. Headroom is measured from the ACCRUED position (each leg +trues `allocation(id)` up to it before the contract's cap check; the aggregate pools add every +market's accrual drift on top of the stored allocation), a relative cap of exactly WAD is honored +as the contract's no-constraint sentinel, and capacity freed by the plan's own deallocations +(executed first) is credited back. A 99.99% buffer absorbs accrual between read and mined +execution. A binding aggregate cap shrinks the plan instead of producing a sim-revert loop. + +Every strategy target is additionally clamped to a **99.9% utilization ceiling** +(`MAX_TARGET_UTILIZATION`): a target at/above 100% (a degenerate AdaptiveCurveIRM inversion on a +decayed market, or a bad-debt aggregate) would size a deallocation to the market's entire free +liquidity — exact to the snapshot and unrealizable one accrual later. The clamp changes a leg's +size, never its side: a move the clamp leaves empty or backwards is dropped, and the min-delta +gates measure the utilization each TRIMMED leg actually realizes — a clearing move cut to a +fragment by the budget or the cap pools cannot arm a plan. One corollary: a withdrawal out of a +zero-borrow market realizes no utilization change, so an empty-market exit only ships alongside a +leg that clears the threshold on its own merits. + +The signing policy is default-deny in depth: only value-0 `multicall(bytes[])` calls to whitelisted +vaults are signed, and every inner call must be `allocate`/`deallocate` targeting that vault's own +adapter (see `@repo/bot-kit`'s `Policy.multicall`). + +Assumptions and posture: + +- **Exactly one Morpho Blue market adapter per vault** (either adapter-contract generation, + `MorphoMarketV1Adapter` or `MorphoMarketV1AdapterV2` — live vaults use the latter) — startup and + every fetch fail loud otherwise. `forceDeallocate`, liquidity adapters, gates, and MetaMorpho + (VaultV1) adapters are out of scope. +- **The adapter's on-chain market list is the candidate set.** The original adapter generation + removes a market from its list when its allocation hits zero, so a fully-deallocated market + cannot be re-entered by this bot until some allocator supplies it again — size deallocations + accordingly (the strategies never fully exit a market on their own; only the vault-wide target + math can drive a position to zero). +- **AdaptiveCurveIRM only**: the `apy-range` math assumes every market uses the canonical + AdaptiveCurveIRM. +- **Relative-cap staleness**: relative headroom moves with `totalAssets` between read and mine; + the cap buffer, exact-bytes simulation, and the queue's revert audit bound the drift. +- **The bot owns allocations between curator actions**: a plan is built, simulated, and submitted + within a single pass; a queued tx re-broadcasts the same calldata on fee bumps, bounded by the + in-flight skip and settled cooldown. +- Cross-tick state is in-memory only; chain truth wins on restart. + +## Prerequisites + +- The EOA behind `REALLOCATOR_PRIVATE_KEY` must hold the **allocator role** on every whitelisted + vault. The bot only pays gas — reallocation moves vault funds, never the EOA's. +- An RPC endpoint per chain. Supported chains: mainnet (1), Base (8453) — extend `CHAIN_MAP` in + [src/config.ts](./src/config.ts). + +## Configuration + +In-container env vars (unsuffixed; the `_` suffix is an operator-side convention used by +docker-compose and the Railway deploy script): + +| Var | Required | Default | Notes | +| --------------------------------------------------------- | -------- | ----------------------- | ---------------------------------------------------- | +| `CHAIN_ID` | yes | — | 1 or 8453 | +| `RPC_URL` | yes | — | reads, simulation, sends | +| `RPC_URL_FALLBACK` | no | — | failover endpoint | +| `REALLOCATOR_PRIVATE_KEY` | yes | — | allocator EOA | +| `VAULT_WHITELIST` | yes | — | comma-separated VaultV2 addresses; must be non-empty | +| `STRATEGY` | no | `equalize-utilizations` | or `apy-range` | +| `REALLOCATION_INTERVAL_MS` | no | `600000` | min wall-clock ms between passes | +| `MIN_APY_DELTA_BIPS` | no | `25` | strategy-config overrides win | +| `MIN_UTILIZATION_DELTA_BIPS` | no | `250` | strategy-config overrides win | +| `ALLOW_IDLE_REALLOCATION` | no | `true` | apy-range only | +| `DRY_RUN` | no | `false` | plan + simulate + log, never submit | +| `MAX_FEE_GWEI` | no | `300` | policy + queue fee ceiling | +| `LOG_LEVEL` | no | `info` | debug/info/warn/error | +| `BETTERSTACK_SOURCE_TOKEN` / `BETTERSTACK_INGESTING_HOST` | no | — | both set = ship logs | +| `BETTERSTACK_HEARTBEAT_URL` | no | — | 60s heartbeat | + +Per-vault / per-market APY ranges and min-delta thresholds are **checked-in curator policy** in +[src/strategy-config.ts](./src/strategy-config.ts) (market > vault > env-default precedence). The +tables ship empty; changing policy is a reviewed PR + redeploy. + +The pre-migration production posture, for reference (set these at deploy time): mainnet +`VAULT_WHITELIST=0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB,0x8eB67A509616cd6A7c1B3c8C21D48FF57df3d458` +with `REALLOCATION_INTERVAL_MS=900000`; Base +`VAULT_WHITELIST=0xbeeF010f9cb27031ad51e3333f9aF9C6B1228183` with `REALLOCATION_INTERVAL_MS=300000`; +both `equalize-utilizations`. + +## Running + +```sh +pnpm --filter @morpho-org/vault-v2-reallocation run build +CHAIN_ID=1 RPC_URL=… REALLOCATOR_PRIVATE_KEY=0x… VAULT_WHITELIST=0x… \ + pnpm --filter @morpho-org/vault-v2-reallocation run start +``` + +Or via compose (one service per chain, both defaulting to `DRY_RUN=true`): + +```sh +cd bots/vault-v2-reallocation && docker compose up --build +``` + +### Ramp-up (recommended) + +Start any new deployment with `DRY_RUN=true`: the bot runs the full live read → strategy → +encode → simulate path and logs each would-be transaction as `reallocation.dry_run`, but never +submits. Review a few passes' plans, then flip `DRY_RUN=false`. + +## Deploy (Railway example) + +An example of a real hosted deployment, for operators who want more than compose: + +```sh +RAILWAY_PROJECT_ID=… RPC_URL_1=… VAULT_WHITELIST_1=0x… REALLOCATOR_PRIVATE_KEY=0x… \ + pnpm --filter @morpho-org/vault-v2-reallocation run deploy:railway +``` + +Provisions one `bot-` service per chain (new services start in dry-run). Re-running with +`DEPLOY_ONLY=1` re-ships already-provisioned services without touching their variables — the shape +a CI pipeline would call; this repo's CI deliberately does not, since Morpho does not operate this +bot. + +## Observability + +Structured JSON-lines on stderr, one event per line, with `bot`/`chainId` (and Railway identity) +stamped on every line. Key events: `startup`, `allocator.missing_role`, `reallocation.found` +(per-leg direction/collateral/lltv/assets), `reallocation.sim_revert`, `reallocation.dry_run`, +`reallocation.not_broadcast` (queue declined the send — e.g. nonce hole or fee ceiling), +`vault.error`, per-pass `tick.end` counters, and the shared bot-kit `tx.*` / `signer.balance` / +`block.new` events. BetterStack shipping and heartbeat are opt-in via the env vars above. + +## Testing + +```sh +pnpm vitest run --project vault-v2-reallocation +``` + +Pure unit tests cover both strategies (delta output, idle folding/top-up, three-level cap pools), +the cap/IRM math, multicall encoding (decode round-trip incl. leg ordering), config loading, +strategy-config resolution, revert decoding, lens-row shaping/validation, and a +dependency-injected tick; the multicall signing policy is tested in `@repo/bot-kit`. There is no +anvil fork suite yet (the old repo's `test/vitest/vaultSetup.ts` V2 timelock harness is the seed +for one). Two live checks stand in for it: + +```sh +# Reads the lens against a real vault at a pinned block, then re-reads the same block through the +# fetchAccrualVaultV2 + cap-multicall path it replaced and diffs every field (markets paired by id, +# in-Solidity cap ids checked against the SDK's derivations). +RPC_URL=… CHAIN_ID=8453 VAULT=0x… \ + pnpm --filter @morpho-org/vault-v2-reallocation run probe:lens +``` + +and `DRY_RUN` against a live RPC for the full read → strategy → simulate path. Known live-evidence +gap: every live vault runs `MorphoMarketV1AdapterV2`, so the lens's original-generation +(`marketParamsList`) branch has no live counterpart — it mirrors the SDK's exact call sequence and +is exercised at the TS layer only. diff --git a/bots/vault-v2-reallocation/docker-compose.yml b/bots/vault-v2-reallocation/docker-compose.yml new file mode 100644 index 00000000..686926aa --- /dev/null +++ b/bots/vault-v2-reallocation/docker-compose.yml @@ -0,0 +1,56 @@ +# Runs the per-chain vault-v2-reallocation bots. All data comes over RPC, so there is no indexer or +# database to run. The bots' build context is the repo root so the pnpm workspace (packages/*) +# resolves — see Dockerfile. Set the chainId-suffixed RPCs/whitelists (RPC_URL_1, VAULT_WHITELIST_1, +# …) and a REALLOCATOR_PRIVATE_KEY holding the allocator role on every whitelisted vault. The tuning +# knobs are chainId-suffixed too (REALLOCATION_INTERVAL_MS_1, MAX_FEE_GWEI_8453, …) so chains can differ; +# leave one unset to take the bot's own default. +services: + bot-mainnet: + build: + context: ../.. + dockerfile: bots/vault-v2-reallocation/Dockerfile + environment: + CHAIN_ID: '1' + RPC_URL: ${RPC_URL_1:?set RPC_URL_1} + RPC_URL_FALLBACK: ${RPC_URL_FALLBACK_1:-} + REALLOCATOR_PRIVATE_KEY: ${REALLOCATOR_PRIVATE_KEY:?set REALLOCATOR_PRIVATE_KEY} + VAULT_WHITELIST: ${VAULT_WHITELIST_1:?set VAULT_WHITELIST_1} + STRATEGY: ${STRATEGY_1:-equalize-utilizations} + REALLOCATION_INTERVAL_MS: ${REALLOCATION_INTERVAL_MS_1:-} + MIN_APY_DELTA_BIPS: ${MIN_APY_DELTA_BIPS_1:-} + MIN_UTILIZATION_DELTA_BIPS: ${MIN_UTILIZATION_DELTA_BIPS_1:-} + ALLOW_IDLE_REALLOCATION: ${ALLOW_IDLE_REALLOCATION_1:-} + MAX_FEE_GWEI: ${MAX_FEE_GWEI_1:-} + # Start new deployments in dry-run: read/plan/simulate and log, never submit. Flip to false + # once the logged reallocation.dry_run plans look right. + DRY_RUN: ${DRY_RUN_1:-true} + LOG_LEVEL: ${LOG_LEVEL:-info} + # Optional BetterStack log shipping (in-process loglayer transport). Empty = disabled; the bot + # ships its structured logs only when BOTH are set (host also required). + BETTERSTACK_SOURCE_TOKEN: ${BETTERSTACK_SOURCE_TOKEN:-} + BETTERSTACK_INGESTING_HOST: ${BETTERSTACK_INGESTING_HOST:-} + BETTERSTACK_HEARTBEAT_URL: ${BETTERSTACK_HEARTBEAT_URL_1:-} + restart: unless-stopped + + bot-base: + build: + context: ../.. + dockerfile: bots/vault-v2-reallocation/Dockerfile + environment: + CHAIN_ID: '8453' + RPC_URL: ${RPC_URL_8453:?set RPC_URL_8453} + RPC_URL_FALLBACK: ${RPC_URL_FALLBACK_8453:-} + REALLOCATOR_PRIVATE_KEY: ${REALLOCATOR_PRIVATE_KEY:?set REALLOCATOR_PRIVATE_KEY} + VAULT_WHITELIST: ${VAULT_WHITELIST_8453:?set VAULT_WHITELIST_8453} + STRATEGY: ${STRATEGY_8453:-equalize-utilizations} + REALLOCATION_INTERVAL_MS: ${REALLOCATION_INTERVAL_MS_8453:-} + MIN_APY_DELTA_BIPS: ${MIN_APY_DELTA_BIPS_8453:-} + MIN_UTILIZATION_DELTA_BIPS: ${MIN_UTILIZATION_DELTA_BIPS_8453:-} + ALLOW_IDLE_REALLOCATION: ${ALLOW_IDLE_REALLOCATION_8453:-} + MAX_FEE_GWEI: ${MAX_FEE_GWEI_8453:-} + DRY_RUN: ${DRY_RUN_8453:-true} + LOG_LEVEL: ${LOG_LEVEL:-info} + BETTERSTACK_SOURCE_TOKEN: ${BETTERSTACK_SOURCE_TOKEN:-} + BETTERSTACK_INGESTING_HOST: ${BETTERSTACK_INGESTING_HOST:-} + BETTERSTACK_HEARTBEAT_URL: ${BETTERSTACK_HEARTBEAT_URL_8453:-} + restart: unless-stopped diff --git a/bots/vault-v2-reallocation/package.json b/bots/vault-v2-reallocation/package.json new file mode 100644 index 00000000..c2b7c77f --- /dev/null +++ b/bots/vault-v2-reallocation/package.json @@ -0,0 +1,34 @@ +{ + "name": "@morpho-org/vault-v2-reallocation", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "tsx scripts/build.ts", + "deploy:railway": "tsx scripts/deploy-railway.ts", + "prestart": "pnpm --filter \"{.}...\" --if-present run build", + "probe:lens": "pnpm --filter \"{.}...\" --if-present run build && node --env-file-if-exists=.env dist/scripts/probe-live-lens.js", + "start": "node --env-file-if-exists=.env dist/src/index.js", + "typecheck": "soltag && tsc --noEmit" + }, + "dependencies": { + "@morpho-org/blue-sdk": "catalog:", + "@morpho-org/blue-sdk-viem": "catalog:", + "@repo/bot-kit": "workspace:*", + "@repo/utils": "workspace:*", + "solc": "catalog:", + "soltag": "catalog:", + "viem": "catalog:" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@types/node": "catalog:", + "esbuild": "catalog:", + "execa": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/bots/vault-v2-reallocation/scripts/build.ts b/bots/vault-v2-reallocation/scripts/build.ts new file mode 100644 index 00000000..dcb5cbdb --- /dev/null +++ b/bots/vault-v2-reallocation/scripts/build.ts @@ -0,0 +1,55 @@ +import type { Plugin } from 'esbuild' + +import { build as esbuild } from 'esbuild' +import { rmSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { transformSolTemplates } from 'soltag/unplugin' + +import { BundleFailedError } from './bundle-failed.error' + +// Bundles the bot entrypoint (and the soltag-dependent operator script) to `dist/` with the +// `sol``` templates compiled to literal ABIs/bytecode, so production runs a plain `node` with no +// runtime transform. + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const DIST_DIR = join(ROOT, 'dist') +rmSync(DIST_DIR, { recursive: true, force: true }) + +const ESCAPED = ROOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +// This bot's own TS sources only — bundled workspace deps ship prebuilt dist and hold no templates. +const INCLUDE = new RegExp(`^${ESCAPED}/(?:src|scripts)/.*\\.tsx?$`) + +const soltagPlugin: Plugin = { + name: 'soltag', + setup(build) { + build.onLoad({ filter: INCLUDE }, async ({ path }) => { + const source = await readFile(path, 'utf8') + // Enable the optimizer — the lens's per-market loop has enough locals to hit "stack too deep" + // without it. + const transformed = transformSolTemplates(source, path, { + solc: { optimizer: { enabled: true, runs: 200 } } + }) + return { contents: transformed?.code ?? source, loader: 'ts' as const } + }) + } +} + +try { + await esbuild({ + entryPoints: [join(ROOT, 'src/index.ts'), join(ROOT, 'scripts/probe-live-lens.ts')], + outdir: DIST_DIR, + outbase: ROOT, + bundle: true, + platform: 'node', + format: 'esm', + // CJS deps reaching for require() inside an ESM bundle need a real require. + banner: { + js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" + }, + plugins: [soltagPlugin] + }) +} catch (error) { + throw new BundleFailedError(error instanceof Error ? error.message : String(error)) +} diff --git a/bots/vault-v2-reallocation/scripts/bundle-failed.error.ts b/bots/vault-v2-reallocation/scripts/bundle-failed.error.ts new file mode 100644 index 00000000..85b07b64 --- /dev/null +++ b/bots/vault-v2-reallocation/scripts/bundle-failed.error.ts @@ -0,0 +1,11 @@ +/** Signals that the production bundle could not be produced. */ +export class BundleFailedError extends Error { + /** + * Creates a tooling failure from the bundler's own message, without retaining source contents. + * @param detail - Bundler-reported reason for the failure. + */ + constructor(readonly detail: string) { + super(`Bundle failed: ${detail}`) + this.name = 'BundleFailedError' + } +} diff --git a/bots/vault-v2-reallocation/scripts/deploy-railway.ts b/bots/vault-v2-reallocation/scripts/deploy-railway.ts new file mode 100644 index 00000000..ef160bbe --- /dev/null +++ b/bots/vault-v2-reallocation/scripts/deploy-railway.ts @@ -0,0 +1,343 @@ +/** + * Idempotent deployment of the multi-chain vault-v2-reallocation system to a Railway project: one + * `bot-` runner per chain (see CHAINS below). Per-chain inputs are chainId-suffixed. + * + * RAILWAY_PROJECT_ID=… RPC_URL_1=… VAULT_WHITELIST_1=0x… REALLOCATOR_PRIVATE_KEY=0x… \ + * pnpm --filter @morpho-org/vault-v2-reallocation run deploy:railway + * + * Required per chain: `RPC_URL_`, `VAULT_WHITELIST_`, and `REALLOCATOR_PRIVATE_KEY_` (or + * one shared unsuffixed `REALLOCATOR_PRIVATE_KEY`). + * Optional per chain: `STRATEGY_`, `DRY_RUN_`, `BETTERSTACK_HEARTBEAT_URL_`, + * `REALLOCATION_INTERVAL_MS_`, `MIN_APY_DELTA_BIPS_`, `MIN_UTILIZATION_DELTA_BIPS_`, + * `ALLOW_IDLE_REALLOCATION_`, `MAX_FEE_GWEI_`, and `RPC_URL_FALLBACK_` (a secret). The + * tuning knobs and the fallback endpoint are set when supplied and DELETED from the service when not, + * so removing one from the deploy env returns the bot to its own default rather than leaving a stale + * value in place. + * Optional, shared across chains: `BETTERSTACK_INGESTING_HOST`, `BETTERSTACK_SOURCE_TOKEN`. + * + * The build context MUST be the repo root so the pnpm workspace (packages/*) resolves — the script + * runs `railway up` with cwd set to the repo root (mirrors the Dockerfile header + compose context). + * + * Secrets (per-chain RPC_URL, RPC_URL_FALLBACK, REALLOCATOR_PRIVATE_KEY) are piped to + * `railway variable set --stdin` so their values never appear in argv or in a failure message; with + * DEPLOY_ONLY=1 (how a CI pipeline would re-ship already-provisioned services) no variable is + * written at all, so such a pipeline needs no secret to redeploy. This repo's own CI deliberately + * does not call this script — Morpho publishes this bot as a reference and does not operate it. + */ +import { delay, tryCatch } from '@repo/utils' +import { $ } from 'execa' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isHex } from 'viem' + +import { RailwayDeploymentError } from './railway-deployment.error' + +type Env = Record +type RailwayService = { id: string; name: string } + +const PRIVATE_KEY_HEX_LENGTH = 66 // '0x' + 32 bytes + +const required = (env: Env, name: string): string => { + const value = env[name] + if (!value || !value.trim()) throw new RailwayDeploymentError(`Missing required env var: ${name}`) + return value.trim() +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +// Narrow an unknown JSON field to a string (CLI JSON fields we read are all strings); never coerces +// objects (which would stringify to '[object Object]'). +const str = (value: unknown): string => (typeof value === 'string' ? value : '') + +const assertPrivateKey = (key: string): void => { + if (!isHex(key, { strict: true }) || key.length !== PRIVATE_KEY_HEX_LENGTH) { + throw new RailwayDeploymentError( + 'REALLOCATOR_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string' + ) + } +} + +// The CLI's JSON is either a bare array or an object wrapping one under `key`. +const rowsOf = (data: unknown, key: string): unknown[] => { + if (Array.isArray(data)) return data + if (isRecord(data) && Array.isArray(data[key])) return data[key] + return [] +} + +const parseServices = (raw: string): RailwayService[] => { + const { data } = tryCatch(() => JSON.parse(raw) as unknown) + return rowsOf(data, 'services') + .filter(isRecord) + .map(row => ({ id: str(row.id), name: str(row.name) || str(row.serviceName) })) + .filter(service => service.name) +} + +const parseLatestStatus = (raw: string): string => { + const { data } = tryCatch(() => JSON.parse(raw) as unknown) + const latest = rowsOf(data, 'deployments').filter(isRecord)[0] + return latest ? str(latest.status) || 'UNKNOWN' : 'UNKNOWN' +} + +const assertCli = async (): Promise => { + const { error } = await tryCatch($`railway --version`) + if (error) + throw new RailwayDeploymentError( + 'Railway CLI not found. Install it: https://docs.railway.com/guides/cli', + { cause: error } + ) +} + +// Railway service names are project-wide, while environments only scope service instances. Retain +// the established production names and prefix every non-production service to prevent collisions. +const serviceName = (productionName: string): string => + ENVIRONMENT === 'production' ? productionName : `${ENVIRONMENT}-${productionName.toLowerCase()}` + +// `railway add` has no --project/--environment flag, so it acts on the linked context. A project +// token scopes every command implicitly; otherwise we link the project id once for this run. +const ensureContext = async (): Promise => { + if (process.env.RAILWAY_TOKEN) { + console.log('Using RAILWAY_TOKEN for project context.') + return + } + const { error } = await tryCatch($`railway link -p ${PROJECT_ID} -e ${ENVIRONMENT}`) + if (error) { + throw new RailwayDeploymentError( + 'Failed to link the Railway project. Set RAILWAY_TOKEN or run `railway login`.', + { cause: error } + ) + } + console.log(`Linked project ${PROJECT_ID} (${ENVIRONMENT}).`) +} + +const listServices = async (): Promise => { + const { data, error } = await tryCatch($`railway service list --json`.then(r => r.stdout)) + return error || typeof data !== 'string' ? [] : parseServices(data) +} + +const ensureService = async (name: string): Promise => { + if ((await listServices()).some(service => service.name === name)) { + console.log(`Service ${name} already exists.`) + return + } + console.log(`Creating service ${name}…`) + const { error } = await tryCatch($`railway add --service ${name} --json`) + if (error) throw new RailwayDeploymentError(`Failed to create service ${name}`, { cause: error }) +} + +// Non-secret variable. `kv` is a single "KEY=VALUE" arg; only the key is logged. +const setVar = async (service: string, kv: string): Promise => { + const key = kv.split('=')[0] + const { error } = await tryCatch($`railway variable set ${kv} -s ${service} --skip-deploys`) + if (error) + throw new RailwayDeploymentError(`Failed to set ${key} on ${service}`, { cause: error }) + console.log(`Set ${key} on ${service}.`) +} + +// Keys currently set on a service — used to clear a knob the operator has stopped supplying, without +// blind deletes. The CLI's JSON includes raw values, so it is parsed in-memory and only key NAMES +// ever leave here. +const listVarKeys = async (service: string): Promise> => { + const { data, error } = await tryCatch( + $`railway variable list -s ${service} -e ${ENVIRONMENT} -p ${PROJECT_ID} --json`.then( + r => r.stdout + ) + ) + if (error || typeof data !== 'string') return new Set() + const { data: parsed } = tryCatch(() => JSON.parse(data) as unknown) + return isRecord(parsed) ? new Set(Object.keys(parsed)) : new Set() +} + +// Delete a variable. Fatal on failure: a stale knob that survives the delete silently keeps its old +// value in effect, which is exactly the drift this prevents. +const deleteVar = async (service: string, key: string): Promise => { + const { error } = await tryCatch($`railway variable delete ${key} -s ${service} --skip-deploys`) + if (error) throw new RailwayDeploymentError(`Failed to delete ${key} on ${service}`) + console.log(`Deleted ${key} on ${service} (stale).`) +} + +// Secret variable: value piped via stdin (never argv), `--json` omitted (it echoes raw values), and +// the CLI error is dropped entirely — its output can quote the piped value. +const setSecret = async (service: string, key: string, value: string): Promise => { + const { error } = await tryCatch( + $({ input: value })`railway variable set ${key} --stdin -s ${service} --skip-deploys` + ) + if (error) throw new RailwayDeploymentError(`Failed to set ${key} on ${service}`) + console.log(`Set ${key} on ${service} (secret).`) +} + +const deployService = async (service: string): Promise => { + console.log(`Deploying ${service} from repo root…`) + // Pass -p/-e explicitly: `railway link` doesn't reliably carry the environment into this non-TTY + // subprocess, so `railway up` otherwise errors "No environment specified". + const { error } = await tryCatch( + $({ cwd: REPO_ROOT })`railway up -s ${service} -e ${ENVIRONMENT} -p ${PROJECT_ID} -d` + ) + if (error) + throw new RailwayDeploymentError(`Failed to start deploy for ${service}`, { cause: error }) +} + +const latestStatus = async (service: string): Promise => { + // -e/-p explicit for the same reason as deployService: don't depend on ambient link state. + const { data, error } = await tryCatch( + $`railway deployment list -s ${service} -e ${ENVIRONMENT} -p ${PROJECT_ID} --limit 1 --json`.then( + r => r.stdout + ) + ) + return error || typeof data !== 'string' ? 'UNKNOWN' : parseLatestStatus(data) +} + +// Bounded poll (default 10 min) on an awaited timer — never a foreground shell sleep, so CI can't hang. +const waitForDeploy = async ( + service: string, + maxAttempts = 60, + intervalMs = 10_000 +): Promise => { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const status = await latestStatus(service) + if (status === 'SUCCESS' || status === 'FAILED' || status === 'CRASHED') return status + console.log(`[${service}] ${status} (${attempt}/${maxAttempts})…`) + await delay(intervalMs) + } + return 'TIMEOUT' +} + +// CRASHED is a failure: the bot fails loud at startup on a bad config (empty whitelist, a +// whitelisted address that isn't a factory-made VaultV2), so a crash-looping service must never read +// as a green deploy. +const badStatus = (status: string) => + status === 'FAILED' || status === 'TIMEOUT' || status === 'CRASHED' + +// Read a chainId-suffixed env var (e.g. RPC_URL_1). Endpoints and whitelists differ per chain so +// these are required per chain; the private key may instead fall back to a shared unsuffixed key. +const suffixed = (name: string, chainId: number): string | undefined => + process.env[`${name}_${chainId}`]?.trim() || undefined + +const requiredSuffixed = (name: string, chainId: number): string => { + const value = suffixed(name, chainId) + if (!value) throw new RailwayDeploymentError(`Missing required env var: ${name}_${chainId}`) + return value +} + +const PROJECT_ID = required(process.env, 'RAILWAY_PROJECT_ID') +const ENVIRONMENT = process.env.RAILWAY_ENVIRONMENT?.trim() || 'production' +const DOCKERFILE_PATH = 'bots/vault-v2-reallocation/Dockerfile' +// Repo root is three levels up from this file (scripts → vault-v2-reallocation → bots → repo root). +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') + +await assertCli() + +// The chains this deploy targets: one `bot-` service each. Add a chain here + in +// src/config.ts's chain map to extend coverage. +type ChainDeploy = { chainId: number; service: string } +const CHAINS: ChainDeploy[] = [ + { chainId: 1, service: serviceName('bot-1') }, + { chainId: 8453, service: serviceName('bot-8453') } +] + +// Deploy-only mode (DEPLOY_ONLY=1|true): re-ship the ALREADY-PROVISIONED services from the +// checked-out tree and set NOTHING — no secrets, no variables. This is the path a CI pipeline +// would use: its environment holds only RAILWAY_TOKEN + RAILWAY_PROJECT_ID, and the +// services/secrets were provisioned once by a full (secret-bearing) run of this script. +if (/^(1|true)$/i.test(process.env.DEPLOY_ONLY?.trim() ?? '')) { + await ensureContext() + const services = CHAINS.map(chain => chain.service) + for (const service of services) await deployService(service) + const statuses = new Map() + for (const service of services) statuses.set(service, await waitForDeploy(service)) + console.log('') + console.log('=== Deploy-only status ===') + for (const [service, status] of statuses) console.log(` ${service}: ${status}`) + process.exit([...statuses.values()].some(badStatus) ? 1 : 0) +} + +// Per-chain secrets/config, read + validated up front so we fail loud before mutating Railway state. +const chainSecrets = CHAINS.map(chain => { + const rpcUrl = requiredSuffixed('RPC_URL', chain.chainId) + const vaultWhitelist = requiredSuffixed('VAULT_WHITELIST', chain.chainId) + // A single allocator key may be reused across chains (unsuffixed fallback), or set one per chain. + const reallocatorPrivateKey = + suffixed('REALLOCATOR_PRIVATE_KEY', chain.chainId) ?? + process.env.REALLOCATOR_PRIVATE_KEY?.trim() + if (!reallocatorPrivateKey) + throw new RailwayDeploymentError( + `Missing required env var: REALLOCATOR_PRIVATE_KEY_${chain.chainId} (or a shared REALLOCATOR_PRIVATE_KEY)` + ) + assertPrivateKey(reallocatorPrivateKey) + const strategy = suffixed('STRATEGY', chain.chainId) ?? 'equalize-utilizations' + // New deployments default to dry-run; flip DRY_RUN_=false once the plans look right. + const dryRun = !/^(0|false)$/i.test(suffixed('DRY_RUN', chain.chainId) ?? '') + const betterstackHeartbeatUrl = suffixed('BETTERSTACK_HEARTBEAT_URL', chain.chainId) + return { + ...chain, + rpcUrl, + vaultWhitelist, + reallocatorPrivateKey, + strategy, + dryRun, + betterstackHeartbeatUrl, + // Optional tuning knobs. Each is validated in src/config.ts, so they are passed through verbatim + // and left UNSET when the operator supplies nothing — the bot's own default then applies. + // Suffixed per chain because thresholds and fee ceilings legitimately differ between chains. + optional: { + REALLOCATION_INTERVAL_MS: suffixed('REALLOCATION_INTERVAL_MS', chain.chainId), + MIN_APY_DELTA_BIPS: suffixed('MIN_APY_DELTA_BIPS', chain.chainId), + MIN_UTILIZATION_DELTA_BIPS: suffixed('MIN_UTILIZATION_DELTA_BIPS', chain.chainId), + ALLOW_IDLE_REALLOCATION: suffixed('ALLOW_IDLE_REALLOCATION', chain.chainId), + MAX_FEE_GWEI: suffixed('MAX_FEE_GWEI', chain.chainId) + }, + // A fallback endpoint is a URL that can carry a key, so it ships as a secret. + rpcUrlFallback: suffixed('RPC_URL_FALLBACK', chain.chainId) + } +}) + +await ensureContext() + +// Optional BetterStack log shipping, one source shared across this bot's chains (told apart by the +// bot/chainId fields the logger stamps). Host is a plain var; token is a secret. Off when unset. +const betterstackHost = process.env.BETTERSTACK_INGESTING_HOST?.trim() +const betterstackToken = process.env.BETTERSTACK_SOURCE_TOKEN?.trim() + +// --- bot-: one reallocation runner per chain. The in-container var names stay RPC_URL / +// REALLOCATOR_PRIVATE_KEY / VAULT_WHITELIST (the chainId suffix is only an operator-side convention). +for (const chain of chainSecrets) { + await ensureService(chain.service) + await setVar(chain.service, `CHAIN_ID=${chain.chainId}`) + await setVar(chain.service, `RAILWAY_DOCKERFILE_PATH=${DOCKERFILE_PATH}`) + await setVar(chain.service, 'LOG_LEVEL=info') + await setVar(chain.service, `VAULT_WHITELIST=${chain.vaultWhitelist}`) + await setVar(chain.service, `STRATEGY=${chain.strategy}`) + await setVar(chain.service, `DRY_RUN=${chain.dryRun}`) + await setSecret(chain.service, 'RPC_URL', chain.rpcUrl) + await setSecret(chain.service, 'REALLOCATOR_PRIVATE_KEY', chain.reallocatorPrivateKey) + // Set-when-provided / delete-when-absent, so dropping a knob from the deploy env actually returns + // the service to the bot's default instead of leaving the last value stuck on the service. + const existingKeys = await listVarKeys(chain.service) + for (const [key, value] of Object.entries(chain.optional)) { + if (value) await setVar(chain.service, `${key}=${value}`) + else if (existingKeys.has(key)) await deleteVar(chain.service, key) + } + if (chain.rpcUrlFallback) await setSecret(chain.service, 'RPC_URL_FALLBACK', chain.rpcUrlFallback) + else if (existingKeys.has('RPC_URL_FALLBACK')) await deleteVar(chain.service, 'RPC_URL_FALLBACK') + if (betterstackHost) await setVar(chain.service, `BETTERSTACK_INGESTING_HOST=${betterstackHost}`) + if (betterstackToken) await setSecret(chain.service, 'BETTERSTACK_SOURCE_TOKEN', betterstackToken) + if (chain.betterstackHeartbeatUrl) { + await setSecret(chain.service, 'BETTERSTACK_HEARTBEAT_URL', chain.betterstackHeartbeatUrl) + } + await deployService(chain.service) +} + +const botStatuses = new Map() +for (const chain of chainSecrets) botStatuses.set(chain.service, await waitForDeploy(chain.service)) + +console.log('') +console.log('=== Deployment status ===') +for (const [service, status] of botStatuses) console.log(` ${service}: ${status}`) +console.log('') +console.log('=== Manual steps ===') +console.log(' 1. Grant the allocator role to the EOA on every whitelisted vault (the bot logs') +console.log(' allocator.missing_role and skips a vault until the grant lands).') +console.log(' 2. New services start in DRY_RUN=true: review the reallocation.dry_run plans in the') +console.log(' logs, then rerun with DRY_RUN_=false to arm the bot.') + +process.exitCode = [...botStatuses.values()].some(badStatus) ? 1 : 0 diff --git a/bots/vault-v2-reallocation/scripts/invalid-probe-config.error.ts b/bots/vault-v2-reallocation/scripts/invalid-probe-config.error.ts new file mode 100644 index 00000000..f79a72d8 --- /dev/null +++ b/bots/vault-v2-reallocation/scripts/invalid-probe-config.error.ts @@ -0,0 +1,7 @@ +/** Raised when the lens probe's environment is missing or malformed — the script must fail loud. */ +export class InvalidProbeConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'InvalidProbeConfigError' + } +} diff --git a/bots/vault-v2-reallocation/scripts/probe-live-lens.ts b/bots/vault-v2-reallocation/scripts/probe-live-lens.ts new file mode 100644 index 00000000..12a048cd --- /dev/null +++ b/bots/vault-v2-reallocation/scripts/probe-live-lens.ts @@ -0,0 +1,281 @@ +/** + * Reads the deployless reallocation lens against REAL chain state, exactly as the running bot does — + * no anvil, no deploy: the viem-dlc `deployless` transport runs the lens inside one `eth_call`. Two + * uses: + * + * 1. Operator sanity check — proves the whole read path works against production (the lens compiles, + * deploys deploylessly, the on-chain `accrueInterest` simulation doesn't revert, and the nested + * structs decode) and prints the decoded snapshot. + * 2. Equivalence check — re-reads the SAME pinned block through the `fetchAccrualVaultV2` + cap + * multicall path the lens replaced and diffs it field by field, pairing markets by id. The two + * accrue differently by construction (the lens accrues on-chain at the block's timestamp; the + * SDK accrues client-side to a timestamp we pass in), so both are pinned to the same block and + * the SDK side is accrued to that block's timestamp. Tiny rounding deltas in accrued totals are + * explainable; a structural mismatch — params, caps, cap ids, adapter, isAllocator, market set, + * rateAtTarget — is a bug. + * + * Usage (needs an RPC for CHAIN_ID; no anvil required): + * RPC_URL=https://base-rpc.publicnode.com CHAIN_ID=8453 VAULT=0x… \ + * pnpm --filter @morpho-org/vault-v2-reallocation run probe:lens + */ +import type { MarketParams } from '@morpho-org/blue-sdk' +import type { Hex } from 'viem' + +import { + AccrualVaultV2MorphoMarketV1Adapter, + AccrualVaultV2MorphoMarketV1AdapterV2, + SharesMath, + VaultV2MorphoMarketV1Adapter +} from '@morpho-org/blue-sdk' +import { fetchAccrualVaultV2, vaultV2Abi } from '@morpho-org/blue-sdk-viem' +import { createDeploylessClient } from '@repo/bot-kit' +import { ensureError } from '@repo/utils' +import { getAddress } from 'viem' +import { getBlock, getBlockNumber, multicall, readContract } from 'viem/actions' +import { base, mainnet } from 'viem/chains' + +import { fetchVaultV2Data } from '../src/vault-data' +import { InvalidProbeConfigError } from './invalid-probe-config.error' + +const CHAINS = { [mainnet.id]: mainnet, [base.id]: base } +// A few blocks back: a public RPC's archive window always covers the recent past, and pinning off the +// exact head avoids a reorg racing the two reads. +const HEAD_LAG = 8n + +const required = (name: string): string => { + const value = process.env[name] + if (!value?.trim()) throw new InvalidProbeConfigError(`Missing required env var: ${name}`) + return value.trim() +} + +const bigintish = (_key: string, value: unknown) => + typeof value === 'bigint' ? value.toString() : value + +/** One reported field comparison. `delta` is set only for numeric mismatches. */ +type Row = { field: string; lens: string; sdk: string; match: boolean; delta?: string } + +const compare = (field: string, lens: unknown, sdk: unknown): Row => { + const same = + typeof lens === 'string' && typeof sdk === 'string' && lens.startsWith('0x') + ? lens.toLowerCase() === sdk.toLowerCase() + : lens === sdk + const row: Row = { field, lens: String(lens), sdk: String(sdk), match: same } + if (!same && typeof lens === 'bigint' && typeof sdk === 'bigint') row.delta = String(lens - sdk) + return row +} + +type SdkMarket = { + id: Hex + params: MarketParams + totalSupplyAssets: bigint + totalBorrowAssets: bigint + vaultAssets: bigint + rateAtTarget: bigint + isIdle: boolean +} + +type MarketAdapter = AccrualVaultV2MorphoMarketV1Adapter | AccrualVaultV2MorphoMarketV1AdapterV2 + +// The pre-lens normalization over both adapter generations, kept here as the reference the lens is +// diffed against (client-side accrual to the pinned block's timestamp). +const sdkMarkets = (adapter: MarketAdapter, timestamp: bigint): SdkMarket[] => { + if (adapter instanceof AccrualVaultV2MorphoMarketV1Adapter) { + return adapter.marketParamsList.map(params => { + // The SDK constructs one position per params entry, so the lookup is total. + const position = adapter.positions.find(candidate => candidate.marketId === params.id)! + const accrued = position.accrueInterest(timestamp) + return { + id: params.id, + params, + totalSupplyAssets: accrued.market.totalSupplyAssets, + totalBorrowAssets: accrued.market.totalBorrowAssets, + vaultAssets: accrued.supplyAssets, + rateAtTarget: accrued.market.rateAtTarget ?? 0n, + isIdle: accrued.market.isIdle + } + }) + } + return adapter.markets.map(market => { + const accrued = market.accrueInterest(timestamp) + return { + id: accrued.id, + params: accrued.params, + totalSupplyAssets: accrued.totalSupplyAssets, + totalBorrowAssets: accrued.totalBorrowAssets, + vaultAssets: SharesMath.toAssets( + adapter.supplyShares[accrued.id] ?? 0n, + accrued.totalSupplyAssets, + accrued.totalSupplyShares, + 'Down' + ), + rateAtTarget: accrued.rateAtTarget ?? 0n, + isIdle: accrued.isIdle + } + }) +} + +async function main() { + const rpcUrl = required('RPC_URL') + const chainId = Number(process.env.CHAIN_ID?.trim() ?? base.id) + const chain = CHAINS[chainId as keyof typeof CHAINS] + if (!chain) throw new InvalidProbeConfigError(`Unsupported CHAIN_ID ${chainId}`) + const vault = getAddress(required('VAULT')) + // Any address works — `isAllocator` is just another field to compare. + const eoa = getAddress(process.env.EOA?.trim() ?? `0x${'11'.repeat(20)}`) + + const client = createDeploylessClient({ chain, rpcUrl, rpcUrlFallback: undefined }) + const blockNumber = (await getBlockNumber(client)) - HEAD_LAG + console.log(`[probe] chain ${chainId} · vault ${vault} · block ${blockNumber}`) + + // (1) The production read path: one deployless eth_call. + const lensStart = Date.now() + const lensData = await fetchVaultV2Data(client, vault, { chainId, blockNumber, eoa }) + const lensMs = Date.now() - lensStart + + // (2) The path the lens replaced, pinned to the same block and accrued to its timestamp. + const sdkStart = Date.now() + const [block, vaultV2] = await Promise.all([ + getBlock(client, { blockNumber }), + fetchAccrualVaultV2(vault, client, { chainId, blockNumber }) + ]) + const adapter = vaultV2.accrualAdapters.find( + (candidate): candidate is MarketAdapter => + candidate instanceof AccrualVaultV2MorphoMarketV1Adapter || + candidate instanceof AccrualVaultV2MorphoMarketV1AdapterV2 + ) + if (!adapter) throw new InvalidProbeConfigError('SDK path found no Morpho Blue market adapter') + const adapterAddress = getAddress(adapter.address) + const markets = sdkMarkets(adapter, block.timestamp) + + const collateralTokens = [...new Set(markets.map(m => getAddress(m.params.collateralToken)))] + const capIds = [ + VaultV2MorphoMarketV1Adapter.adapterId(adapterAddress), + ...collateralTokens.map(token => VaultV2MorphoMarketV1Adapter.collateralId(token)), + ...markets.map(m => VaultV2MorphoMarketV1Adapter.marketParamsId(adapterAddress, m.params)) + ] + const capReads = await multicall(client, { + allowFailure: false, + blockNumber, + contracts: capIds.flatMap(id => + (['absoluteCap', 'relativeCap', 'allocation'] as const).map(functionName => ({ + address: vault, + abi: vaultV2Abi, + functionName, + args: [id] as const + })) + ) + }) + const capAt = (i: number) => ({ + absolute: capReads[i * 3] as bigint, + relative: capReads[i * 3 + 1] as bigint, + allocation: capReads[i * 3 + 2] as bigint + }) + const sdkMs = Date.now() - sdkStart + + console.log( + `[probe] lens ${lensData.marketsData.length} markets in ${lensMs}ms (1 eth_call) · ` + + `fetchAccrualVaultV2 + cap multicall in ${sdkMs}ms (fan-out)` + ) + + // `fetchAccrualVaultV2` never read the allocator bit — folding it into the lens is the point — so + // it is checked against a direct standalone read instead. + const directIsAllocator = await readContract(client, { + address: vault, + abi: vaultV2Abi, + functionName: 'isAllocator', + args: [eoa], + blockNumber + }) + + const rows: Row[] = [ + compare('adapterAddress', lensData.adapterAddress, adapterAddress), + compare('isAllocator', lensData.isAllocator, directIsAllocator), + compare( + 'totalAssets', + lensData.totalAssets, + vaultV2.accrueInterest(block.timestamp).vault._totalAssets + ), + compare('idleAssets', lensData.idleAssets, vaultV2.assetBalance), + compare('marketCount', BigInt(lensData.marketsData.length), BigInt(markets.length)) + ] + const sdkAdapterCap = capAt(0) + rows.push( + compare('adapterCap.absolute', lensData.adapterCap.absolute, sdkAdapterCap.absolute), + compare('adapterCap.relative', lensData.adapterCap.relative, sdkAdapterCap.relative), + compare('adapterCap.allocation', lensData.adapterCap.allocation, sdkAdapterCap.allocation) + ) + for (const [index, token] of collateralTokens.entries()) { + const lensCap = lensData.collateralCaps[token] + const sdkCap = capAt(1 + index) + const at = (field: string) => `collateralCap[${token}].${field}` + rows.push( + compare(at('absolute'), lensCap?.absolute, sdkCap.absolute), + compare(at('relative'), lensCap?.relative, sdkCap.relative), + compare(at('allocation'), lensCap?.allocation, sdkCap.allocation) + ) + } + + // Pair by market id — enumeration order is an implementation detail of each path. + const lensById = new Map(lensData.marketsData.map(market => [market.id.toLowerCase(), market])) + for (const [index, sdk] of markets.entries()) { + const lens = lensById.get(sdk.id.toLowerCase()) + const at = (field: string) => `market[${sdk.id}].${field}` + if (!lens) { + rows.push(compare(at('present'), '', sdk.id)) + continue + } + const sdkCap = capAt(1 + collateralTokens.length + index) + rows.push( + compare( + at('capId'), + lens.capId, + VaultV2MorphoMarketV1Adapter.marketParamsId(adapterAddress, sdk.params) + ), + compare(at('params.loanToken'), lens.params.loanToken, sdk.params.loanToken), + compare( + at('params.collateralToken'), + lens.params.collateralToken, + sdk.params.collateralToken + ), + compare(at('params.oracle'), lens.params.oracle, sdk.params.oracle), + compare(at('params.irm'), lens.params.irm, sdk.params.irm), + compare(at('params.lltv'), lens.params.lltv, sdk.params.lltv), + compare(at('state.totalSupplyAssets'), lens.state.totalSupplyAssets, sdk.totalSupplyAssets), + compare(at('state.totalBorrowAssets'), lens.state.totalBorrowAssets, sdk.totalBorrowAssets), + compare(at('cap.absolute'), lens.cap.absolute, sdkCap.absolute), + compare(at('cap.relative'), lens.cap.relative, sdkCap.relative), + compare(at('cap.allocation'), lens.cap.allocation, sdkCap.allocation), + compare(at('vaultAssets'), lens.vaultAssets, sdk.vaultAssets), + compare(at('rateAtTarget'), lens.rateAtTarget, sdk.rateAtTarget), + compare(at('isIdle'), lens.isIdle, sdk.isIdle) + ) + } + + const mismatches = rows.filter(row => !row.match) + console.log(`\n[probe] compared ${rows.length} fields · ${mismatches.length} mismatch(es)`) + if (mismatches.length > 0) { + console.log('[probe] MISMATCHES:') + for (const row of mismatches) { + console.log( + ` ${row.field}\n lens = ${row.lens}\n sdk = ${row.sdk}` + + (row.delta ? `\n delta = ${row.delta}` : '') + ) + } + } else { + console.log('[probe] EXACT MATCH on every compared field.') + } + + console.log('\n[probe] lens snapshot (first market):') + console.log(JSON.stringify(lensData.marketsData[0] ?? null, bigintish, 2)) + console.log( + `[probe] totals: totalAssets=${lensData.totalAssets} idleAssets=${lensData.idleAssets} ` + + `isAllocator(${eoa})=${lensData.isAllocator}` + ) + console.log(`[probe] nonAdaptiveCurveMarketIds: ${lensData.nonAdaptiveCurveMarketIds.length}`) + if (mismatches.length > 0) process.exitCode = 1 +} + +main().catch(error => { + console.error(`[probe] failed: ${ensureError(error).message}`) + process.exitCode = 1 +}) diff --git a/bots/vault-v2-reallocation/scripts/railway-deployment.error.ts b/bots/vault-v2-reallocation/scripts/railway-deployment.error.ts new file mode 100644 index 00000000..af8a7923 --- /dev/null +++ b/bots/vault-v2-reallocation/scripts/railway-deployment.error.ts @@ -0,0 +1,12 @@ +/** Signals a sanitized Railway provisioning, deployment, or confirmation failure. */ +export class RailwayDeploymentError extends Error { + /** + * Creates an operator-safe deployment failure without retaining CLI output or runtime values. + * @param message - Fixed diagnostic describing the failed deployment phase. + * @param options - Optional `cause` retaining the underlying CLI error for local debugging only. + */ + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'RailwayDeploymentError' + } +} diff --git a/bots/vault-v2-reallocation/src/config.ts b/bots/vault-v2-reallocation/src/config.ts new file mode 100644 index 00000000..81bf2076 --- /dev/null +++ b/bots/vault-v2-reallocation/src/config.ts @@ -0,0 +1,205 @@ +import type { LogLevel } from '@repo/bot-kit' +import type { Address, Chain, Hex } from 'viem' + +import { getAddress, isAddress, isHex, parseGwei } from 'viem' +import { base, mainnet } from 'viem/chains' + +import { InvalidConfigError } from './invalid-config.error' + +// Chains this bot supports. VaultV2 addresses come from VAULT_WHITELIST and the Blue +// deployment addresses are resolved by chainId inside blue-sdk-viem's fetchers, so an entry here is +// just the viem chain. loadConfig fails loud for any CHAIN_ID not present here. +const CHAIN_MAP: Record = { + [mainnet.id]: mainnet, + [base.id]: base +} + +const LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const +const PRIVATE_KEY_HEX_LENGTH = 66 // '0x' + 32 bytes + +const STRATEGY_NAMES = ['apy-range', 'equalize-utilizations'] as const +export type StrategyName = (typeof STRATEGY_NAMES)[number] + +const DEFAULT_MAX_FEE_GWEI = '300' +// Reallocation cadence in wall-clock ms, gating the per-block tick. +const DEFAULT_REALLOCATION_INTERVAL_MS = 600_000 +// ApyRange fires only when some market's implied borrow-APY move exceeds this (bips). +const DEFAULT_MIN_APY_DELTA_BIPS = 25 +// EqualizeUtilizations fires only when some market's utilization deviates from the vault-wide +// target by more than this (bips). +const DEFAULT_MIN_UTILIZATION_DELTA_BIPS = 250 + +type Env = Record + +export type Config = { + chainId: number + chain: Chain + rpcUrl: string + rpcUrlFallback: string | undefined + reallocatorPrivateKey: Hex + /** Vaults this bot manages; also the signer policy's allowed tx-target set. Never empty. */ + vaultWhitelist: Address[] + strategy: StrategyName + /** Minimum wall-clock ms between reallocation passes (the per-block tick early-returns inside it). */ + reallocationIntervalMs: number + minApyDeltaBips: number + minUtilizationDeltaBips: number + /** Whether ApyRange may park excess deallocations in the vault's idle balance. */ + allowIdleReallocation: boolean + /** Read, plan, and simulate as normal, but never submit — the operator ramp-up mode. */ + dryRun: boolean + maxFeeWei: bigint + logLevel: LogLevel +} + +const required = (env: Env, name: string): string => { + const value = env[name] + if (value === undefined || value.trim() === '') { + throw new InvalidConfigError(`Missing required env var: ${name}`) + } + return value.trim() +} + +// Parses an optional non-negative integer env var, with a default and optional min/max bounds. +const intEnv = ( + env: Env, + name: string, + def: number, + bounds: { min?: number; max?: number } = {} +): number => { + 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) + // A digit string may still overflow to Infinity or lose precision past 2^53, which would silently + // distort the knob (e.g. an infinite interval freezes reallocation after the first pass). + if (!Number.isSafeInteger(value)) { + throw new InvalidConfigError(`${name} must be a safe integer, got: ${env[name]}`) + } + if (bounds.min !== undefined && value < bounds.min) { + throw new InvalidConfigError(`${name} must be >= ${bounds.min}, got: ${env[name]}`) + } + if (bounds.max !== undefined && value > bounds.max) { + throw new InvalidConfigError(`${name} must be <= ${bounds.max}, got: ${env[name]}`) + } + return value +} + +// Parses an optional boolean env var (`true`/`false`, case-insensitive), with a default. Any other +// non-empty value is operator error and fails loud. +const boolEnv = (env: Env, name: string, def: boolean): boolean => { + const raw = env[name]?.trim().toLowerCase() + if (!raw) return def + if (raw !== 'true' && raw !== 'false') { + throw new InvalidConfigError(`${name} must be "true" or "false", got: ${env[name]}`) + } + return raw === 'true' +} + +// Parses a required comma-separated list of addresses into deduplicated, checksummed `Address`es. +// Fails loud on any malformed element and on an empty result — an empty whitelist would silently +// no-op every tick. +const addressListEnv = (env: Env, name: string): Address[] => { + const parts = required(env, name) + .split(',') + .map(part => part.trim()) + .filter(part => part.length > 0) + const addresses = parts.map(part => { + if (!isAddress(part, { strict: false })) { + throw new InvalidConfigError(`${name} contains an invalid address: ${part}`) + } + return getAddress(part) + }) + if (addresses.length === 0) { + throw new InvalidConfigError(`${name} must contain at least one address`) + } + // Checksumming first makes case-variant spellings of one address collapse here rather than + // processing the same vault twice per pass. + return [...new Set(addresses)] +} + +const isLogLevel = (value: string): value is LogLevel => + (LOG_LEVELS as readonly string[]).includes(value) + +const isStrategyName = (value: string): value is StrategyName => + (STRATEGY_NAMES as readonly string[]).includes(value) + +/** + * Reads the full env table into a typed, validated {@link Config}. Throws {@link InvalidConfigError} + * on any missing required var, malformed value, or unknown `CHAIN_ID` — the bot must fail loud at + * startup rather than run half-configured. On-chain checks (that each whitelisted vault holds code + * and is a factory-made VaultV2 with a supported adapter) are performed in `index.ts` once a + * client exists. + */ +export const loadConfig = (env: Env = process.env): Config => { + const chainIdRaw = required(env, 'CHAIN_ID') + if (!/^\d+$/.test(chainIdRaw)) { + // Plain decimal only — reject hex (Number('0x1')) and exponent (Number('1e3')) forms. + throw new InvalidConfigError(`CHAIN_ID must be a positive integer, got: ${chainIdRaw}`) + } + const chainId = Number(chainIdRaw) + const chain = CHAIN_MAP[chainId] + if (!chain) { + throw new InvalidConfigError( + `Unsupported CHAIN_ID ${chainId}; supported chain ids: ${Object.keys(CHAIN_MAP).join(', ')}` + ) + } + + const rpcUrl = required(env, 'RPC_URL') + + const reallocatorPrivateKey = required(env, 'REALLOCATOR_PRIVATE_KEY') + if ( + !isHex(reallocatorPrivateKey, { strict: true }) || + reallocatorPrivateKey.length !== PRIVATE_KEY_HEX_LENGTH + ) { + throw new InvalidConfigError('REALLOCATOR_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string') + } + + const strategy = env.STRATEGY?.trim() || 'equalize-utilizations' + if (!isStrategyName(strategy)) { + throw new InvalidConfigError( + `STRATEGY must be one of ${STRATEGY_NAMES.join(', ')}, got: ${env.STRATEGY}` + ) + } + + const logLevel = env.LOG_LEVEL?.trim() || 'info' + if (!isLogLevel(logLevel)) { + throw new InvalidConfigError( + `LOG_LEVEL must be one of ${LOG_LEVELS.join(', ')}, got: ${env.LOG_LEVEL}` + ) + } + + const maxFeeGwei = env.MAX_FEE_GWEI?.trim() || DEFAULT_MAX_FEE_GWEI + if (!/^\d+(\.\d+)?$/.test(maxFeeGwei) || Number(maxFeeGwei) <= 0) { + throw new InvalidConfigError(`MAX_FEE_GWEI must be a positive number, got: ${env.MAX_FEE_GWEI}`) + } + + return { + chainId, + chain, + rpcUrl, + rpcUrlFallback: env.RPC_URL_FALLBACK?.trim() || undefined, + reallocatorPrivateKey, + vaultWhitelist: addressListEnv(env, 'VAULT_WHITELIST'), + strategy, + reallocationIntervalMs: intEnv( + env, + 'REALLOCATION_INTERVAL_MS', + DEFAULT_REALLOCATION_INTERVAL_MS, + { min: 1 } + ), + minApyDeltaBips: intEnv(env, 'MIN_APY_DELTA_BIPS', DEFAULT_MIN_APY_DELTA_BIPS, { min: 0 }), + minUtilizationDeltaBips: intEnv( + env, + 'MIN_UTILIZATION_DELTA_BIPS', + DEFAULT_MIN_UTILIZATION_DELTA_BIPS, + { min: 0 } + ), + allowIdleReallocation: boolEnv(env, 'ALLOW_IDLE_REALLOCATION', true), + dryRun: boolEnv(env, 'DRY_RUN', false), + maxFeeWei: parseGwei(maxFeeGwei), + logLevel + } +} diff --git a/bots/vault-v2-reallocation/src/encode.ts b/bots/vault-v2-reallocation/src/encode.ts new file mode 100644 index 00000000..585ad710 --- /dev/null +++ b/bots/vault-v2-reallocation/src/encode.ts @@ -0,0 +1,41 @@ +import type { InputMarketParams } from '@morpho-org/blue-sdk' +import type { Address, Hex } from 'viem' + +import { marketParamsAbi } from '@morpho-org/blue-sdk' +import { vaultV2Abi } from '@morpho-org/blue-sdk-viem' +import { encodeAbiParameters, encodeFunctionData } from 'viem' + +import type { Reallocation } from './strategies' + +const encodeMarketParams = (params: InputMarketParams): Hex => + encodeAbiParameters([marketParamsAbi], [params]) + +/** + * Encodes one vault's planned move as a single `VaultV2.multicall(bytes[])` — deallocate legs + * strictly first so the idle balance is funded before allocations draw on it. Each leg is + * `allocate/deallocate(adapter, abi.encode(marketParams), assets)`. These are the exact bytes the + * tick simulates and the queue broadcasts. + */ +export const encodeReallocation = (adapter: Address, reallocation: Reallocation): Hex => + encodeFunctionData({ + abi: vaultV2Abi, + functionName: 'multicall', + args: [ + [ + ...reallocation.deallocations.map(leg => + encodeFunctionData({ + abi: vaultV2Abi, + functionName: 'deallocate', + args: [adapter, encodeMarketParams(leg.marketParams), leg.assets] + }) + ), + ...reallocation.allocations.map(leg => + encodeFunctionData({ + abi: vaultV2Abi, + functionName: 'allocate', + args: [adapter, encodeMarketParams(leg.marketParams), leg.assets] + }) + ) + ] + ] + }) diff --git a/bots/vault-v2-reallocation/src/index.ts b/bots/vault-v2-reallocation/src/index.ts new file mode 100644 index 00000000..fbd8627a --- /dev/null +++ b/bots/vault-v2-reallocation/src/index.ts @@ -0,0 +1,194 @@ +import { vaultV2Abi } from '@morpho-org/blue-sdk-viem' +import { + assertContractDeployed, + createBalanceMonitor, + createDeploylessClient, + createHeartbeatMonitor, + createLogger, + createPendingQueue, + createRunner, + createSigner, + DEFAULT_MAX_DATA_BYTES, + DEFAULT_MAX_GAS_LIMIT, + initialFees, + railwayContext, + simulateCall +} from '@repo/bot-kit' +import { getAbiItem, toFunctionSelector } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { getBlockNumber } from 'viem/actions' + +import { loadConfig } from './config' +import { encodeReallocation } from './encode' +import { createIntervalGate } from './interval-gate' +import { runTick } from './runner/tick' +import { createStrategy } from './strategies' +import { revertReason } from './tx-error' +import { checkVaults } from './vault-checks' +import { fetchVaultV2Data } from './vault-data' + +// Blocks a vault stays in the queue's backpressure set AFTER its tx settles, suppressing an +// immediate re-plan from a read RPC that lags the send RPC's confirmation. +const SETTLED_COOLDOWN_BLOCKS = 20n + +async function main() { + const config = loadConfig() + const logger = createLogger(config.logLevel, { + context: { bot: 'vault-v2-reallocation', chainId: config.chainId, ...railwayContext() } + }) + + // Every per-pass read goes through the deployless lens (one eth_call per vault), so the HTTP + // transport has nothing to batch. + const client = createDeploylessClient({ + chain: config.chain, + rpcUrl: config.rpcUrl, + rpcUrlFallback: config.rpcUrlFallback + }) + + // The signer's policy needs the vault → adapter map the startup checks resolve, so the EOA is + // derived from the key directly here. + const eoa = privateKeyToAccount(config.reallocatorPrivateKey).address + const startupBlock = await getBlockNumber(client) + + const adapterByVault = await checkVaults( + config.vaultWhitelist, + { + assertDeployed: vault => assertContractDeployed(client, vault, 'VAULT_WHITELIST entry'), + fetchVault: vault => + fetchVaultV2Data(client, vault, { + chainId: config.chainId, + blockNumber: startupBlock, + eoa + }) + }, + logger + ) + + // Default-deny pre-broadcast guard: only value-0 multicall(bytes[]) calls to whitelisted vaults + // whose every inner leg is allocate/deallocate to that vault's own adapter, under the + // fee/gas/size ceilings, are ever signed (see @repo/bot-kit `evaluatePolicy`). + const signer = createSigner({ + chain: config.chain, + rpcUrl: config.rpcUrl, + rpcUrlFallback: config.rpcUrlFallback, + privateKey: config.reallocatorPrivateKey, + policy: { + chainId: config.chainId, + targets: config.vaultWhitelist, + maxFeePerGasWei: config.maxFeeWei, + maxGasLimit: DEFAULT_MAX_GAS_LIMIT, + maxDataBytes: DEFAULT_MAX_DATA_BYTES, + selector: toFunctionSelector(getAbiItem({ abi: vaultV2Abi, name: 'multicall' })), + multicall: { + innerSelectors: [ + toFunctionSelector(getAbiItem({ abi: vaultV2Abi, name: 'allocate' })), + toFunctionSelector(getAbiItem({ abi: vaultV2Abi, name: 'deallocate' })) + ], + innerTargetsByOuter: adapterByVault + } + }, + logger + }) + + logger.info('startup', { + reallocator: eoa, + vaults: config.vaultWhitelist, + adapters: adapterByVault, + strategy: config.strategy, + intervalMs: config.reallocationIntervalMs, + dryRun: config.dryRun + }) + + const queue = createPendingQueue({ + send: signer.send, + getReceipt: signer.getReceipt, + getBaseFee: signer.getBaseFee, + getConsumedNonce: signer.consumedNonce, + syncNonce: signer.syncNonce, + maxFeeWei: config.maxFeeWei, + logger, + settledCooldownBlocks: SETTLED_COOLDOWN_BLOCKS, + revertReason + }) + + const balanceMonitor = createBalanceMonitor({ address: eoa, read: signer.balance, logger }) + const heartbeatMonitor = createHeartbeatMonitor({ + url: process.env.BETTERSTACK_HEARTBEAT_URL, + logger + }) + void heartbeatMonitor.start() + + const strategy = createStrategy(config) + + // The block watcher drives one tick per new block; the time gate throttles actual reallocation + // passes to the configured cadence (queue maintenance still runs every block via `maintain`). A + // plan is built, simulated, and submitted within a single tick — no unsent plan survives it. + const intervalGate = createIntervalGate(config.reallocationIntervalMs) + const tick = async (chainHead: bigint) => { + if (!intervalGate()) return + await runTick({ + vaults: config.vaultWhitelist, + chainHead, + expectedAdapter: vault => adapterByVault[vault]?.[0], + fetchVault: (vault, blockNumber) => + fetchVaultV2Data(client, vault, { chainId: config.chainId, blockNumber, eoa }), + strategy, + encodeReallocation: (vaultData, reallocation) => + encodeReallocation(vaultData.adapterAddress, reallocation), + // Byte-for-byte what gets broadcast. A revert here — role revoked, cap exceeded, insufficient + // idle, market no longer enabled — means do not send; the tick gates on `ok` only. + simulate: (vault, data) => simulateCall(client, { eoa, to: vault, data }), + submit: async ({ vault, data, blockNumber }) => { + const fees = initialFees(await signer.getBaseFee(), config.maxFeeWei) + return queue.submit({ + request: { to: vault, data }, + label: vault, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + blockNumber + }) + }, + dryRun: config.dryRun, + inflightLabels: () => queue.inflightLabels(), + revertReason, + logger + }) + } + + // Per-block maintenance, run by the runner BEFORE the tick and independently of it: pending-queue + // upkeep (confirmations / stuck-detection / fee-bumps / nonce reconciliation) plus the periodic + // EOA-balance metric — a sustained read outage can't starve broadcast txs of receipt checks. + const maintain = async (blockNumber: bigint) => { + await queue.onBlock(blockNumber) + await balanceMonitor.maybeLog(blockNumber) + } + + const runner = createRunner({ + getBlockNumber: () => getBlockNumber(client), + tick, + maintain, + logger, + revertReason + }) + runner.start() + + // Graceful shutdown: stop the watcher and log the pending set. Sends are fire-and-forget and + // chain truth wins on restart, so there is nothing to persist or await-drain. + const shutdown = (signal: string) => { + heartbeatMonitor.stop() + logger.info('shutdown', { signal, pending: queue.snapshot() }) + void runner.stop().finally(() => process.exit(0)) + } + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) +} + +main().catch(error => { + // Config/client never came up, so we cannot honor LOG_LEVEL — emit the failure directly. + // `revertReason` keeps the line log-safe: a raw viem transport message can embed the + // authenticated RPC URL and response body. + console.error( + JSON.stringify({ level: 'error', event: 'startup.error', error: revertReason(error) }) + ) + process.exitCode = 1 +}) diff --git a/bots/vault-v2-reallocation/src/interval-gate.ts b/bots/vault-v2-reallocation/src/interval-gate.ts new file mode 100644 index 00000000..7d542992 --- /dev/null +++ b/bots/vault-v2-reallocation/src/interval-gate.ts @@ -0,0 +1,17 @@ +/** + * Wall-clock rate limiter for the per-block tick: the returned predicate passes on its first call and + * then only once `intervalMs` has elapsed since the last pass it admitted, recording the new instant + * as it does so. `now` is injectable for tests; production uses `Date.now`. + */ +export const createIntervalGate = ( + intervalMs: number, + now: () => number = Date.now +): (() => boolean) => { + let lastPassMs: number | undefined + return () => { + const current = now() + if (lastPassMs !== undefined && current - lastPassMs < intervalMs) return false + lastPassMs = current + return true + } +} diff --git a/bots/vault-v2-reallocation/src/invalid-config.error.ts b/bots/vault-v2-reallocation/src/invalid-config.error.ts new file mode 100644 index 00000000..f61f9bd5 --- /dev/null +++ b/bots/vault-v2-reallocation/src/invalid-config.error.ts @@ -0,0 +1,9 @@ +/** Raised when a required env var is missing or malformed — the bot must fail loud at startup. */ +export class InvalidConfigError extends Error { + readonly code = 'invalid_config' + + constructor(message: string) { + super(message) + this.name = 'InvalidConfigError' + } +} diff --git a/bots/vault-v2-reallocation/src/invalid-vault.error.ts b/bots/vault-v2-reallocation/src/invalid-vault.error.ts new file mode 100644 index 00000000..d7fd55dc --- /dev/null +++ b/bots/vault-v2-reallocation/src/invalid-vault.error.ts @@ -0,0 +1,13 @@ +/** + * Raised when a whitelisted address is not a factory-made VaultV2 whose single adapter is a + * MorphoMarketV1 adapter — the signing policy authorizes the address as a tx target, and the + * strategies only understand that adapter shape. + */ +export class InvalidVaultError extends Error { + readonly code = 'invalid_vault' + + constructor(message: string) { + super(message) + this.name = 'InvalidVaultError' + } +} diff --git a/bots/vault-v2-reallocation/src/math.ts b/bots/vault-v2-reallocation/src/math.ts new file mode 100644 index 00000000..b2439c07 --- /dev/null +++ b/bots/vault-v2-reallocation/src/math.ts @@ -0,0 +1,265 @@ +import type { Address } from 'viem' + +import { AdaptiveCurveIrmLib, MathLib, SECONDS_PER_YEAR } from '@morpho-org/blue-sdk' +import { wholePercentToWAD } from '@repo/utils' +import { getAddress } from 'viem' + +import type { CapState, MarketState, VaultV2Data, VaultV2MarketData } from './vault-data' + +const WAD_PER_BIP_SCALE = 1_000_000_000n + +/** + * Deposit legs stop just short of each cap: caps are scaled by this factor before computing + * headroom, absorbing interest accrual between read and mined execution (a deposit that lands + * exactly at cap would revert on any accrual). + */ +export const CAP_BUFFER_WAD = wholePercentToWAD(99.99) + +/** + * Ceiling on any classifier target: a WAD target (an APY bound past the curve's max on a market + * whose `rateAtTarget` decayed toward the minimum, or an aggregate utilization at/above 100%) + * would size a deallocation to the market's ENTIRE free liquidity — exact to the snapshot and + * unrealizable one accrual later, so the plan sim-passes then reverts on-chain forever. The ~10 + * bips left behind scale with the market's borrow, which is what accrues; deliberately looser than + * {@link CAP_BUFFER_WAD}, whose sliver absorbs supply-side drift instead. + * + * The clamp only ever sizes a move — never its direction: a classifier decides the side on its raw + * bound and reports that as `intent`, and the reconciler drops any leg the clamp has made empty or + * backwards (see `createReconciler`). + */ +export const MAX_TARGET_UTILIZATION = wholePercentToWAD(99.9) + +/** Converts a WAD-scaled fraction to (fractional, signed) bips. */ +export const wadToBips = (wad: bigint): number => Number(wad / WAD_PER_BIP_SCALE) / 1e5 + +// `MarketUtils.getUtilization` returns MAX_UINT_256 when supply is 0 and borrow is not; sizing math +// downstream needs the 0 guard instead. +/** WAD-scaled `totalBorrowAssets / totalSupplyAssets`; 0 for an empty market. */ +export const getUtilization = (state: MarketState): bigint => + state.totalSupplyAssets === 0n + ? 0n + : MathLib.wDivDown(state.totalBorrowAssets, state.totalSupplyAssets) + +/** + * Utilization a market lands at once `take` assets have moved onto (`allocate`) or off + * (`deallocate`) its supply side — the realized counterpart of the target the move was sized + * against, which a budget-trimmed take falls short of. Empties to 0 like {@link getUtilization}. + */ +export const getUtilizationAfter = ( + state: MarketState, + side: 'allocate' | 'deallocate', + take: bigint +): bigint => + getUtilization({ + ...state, + totalSupplyAssets: + side === 'deallocate' ? state.totalSupplyAssets - take : state.totalSupplyAssets + take + }) + +// A 0 target is reachable (an APY bound at/below the curve's minimum rate, or an aggregate borrow +// that rounds to zero) and `wDivDown(_, 0n)` would throw, erroring the whole vault every pass. +// Depositing toward 0% utilization is unachievable and a 0-utilization market has nothing a +// withdrawal target constrains, so both moves size to 0 via the `undefined` ratio. +const getUtilizationRatio = (state: MarketState, targetUtilization: bigint): bigint | undefined => + targetUtilization === 0n ? undefined : MathLib.wDivDown(getUtilization(state), targetUtilization) + +const getWithdrawalToUtilization = (state: MarketState, targetUtilization: bigint): bigint => { + const ratio = getUtilizationRatio(state, targetUtilization) + return ratio === undefined ? 0n : MathLib.wMulDown(state.totalSupplyAssets, MathLib.WAD - ratio) +} + +const getDepositToUtilization = (state: MarketState, targetUtilization: bigint): bigint => { + const ratio = getUtilizationRatio(state, targetUtilization) + return ratio === undefined ? 0n : MathLib.wMulDown(state.totalSupplyAssets, ratio - MathLib.WAD) +} + +/** + * Assets deallocatable from a market before its utilization would exceed `targetUtilization`, + * bounded by the adapter's own position. Callers must only invoke this for markets whose current + * utilization is below the target. + */ +export const getWithdrawableAmount = ( + marketData: VaultV2MarketData, + targetUtilization: bigint +): bigint => + MathLib.min( + getWithdrawalToUtilization(marketData.state, targetUtilization), + marketData.vaultAssets + ) + +/** + * Remaining deposit headroom under one cap id, measured from `basis` — the id's effective post-leg + * allocation. The adapter trues `allocation(id)` up to the market's ACCRUED position on every + * touch, so `basis` must include accrual drift, not the stored allocation alone. A relative cap of + * exactly WAD is the contract's "no relative constraint" sentinel (`allocateInternal` skips the + * relative check entirely), never a binding 100%-of-totalAssets ceiling. + */ +export const getCapHeadroom = ( + cap: CapState, + basis: bigint, + totalAssets: bigint, + capBufferWad: bigint +): bigint => MathLib.zeroFloorSub(getBufferedCeiling(cap, totalAssets, capBufferWad), basis) + +// The binding buffered ceiling across both cap dimensions (see {@link getCapHeadroom} for the +// WAD-sentinel rule). +const getBufferedCeiling = (cap: CapState, totalAssets: bigint, capBufferWad: bigint): bigint => { + const absolute = MathLib.wMulDown(cap.absolute, capBufferWad) + if (cap.relative === MathLib.WAD) return absolute + return MathLib.min( + absolute, + MathLib.wMulDown(MathLib.wMulDown(totalAssets, cap.relative), capBufferWad) + ) +} + +// A pool can start UNDER water: accrual or a curator's cap reduction can leave the effective +// allocation above the buffered ceiling. The deficit must be repaid by deallocations before any +// credit becomes headroom — otherwise an allocation could restore the over-cap balance the plan's +// own deallocations just relieved. +const getCapRoom = ( + cap: CapState, + basis: bigint, + totalAssets: bigint, + capBufferWad: bigint +): CapRoom => { + const ceiling = getBufferedCeiling(cap, totalAssets, capBufferWad) + return { + headroom: MathLib.zeroFloorSub(ceiling, basis), + deficit: MathLib.zeroFloorSub(basis, ceiling) + } +} + +// The allocation true-up an allocate/deallocate leg applies to every id it touches: the accrued +// position minus the stored allocation (never negative — accrual only grows a position). +const accrualDrift = (marketData: VaultV2MarketData): bigint => + MathLib.zeroFloorSub(marketData.vaultAssets, marketData.cap.allocation) + +/** + * Assets allocatable into a market before its utilization would fall below `targetUtilization`, + * bounded by the market cap id's remaining headroom measured from the ACCRUED position (what + * `allocation(id)` becomes the moment the leg executes). Callers must only invoke this for markets + * whose current utilization is above the target; adapter-level and collateral-level ceilings are + * applied separately via {@link createDepositPools}. + */ +export const getDepositableAmount = ( + marketData: VaultV2MarketData, + totalAssets: bigint, + targetUtilization: bigint, + capBufferWad: bigint +): bigint => + MathLib.min( + getDepositToUtilization(marketData.state, targetUtilization), + getCapHeadroom( + marketData.cap, + MathLib.max(marketData.cap.allocation, marketData.vaultAssets), + totalAssets, + capBufferWad + ) + ) + +/** + * Shared deposit ceilings above the per-market caps: the adapter-level ("this") cap id and one pool + * per collateral cap id. Each pool's basis is the stored aggregate `allocation(id)` plus every + * member market's accrual drift (a touched market trues its drift into all three ids; counting + * untouched markets' drift too is safely conservative). Strategies credit their deallocation legs + * back via {@link creditPools} — the contract executes deallocations first, so that capacity is + * genuinely free — and draw allocation legs through {@link takeFromPools}, so a plan can never + * exceed an aggregate cap. + */ +type CapRoom = { headroom: bigint; deficit: bigint } + +type DepositPools = { + adapter: CapRoom + byCollateral: Map +} + +export const createDepositPools = (vaultData: VaultV2Data, capBufferWad: bigint): DepositPools => { + const totalDrift = vaultData.marketsData.reduce((acc, m) => acc + accrualDrift(m), 0n) + const driftByCollateral = new Map() + for (const marketData of vaultData.marketsData) { + const key = getAddress(marketData.params.collateralToken) + driftByCollateral.set(key, (driftByCollateral.get(key) ?? 0n) + accrualDrift(marketData)) + } + return { + adapter: getCapRoom( + vaultData.adapterCap, + vaultData.adapterCap.allocation + totalDrift, + vaultData.totalAssets, + capBufferWad + ), + byCollateral: new Map( + Object.entries(vaultData.collateralCaps).map(([token, cap]) => [ + getAddress(token), + getCapRoom( + cap, + cap.allocation + (driftByCollateral.get(getAddress(token)) ?? 0n), + vaultData.totalAssets, + capBufferWad + ) + ]) + ) + } +} + +const creditRoom = (room: CapRoom, amount: bigint): void => { + const repaid = MathLib.min(amount, room.deficit) + room.deficit -= repaid + room.headroom += amount - repaid +} + +/** + * Credits capacity freed by a deallocation leg (executed before every allocation leg). A pool's + * pre-existing over-cap deficit is repaid first — only the remainder becomes drawable headroom. + */ +export const creditPools = ( + pools: DepositPools, + collateralToken: Address, + amount: bigint +): void => { + const key = getAddress(collateralToken) + creditRoom(pools.adapter, amount) + const room = pools.byCollateral.get(key) ?? { headroom: 0n, deficit: 0n } + creditRoom(room, amount) + pools.byCollateral.set(key, room) +} + +/** Clamps `amount` to the adapter and collateral pools, decrements both, and returns the clamp. */ +export const takeFromPools = ( + pools: DepositPools, + collateralToken: Address, + amount: bigint +): bigint => { + const key = getAddress(collateralToken) + const room = pools.byCollateral.get(key) + const taken = MathLib.min(amount, MathLib.min(pools.adapter.headroom, room?.headroom ?? 0n)) + pools.adapter.headroom -= taken + if (room) room.headroom -= taken + return taken +} + +/** + * Converts a WAD-scaled APY to the equivalent per-second rate, approximating `ln(1 + apy)` with the + * first three Taylor terms (the inverse of {@link rateToApy}). + */ +export const apyToRate = (apy: bigint): bigint => { + const firstTerm = apy + const secondTerm = MathLib.wMulDown(firstTerm, firstTerm) + const thirdTerm = MathLib.wMulDown(secondTerm, firstTerm) + const apr = firstTerm - secondTerm / 2n + thirdTerm / 3n + return apr / SECONDS_PER_YEAR +} + +/** Compounds a per-second rate to a WAD-scaled APY (Blue's wTaylorCompounded). */ +export const rateToApy = (rate: bigint): bigint => MathLib.wTaylorCompounded(rate, SECONDS_PER_YEAR) + +/** Utilization at which the AdaptiveCurveIRM yields `rate`, given the market's `rateAtTarget`. */ +export const rateToUtilization = (rate: bigint, rateAtTarget: bigint): bigint => + AdaptiveCurveIrmLib.getUtilizationAtBorrowRate(rate, rateAtTarget) + +/** + * Instantaneous AdaptiveCurveIRM rate at `utilization`, given the market's `rateAtTarget`. + * Utilization is clamped to WAD — above 100% (bad-debt states) the curve's max rate applies. + */ +export const utilizationToRate = (utilization: bigint, rateAtTarget: bigint): bigint => + AdaptiveCurveIrmLib.getBorrowRate(MathLib.min(utilization, MathLib.WAD), rateAtTarget, 0n) + .endBorrowRate diff --git a/bots/vault-v2-reallocation/src/runner/tick.ts b/bots/vault-v2-reallocation/src/runner/tick.ts new file mode 100644 index 00000000..ebfa1a0a --- /dev/null +++ b/bots/vault-v2-reallocation/src/runner/tick.ts @@ -0,0 +1,167 @@ +import type { Logger, SimulateResult } from '@repo/bot-kit' +import type { Address, Hex } from 'viem' + +import { tryCatch } from '@repo/utils' +import { isAddressEqual } from 'viem' + +import type { Reallocation, ReallocationAction, Strategy } from '../strategies' +import type { VaultV2Data } from '../vault-data' + +export type TickDeps = { + vaults: Address[] + chainHead: bigint + /** + * The adapter the signing policy was pinned to at startup. A curator swapping the adapter + * mid-run would otherwise surface as an opaque PolicyViolationError on every submit — the tick + * skips the vault with an actionable `adapter.changed` instead (restart to re-pin). + */ + expectedAdapter: (vault: Address) => Address | undefined + fetchVault: (vault: Address, blockNumber: bigint) => Promise + strategy: Strategy + encodeReallocation: (vaultData: VaultV2Data, reallocation: Reallocation) => Hex + simulate: (vault: Address, data: Hex) => Promise + /** Resolves true only when the transaction was actually broadcast. */ + submit: (params: { vault: Address; data: Hex; blockNumber: bigint }) => Promise + /** When true, a sim-ok plan is logged (`reallocation.dry_run`) instead of submitted. */ + dryRun: boolean + /** Labels (vault addresses) with an in-flight or cooling-down tx — skipped this tick. */ + inflightLabels: () => ReadonlySet + revertReason: (error: unknown) => string + logger: Logger +} + +type VaultCounters = { + skipped_inflight: number + missing_role: number + adapter_changed: number + reallocations_found: number + sim_reverts: number + dry_runs: number + submitted: number + errors: number +} + +const NO_COUNTS: VaultCounters = { + skipped_inflight: 0, + missing_role: 0, + adapter_changed: 0, + reallocations_found: 0, + sim_reverts: 0, + dry_runs: 0, + submitted: 0, + errors: 0 +} + +const COUNTER_KEYS = Object.keys(NO_COUNTS) as (keyof VaultCounters)[] + +const legSummary = (action: 'allocate' | 'deallocate', leg: ReallocationAction) => ({ + action, + marketId: leg.marketId, + collateralToken: leg.marketParams.collateralToken, + lltv: leg.marketParams.lltv, + assets: leg.assets +}) + +const summarize = (reallocation: Reallocation) => [ + ...reallocation.deallocations.map(leg => legSummary('deallocate', leg)), + ...reallocation.allocations.map(leg => legSummary('allocate', leg)) +] + +const processVault = async (deps: TickDeps, vault: Address): Promise => { + const vaultData = await deps.fetchVault(vault, deps.chainHead) + + // Strict `isAllocator(eoa)`, read in the snapshot's single call (see {@link VaultV2Data}); a + // vault the EOA cannot reallocate is skipped, and resumes on its own once the role is granted. + if (!vaultData.isAllocator) { + deps.logger.warn('allocator.missing_role', { vault }) + return { ...NO_COUNTS, missing_role: 1 } + } + + const expectedAdapter = deps.expectedAdapter(vault) + if (expectedAdapter !== undefined && !isAddressEqual(vaultData.adapterAddress, expectedAdapter)) { + deps.logger.warn('adapter.changed', { + vault, + expected: expectedAdapter, + actual: vaultData.adapterAddress, + detail: 'restart the bot to re-pin the signing policy to the new adapter' + }) + return { ...NO_COUNTS, adapter_changed: 1 } + } + + // Surfaced because `apy-range` excludes these outright — the curve inversion it relies on needs a + // real AdaptiveCurveIRM `rateAtTarget` (`equalize-utilizations` keeps them). + if (vaultData.nonAdaptiveCurveMarketIds.length > 0) { + deps.logger.debug('market.non_adaptive_curve', { + vault, + markets: vaultData.nonAdaptiveCurveMarketIds + }) + } + + const reallocation = deps.strategy(vaultData) + if (!reallocation) return NO_COUNTS + + const summary = summarize(reallocation) + deps.logger.info('reallocation.found', { vault, legs: summary.length, allocations: summary }) + + const data = deps.encodeReallocation(vaultData, reallocation) + const sim = await deps.simulate(vault, data) + if (sim.status === 'revert') { + deps.logger.warn('reallocation.sim_revert', { vault, reason: sim.reason }) + return { ...NO_COUNTS, reallocations_found: 1, sim_reverts: 1 } + } + + if (deps.dryRun) { + // The plan itself was just logged by reallocation.found — this line only marks the decision. + deps.logger.info('reallocation.dry_run', { vault }) + return { ...NO_COUNTS, reallocations_found: 1, dry_runs: 1 } + } + + const sent = await deps.submit({ vault, data, blockNumber: deps.chainHead }) + if (!sent) deps.logger.debug('reallocation.not_broadcast', { vault }) + return { ...NO_COUNTS, reallocations_found: 1, submitted: sent ? 1 : 0 } +} + +/** + * One reallocation pass: every whitelisted vault is processed concurrently — skip if a tx is in + * flight, fetch a block-pinned snapshot (one deployless `eth_call`, allocator bit included), skip + * (loudly) if the EOA lacks the role or the vault's adapter changed since startup, run the + * strategy, simulate the exact multicall bytes, and submit (or dry-run-log) on sim-ok. A failure in + * one vault logs `vault.error` and never blocks the others; counters are folded after every vault + * settles and closed by one wide `tick.end` line. + */ +export const runTick = async (deps: TickDeps): Promise => { + const started = Date.now() + const inflight = deps.inflightLabels() + + // The mapper cannot reject — `processVault` is wrapped in `tryCatch` and every branch returns a + // counter set — so `Promise.all` never short-circuits a vault. + const results = await Promise.all( + deps.vaults.map(async (vault): Promise => { + if (inflight.has(vault)) { + deps.logger.debug('vault.inflight', { vault }) + return { ...NO_COUNTS, skipped_inflight: 1 } + } + const { data, error } = await tryCatch(processVault(deps, vault)) + if (error) { + deps.logger.error('vault.error', { vault, reason: deps.revertReason(error) }) + return { ...NO_COUNTS, errors: 1 } + } + return data + }) + ) + + const counters = results.reduce( + (acc, result) => { + for (const key of COUNTER_KEYS) acc[key] += result[key] + return acc + }, + { ...NO_COUNTS } + ) + + deps.logger.info('tick.end', { + blockNumber: deps.chainHead, + vaults: deps.vaults.length, + ...counters, + duration_ms: Date.now() - started + }) +} diff --git a/bots/vault-v2-reallocation/src/state/lens.sol.ts b/bots/vault-v2-reallocation/src/state/lens.sol.ts new file mode 100644 index 00000000..2a6d816c --- /dev/null +++ b/bots/vault-v2-reallocation/src/state/lens.sol.ts @@ -0,0 +1,368 @@ +import type { InputMarketParams } from '@morpho-org/blue-sdk' +import type { Address, Client, Hex, Transport } from 'viem' + +import { + type BatchLensTransportType, + MAX_INITCODE_SIZE, + readDeploylessBatchLens +} from '@repo/utils' +import { sol } from 'soltag' + +// Single-file soltag lens: reads everything a reallocation pass needs for a batch of vaults inside +// ONE eth_call against one pinned block — the VaultV2 factory identity, the EOA's allocator bit, +// the idle balance, every configured adapter (factory-classified by generation), and per market the +// params, accrued Blue state, the adapter's position, the IRM's rateAtTarget, and the market- and +// collateral-level cap state; the adapter-level ("this") cap state rides the same row. It replaces +// `fetchAccrualVaultV2`'s per-market fan-out plus the separate cap multicall and `isAllocator` read +// with a single billed call, and it removes the client-side accrual entirely: `Morpho.accrueInterest` +// runs inside the simulation, so every read below is exactly what the market holds at that block. +// +// `vault.totalAssets()` is read LAST: VaultV2's `accrueInterestView` sums each adapter's +// `realAssets()` — which reads the Blue positions this lens just accrued — so reading it after the +// per-market accruals yields the same value the reallocation tx's own first accrual will lock in as +// `firstTotalAssets` (the basis the contract checks relative caps against). +// +// The `lens` entrypoint is state-changing (`accrueInterest` is `nonpayable`); nothing escapes the +// `eth_call` — the simulation's writes are discarded — and `readDeploylessBatchLens` accepts +// nonpayable lens entrypoints for exactly this pattern. +// +// The soltag type codegen (`.soltag/types.d.ts`, regenerated by the `soltag` CLI before typecheck) +// narrows the compiled `.abi`, so viem encodes the inputs and decodes the structs natively; no +// hand-written ABI fragment and no manual abi.encode/decode. Compiled to a deployless factory by +// soltag's Vitest/esbuild integrations; `sol``` throws if the transform is not active. +export const VaultV2ReallocationLens = sol('VaultV2ReallocationLens')` +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.19; + +struct MarketParams { address loanToken; address collateralToken; address oracle; address irm; uint256 lltv; } +struct Market { + uint128 totalSupplyAssets; + uint128 totalSupplyShares; + uint128 totalBorrowAssets; + uint128 totalBorrowShares; + uint128 lastUpdate; + uint128 fee; +} +struct Position { uint256 supplyShares; uint128 borrowShares; uint128 collateral; } + +interface IMorpho { + function market(bytes32 id) external view returns (Market memory); + function position(bytes32 id, address user) external view returns (Position memory); + function idToMarketParams(bytes32 id) external view returns (MarketParams memory); + function accrueInterest(MarketParams memory marketParams) external; +} + +interface IVaultV2 { + function asset() external view returns (address); + function totalAssets() external view returns (uint256); + function isAllocator(address account) external view returns (bool); + function adaptersLength() external view returns (uint256); + function adapters(uint256 index) external view returns (address); + function absoluteCap(bytes32 id) external view returns (uint256); + function relativeCap(bytes32 id) external view returns (uint256); + function allocation(bytes32 id) external view returns (uint256); +} + +interface IVaultV2Factory { function isVaultV2(address account) external view returns (bool); } +interface IMarketV1AdapterFactory { function isMorphoMarketV1Adapter(address account) external view returns (bool); } +interface IMarketV1AdapterV2Factory { function isMorphoMarketV1AdapterV2(address account) external view returns (bool); } + +// The two Morpho Blue market adapter generations enumerate their markets differently (a params list +// vs an id list) but take identical (de)allocate calldata and derive identical cap ids. +interface IMarketV1Adapter { + function marketParamsListLength() external view returns (uint256); + function marketParamsList(uint256 index) external view returns (MarketParams memory); +} + +interface IMarketV1AdapterV2 { + function marketIdsLength() external view returns (uint256); + function marketIds(uint256 index) external view returns (bytes32); + function supplyShares(bytes32 id) external view returns (uint256); +} + +interface IERC20 { function balanceOf(address account) external view returns (uint256); } +interface IAdaptiveCurveIrm { function rateAtTarget(bytes32 id) external view returns (int256); } + +contract VaultV2ReallocationLens { + uint256 internal constant VIRTUAL_SHARES = 1e6; + uint256 internal constant VIRTUAL_ASSETS = 1; + + // Factory-verified adapter generations; anything else (e.g. a MorphoVaultV1Adapter) stays 0 and + // the fetcher fails the vault loud. + uint8 internal constant KIND_UNKNOWN = 0; + uint8 internal constant KIND_MARKET_V1 = 1; + uint8 internal constant KIND_MARKET_V1_V2 = 2; + + IMorpho public immutable MORPHO; + address public immutable ADAPTIVE_CURVE_IRM; + IVaultV2Factory public immutable VAULT_V2_FACTORY; + address public immutable MARKET_V1_ADAPTER_FACTORY; + address public immutable MARKET_V1_ADAPTER_V2_FACTORY; + + // One vault, plus the EOA whose allocator bit is being probed. Role data rides the same call as + // the market snapshot, so the tick needs no separate isAllocator read. + struct Input { address vault; address eoa; } + + // One cap id's state, exactly the triple the vault enforces (de)allocations against. + struct CapsOut { uint256 absoluteCap; uint256 relativeCap; uint256 allocation; } + + struct AdapterOut { address adapter; uint8 kind; } + + struct MarketOut { + bytes32 id; + bytes32 capId; // keccak256(abi.encode("this/marketParams", adapter, params)) + MarketParams params; + uint256 totalSupplyAssets; // post-accrual + uint256 totalBorrowAssets; // post-accrual + CapsOut cap; // the market's own cap id + CapsOut collateralCap; // keccak256(abi.encode("collateralToken", collateral)); deduped client-side + uint256 vaultAssets; // the adapter's supply shares converted down, post-accrual + uint256 rateAtTarget; // 0 unless irm is the chain's canonical AdaptiveCurveIRM + } + + struct VaultOut { + bool isVaultV2; // factory identity; when false every other field is left zeroed + bool isAllocator; + uint256 totalAssets; // post-accrual (see the module comment on read ordering) + uint256 idleAssets; // asset.balanceOf(vault) + AdapterOut[] adapters; + CapsOut adapterCap; // keccak256(abi.encode("this", adapter)); zeroed unless one market adapter qualifies + MarketOut[] markets; // enumeration order of the single qualifying adapter + } + + constructor( + IMorpho morpho, + address adaptiveCurveIrm, + IVaultV2Factory vaultV2Factory, + address marketV1AdapterFactory, + address marketV1AdapterV2Factory + ) { + MORPHO = morpho; + ADAPTIVE_CURVE_IRM = adaptiveCurveIrm; + VAULT_V2_FACTORY = vaultV2Factory; + MARKET_V1_ADAPTER_FACTORY = marketV1AdapterFactory; + MARKET_V1_ADAPTER_V2_FACTORY = marketV1AdapterV2Factory; + } + + // SharesMathLib.toAssetsDown: the adapter's own position, rounded against the vault. + function _toAssetsDown(uint256 shares, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { + return (shares * (totalAssets + VIRTUAL_ASSETS)) / (totalShares + VIRTUAL_SHARES); + } + + // No per-element try/catch, unlike the liquidation lenses: the fetcher submits one vault per call, + // so a revert IS that vault's failure and the real reason should reach the tick's vault.error log + // rather than being flattened into a valid=false row. + function lens(Input[] calldata input) external returns (VaultOut[] memory output) { + output = new VaultOut[](input.length); + for (uint256 i = 0; i < input.length; i++) output[i] = _readVault(input[i]); + } + + function _adapterKind(address adapter) internal view returns (uint8) { + if ( + MARKET_V1_ADAPTER_FACTORY != address(0) && + IMarketV1AdapterFactory(MARKET_V1_ADAPTER_FACTORY).isMorphoMarketV1Adapter(adapter) + ) return KIND_MARKET_V1; + if ( + MARKET_V1_ADAPTER_V2_FACTORY != address(0) && + IMarketV1AdapterV2Factory(MARKET_V1_ADAPTER_V2_FACTORY).isMorphoMarketV1AdapterV2(adapter) + ) return KIND_MARKET_V1_V2; + return KIND_UNKNOWN; + } + + function _caps(IVaultV2 vault, bytes32 id) internal view returns (CapsOut memory) { + return CapsOut({ + absoluteCap: vault.absoluteCap(id), + relativeCap: vault.relativeCap(id), + allocation: vault.allocation(id) + }); + } + + function _readVault(Input calldata e) internal returns (VaultOut memory o) { + o.isVaultV2 = VAULT_V2_FACTORY.isVaultV2(e.vault); + // Nothing else about a non-factory address is safe to call; the fetcher rejects on this bit. + if (!o.isVaultV2) return o; + + IVaultV2 vault = IVaultV2(e.vault); + o.isAllocator = vault.isAllocator(e.eoa); + o.idleAssets = IERC20(vault.asset()).balanceOf(e.vault); + + uint256 count = vault.adaptersLength(); + o.adapters = new AdapterOut[](count); + uint256 qualifying = 0; + uint256 marketAdapterIndex = 0; + for (uint256 i = 0; i < count; i++) { + address adapter = vault.adapters(i); + uint8 kind = _adapterKind(adapter); + o.adapters[i] = AdapterOut({ adapter: adapter, kind: kind }); + if (kind != KIND_UNKNOWN) { + qualifying++; + marketAdapterIndex = i; + } + } + + // Markets are read only for the shape the bot supports (one adapter, and it is a Morpho Blue + // market adapter); any other shape returns bare adapter rows for the fetcher's error message. + if (count == 1 && qualifying == 1) { + AdapterOut memory qualified = o.adapters[marketAdapterIndex]; + o.markets = qualified.kind == KIND_MARKET_V1 + ? _readV1Markets(vault, qualified.adapter) + : _readV2Markets(vault, qualified.adapter); + o.adapterCap = _caps(vault, keccak256(abi.encode("this", qualified.adapter))); + } + + // AFTER the per-market accruals — accrueInterestView folds the adapters' realAssets. + o.totalAssets = vault.totalAssets(); + } + + function _readV1Markets(IVaultV2 vault, address adapter) internal returns (MarketOut[] memory markets) { + uint256 length = IMarketV1Adapter(adapter).marketParamsListLength(); + markets = new MarketOut[](length); + for (uint256 i = 0; i < length; i++) { + MarketParams memory params = IMarketV1Adapter(adapter).marketParamsList(i); + // MarketParamsLib.id: the Blue market id is the hash of its params. + bytes32 id = keccak256(abi.encode(params)); + markets[i] = _readMarket(vault, adapter, id, params, MORPHO.position(id, adapter).supplyShares); + } + } + + function _readV2Markets(IVaultV2 vault, address adapter) internal returns (MarketOut[] memory markets) { + uint256 length = IMarketV1AdapterV2(adapter).marketIdsLength(); + markets = new MarketOut[](length); + for (uint256 i = 0; i < length; i++) { + bytes32 id = IMarketV1AdapterV2(adapter).marketIds(i); + markets[i] = _readMarket( + vault, + adapter, + id, + MORPHO.idToMarketParams(id), + IMarketV1AdapterV2(adapter).supplyShares(id) + ); + } + } + + function _readMarket( + IVaultV2 vault, + address adapter, + bytes32 id, + MarketParams memory params, + uint256 shares + ) internal returns (MarketOut memory o) { + // Accrue FIRST. Every read after this line — the market totals, the adapter's assets, and the + // IRM's STORED rateAtTarget, which only advances when Blue calls borrowRate — is then the + // exact on-chain state at this block, with no client-side accrual to keep in sync. + MORPHO.accrueInterest(params); + Market memory m = MORPHO.market(id); + + o.id = id; + o.params = params; + o.totalSupplyAssets = m.totalSupplyAssets; + o.totalBorrowAssets = m.totalBorrowAssets; + o.vaultAssets = _toAssetsDown(shares, m.totalSupplyAssets, m.totalSupplyShares); + + if (params.irm == ADAPTIVE_CURVE_IRM) { + int256 signedRate = IAdaptiveCurveIrm(params.irm).rateAtTarget(id); + if (signedRate > 0) o.rateAtTarget = uint256(signedRate); + } + + o.capId = keccak256(abi.encode("this/marketParams", adapter, params)); + o.cap = _caps(vault, o.capId); + o.collateralCap = _caps(vault, keccak256(abi.encode("collateralToken", params.collateralToken))); + } +} +` + +/** One vault to read, plus the EOA whose `isAllocator` bit is wanted alongside its snapshot. */ +type LensInput = { vault: Address; eoa: Address } + +/** The decoded Solidity `CapsOut` struct. */ +export type LensCapsOut = { + absoluteCap: bigint + relativeCap: bigint + allocation: bigint +} + +/** The decoded Solidity `AdapterOut` struct; `kind` follows the lens's KIND_* constants. */ +export type LensAdapterOut = { + adapter: Address + kind: number +} + +/** The decoded Solidity `MarketOut` struct (uint* → bigint per viem). */ +export type LensMarketOut = { + id: Hex + capId: Hex + params: InputMarketParams + totalSupplyAssets: bigint + totalBorrowAssets: bigint + cap: LensCapsOut + collateralCap: LensCapsOut + vaultAssets: bigint + rateAtTarget: bigint +} + +/** The decoded Solidity `VaultOut` struct. */ +export type LensVaultOut = { + isVaultV2: boolean + isAllocator: boolean + totalAssets: bigint + idleAssets: bigint + adapters: readonly LensAdapterOut[] + adapterCap: LensCapsOut + markets: readonly LensMarketOut[] +} + +export const KIND_UNKNOWN = 0 + +/** + * Reads the reallocation lens for every vault in one deployless `eth_call`, pinned to `blockNumber` + * so the whole snapshot is coherent across markets and reproducible. Returns a map keyed by the + * lower-cased vault address. + * + * Throws whatever the lens reverted with — the caller (one vault per call, see `fetchVaultV2Data`) + * treats that as that vault's failure. The `client` must use viem-dlc's deployless transport (built + * by bot-kit's `createDeploylessClient`). + */ +export const readVaultV2Lens = async ( + client: Client>, + addresses: { + morpho: Address + adaptiveCurveIrm: Address + vaultV2Factory: Address + marketV1AdapterFactory: Address + marketV1AdapterV2Factory: Address + }, + vaults: readonly LensInput[], + blockNumber: bigint +): Promise> => { + const compiled = VaultV2ReallocationLens.with( + addresses.morpho, + addresses.adaptiveCurveIrm, + addresses.vaultV2Factory, + addresses.marketV1AdapterFactory, + addresses.marketV1AdapterV2Factory + ) + return readDeploylessBatchLens( + client, + { + ...compiled, + functionName: 'lens', + args: [vaults], + blockNumber, + batch: { + batchSize: MAX_INITCODE_SIZE, + exfil: 'revert', + compress: false, + // A vault is a coarse batch element: it fans out ~11 sub-calls per market (params/id, + // accrual, market, position or supplyShares, rateAtTarget, and six cap reads) plus the + // vault-level reads, and `totalAssets()` re-walks every adapter's realAssets on top — + // `accrueInterest` is the expensive one (IRM borrowRate + storage writes). `linear` + // budgets a deep market list; `constant` folds in the deployless CREATE + code-deposit + // overhead. Under-budgeting is self-correcting — viem-dlc's chunker halve-and-retries any + // batch over the RPC gas cap — and the production fetcher submits one vault per call anyway. + gas: { default: { constant: 2_000_000, linear: 8_000_000, quadratic: 0 } } + } + }, + ({ vault }) => vault.toLowerCase(), + (_input, out): LensVaultOut => out + ) +} diff --git a/bots/vault-v2-reallocation/src/strategies/apy-range.ts b/bots/vault-v2-reallocation/src/strategies/apy-range.ts new file mode 100644 index 00000000..8bbf564c --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/apy-range.ts @@ -0,0 +1,79 @@ +import type { Address, Hex } from 'viem' + +import type { Strategy } from './strategy' + +import { + apyToRate, + getUtilization, + MAX_TARGET_UTILIZATION, + rateToApy, + rateToUtilization, + utilizationToRate, + wadToBips +} from '../math' +import { createReconciler } from './reconcile' + +export type ApyRangeConfig = { + /** Whether excess deallocations may be parked in the vault's idle balance. */ + allowIdleReallocation: boolean + /** WAD-scaled cap scale factor (e.g. 99.99% as 0.9999e18). */ + capBufferWad: bigint + /** WAD-scaled borrow-APY bounds for (vault, market). */ + apyRange: (vault: Address, marketId: Hex) => { min: bigint; max: bigint } + /** Firing threshold: at least one market's implied APY move must exceed this (bips). */ + minApyDeltaBips: (vault: Address, marketId: Hex) => number +} + +const apyDeltaBips = (from: bigint, to: bigint, rateAtTarget: bigint): number => + Math.abs( + wadToBips( + rateToApy(utilizationToRate(to, rateAtTarget)) - + rateToApy(utilizationToRate(from, rateAtTarget)) + ) + ) + +/** + * Keeps each market's borrow APY inside its configured range: converts the APY bounds to + * utilization bounds via the AdaptiveCurveIRM inverse, so a market above range targets its upper + * bound (an allocation) and one below range targets its lower bound (a deallocation), with the + * imbalance netted through the vault's idle balance. + * + * Only markets on the canonical AdaptiveCurveIRM participate — the inversion is meaningless without + * a real `rateAtTarget`, so a foreign-IRM market is excluded from both legs rather than misread + * (see `isAdaptiveCurve` on the market data). + */ +export const createApyRangeStrategy = (config: ApyRangeConfig): Strategy => + createReconciler({ + capBufferWad: config.capBufferWad, + allowIdleParking: config.allowIdleReallocation, + classifierFor: + ({ vaultAddress }) => + marketData => { + if (!marketData.isAdaptiveCurve) return undefined + + const { rateAtTarget } = marketData + const apyRange = config.apyRange(vaultAddress, marketData.id) + const utilization = getUtilization(marketData.state) + const lowerBound = rateToUtilization(apyToRate(apyRange.min), rateAtTarget) + const upperBound = rateToUtilization(apyToRate(apyRange.max), rateAtTarget) + + const boundFor = (u: bigint): bigint | undefined => { + if (u > upperBound) return upperBound + if (u < lowerBound) return lowerBound + return undefined + } + const bound = boundFor(utilization) + if (bound === undefined) return undefined + // The leg only travels to the clamped bound, so the firing gate measures that realizable + // move — while the side stays decided by the raw bound (see MarketTarget.intent). + const targetUtilization = bound > MAX_TARGET_UTILIZATION ? MAX_TARGET_UTILIZATION : bound + + return { + targetUtilization, + intent: utilization > bound ? 'allocate' : 'deallocate', + clearsMinDelta: utilizationAfter => + apyDeltaBips(utilization, utilizationAfter, rateAtTarget) > + config.minApyDeltaBips(vaultAddress, marketData.id) + } + } + }) diff --git a/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts new file mode 100644 index 00000000..3a68fefc --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/equalize-utilizations.ts @@ -0,0 +1,64 @@ +import type { Address } from 'viem' + +import { MathLib } from '@morpho-org/blue-sdk' +import { isNonZeroAddress } from '@repo/utils' + +import type { VaultV2MarketData } from '../vault-data' +import type { Strategy } from './strategy' + +import { getUtilization, MAX_TARGET_UTILIZATION, wadToBips } from '../math' +import { createReconciler } from './reconcile' + +type EqualizeUtilizationsConfig = { + /** WAD-scaled cap scale factor (e.g. 99.99% as 0.9999e18). */ + capBufferWad: bigint + /** Firing threshold: at least one market's utilization must deviate from target by this (bips). */ + minUtilizationDeltaBips: (vault: Address) => number +} + +const { min, wDivDown } = MathLib + +const isRealCollateral = (marketData: VaultV2MarketData): boolean => + isNonZeroAddress(marketData.params.collateralToken) + +/** + * Converges every market toward the vault-wide average utilization + * (`sum(totalBorrowAssets) / (sum(totalSupplyAssets) + idleAssets)` — idle counts + * as deployable supply): deallocates from markets below it, allocates into markets above it, with + * the imbalance netted through the vault's idle balance (excess deallocations always park there). + * Fires only when at least one contributing market's deviation exceeds the vault's min-delta + * threshold. Utilization-only, so markets on any IRM participate. + */ +export const createEqualizeUtilizationsStrategy = (config: EqualizeUtilizationsConfig): Strategy => + createReconciler({ + capBufferWad: config.capBufferWad, + allowIdleParking: true, + classifierFor: vaultData => { + const marketsData = vaultData.marketsData.filter(isRealCollateral) + const totalSupply = marketsData.reduce( + (acc, m) => acc + m.state.totalSupplyAssets, + vaultData.idleAssets + ) + const totalBorrow = marketsData.reduce((acc, m) => acc + m.state.totalBorrowAssets, 0n) + // Nothing supplied or nothing borrowed anywhere — every market already sits at the + // (degenerate) target, and the per-market target math below would divide by zero. + if (totalSupply === 0n || totalBorrow === 0n) return () => undefined + + const minUtilizationDeltaBips = config.minUtilizationDeltaBips(vaultData.vaultAddress) + // May exceed WAD in bad-debt states — the raw aggregate decides each market's side (intent), + // while sizing and the firing gate use the clamped target the emitted leg actually realizes. + const rawTarget = wDivDown(totalBorrow, totalSupply) + const targetUtilization = min(rawTarget, MAX_TARGET_UTILIZATION) + + return marketData => { + if (!isRealCollateral(marketData)) return undefined + const utilization = getUtilization(marketData.state) + return { + targetUtilization, + intent: utilization > rawTarget ? 'allocate' : 'deallocate', + clearsMinDelta: utilizationAfter => + Math.abs(wadToBips(utilization - utilizationAfter)) > minUtilizationDeltaBips + } + } + } + }) diff --git a/bots/vault-v2-reallocation/src/strategies/index.ts b/bots/vault-v2-reallocation/src/strategies/index.ts new file mode 100644 index 00000000..d009921d --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/index.ts @@ -0,0 +1,43 @@ +import { assertNever, wholePercentToWAD } from '@repo/utils' + +import type { Config } from '../config' +import type { Strategy } from './strategy' + +import { CAP_BUFFER_WAD } from '../math' +import { + resolveApyRange, + resolveMinApyDeltaBips, + resolveMinUtilizationDeltaBips +} from '../strategy-config' +import { createApyRangeStrategy } from './apy-range' +import { createEqualizeUtilizationsStrategy } from './equalize-utilizations' + +export type { Reallocation, ReallocationAction, Strategy } from './strategy' + +/** + * Builds the configured {@link Strategy}, binding the checked-in strategy-config tables (market > + * vault > env-default precedence) and the env-level knobs to a pure function of one vault's data. + */ +export const createStrategy = (config: Config): Strategy => { + switch (config.strategy) { + case 'apy-range': + return createApyRangeStrategy({ + allowIdleReallocation: config.allowIdleReallocation, + capBufferWad: CAP_BUFFER_WAD, + apyRange: (vault, marketId) => { + const range = resolveApyRange(config.chainId, vault, marketId) + return { min: wholePercentToWAD(range.min), max: wholePercentToWAD(range.max) } + }, + minApyDeltaBips: (vault, marketId) => + resolveMinApyDeltaBips(config.chainId, vault, marketId, config.minApyDeltaBips) + }) + case 'equalize-utilizations': + return createEqualizeUtilizationsStrategy({ + capBufferWad: CAP_BUFFER_WAD, + minUtilizationDeltaBips: vault => + resolveMinUtilizationDeltaBips(config.chainId, vault, config.minUtilizationDeltaBips) + }) + default: + return assertNever(config.strategy) + } +} diff --git a/bots/vault-v2-reallocation/src/strategies/reconcile.ts b/bots/vault-v2-reallocation/src/strategies/reconcile.ts new file mode 100644 index 00000000..d92a1ec3 --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/reconcile.ts @@ -0,0 +1,236 @@ +import { MathLib } from '@morpho-org/blue-sdk' +import { getAddress } from 'viem' + +import type { VaultV2Data, VaultV2MarketData } from '../vault-data' +import type { ReallocationAction, Strategy } from './strategy' + +import { + createDepositPools, + creditPools, + getDepositableAmount, + getUtilization, + getUtilizationAfter, + getWithdrawableAmount, + MAX_TARGET_UTILIZATION, + takeFromPools +} from '../math' + +/** Which way a classifier wants a market to move, decided on its RAW (unclamped) bound. */ +export type MoveIntent = 'allocate' | 'deallocate' + +/** Where one market should sit, and whether getting it there is worth a transaction. */ +export type MarketTarget = { + /** Already clamped to {@link MAX_TARGET_UTILIZATION}; the move is sized against this. */ + targetUtilization: bigint + /** + * The direction the raw bound asked for. Carried separately because the clamp can pull the target + * to the near side of current utilization, and re-deriving the side from the clamped target would + * then emit the opposite leg. + */ + intent: MoveIntent + /** + * Whether a move that lands this market at `utilizationAfter` clears the min-delta. The reconciler + * calls it with the REALIZED endpoint of the trimmed leg — a full move lands at the clamped + * target, but a leg cut down by the budget or the cap pools lands short, and a fragment must not + * arm the plan on the delta its classifier originally wanted. + */ + clearsMinDelta: (utilizationAfter: bigint) => boolean +} + +/** Verdict for one market; `undefined` leaves the market out of the plan entirely. */ +export type Classify = (marketData: VaultV2MarketData) => MarketTarget | undefined + +type ReconcilerOptions = { + /** WAD-scaled cap scale factor (e.g. 99.99% as 0.9999e18). */ + capBufferWad: bigint + /** + * Whether excess deallocations may park in the vault's idle balance; when off, deallocations are + * clamped to the allocation total. Allocations always draw on idle (up to `idleAssets`) — beyond + * that `allocate` would pull more than the vault balance holds and revert. + */ + allowIdleParking: boolean + /** Built per vault so a strategy's target may depend on vault-wide aggregates. */ + classifierFor: (vaultData: VaultV2Data) => Classify +} + +type SizedMove = { + marketData: VaultV2MarketData + amount: bigint + clearsMinDelta: MarketTarget['clearsMinDelta'] +} + +const { min } = MathLib + +const toLeg = ({ marketData }: SizedMove, assets: bigint): ReallocationAction => ({ + marketId: marketData.id, + marketParams: marketData.params, + assets +}) + +/** + * Turns a per-market target-utilization classifier into a {@link Strategy}: sizes each market's move + * with the clamped Blue math and the three-level cap pools, nets the imbalance through the vault's + * idle balance, trims both sides to the shared budget in market order, and emits the + * `{allocations, deallocations}` delta legs. + * + * The min-delta firing gate is evaluated on the TRIMMED legs' REALIZED endpoints: each surviving + * take is converted back to the utilization it lands the market at, and the classifier judges that + * delta — a clearing move cut down to a fragment by the budget or the cap pools cannot arm the + * plan, so a fired plan always contains at least one leg worth its transaction. + * + * Deallocations resolve FIRST in both phases — the contract executes them first, so their amounts + * credit the aggregate cap pools that allocation sizing then draws from; the emission phase re-runs + * the pools with the TRIMMED deallocation credits so per-collateral funding stays exact even when + * the deallocation budget clamps. + * + * This is the one place in the bot that builds legs; classifiers never size or trim. A classifier + * DOES decide the side (`intent`, off its raw bound) and hands over an already-clamped target — see + * {@link MarketTarget} and {@link MAX_TARGET_UTILIZATION}. + */ +export const createReconciler = (options: ReconcilerOptions): Strategy => { + return vaultData => { + const classify = options.classifierFor(vaultData) + const classified = vaultData.marketsData.flatMap(marketData => { + const verdict = classify(marketData) + if (verdict === undefined) return [] + // Inert backstop — classifiers already clamp. Kept so a future classifier that forgets + // cannot size a leg against a >99.9% target. + const targetUtilization = min(verdict.targetUtilization, MAX_TARGET_UTILIZATION) + const utilization = getUtilization(marketData.state) + const side = verdict.intent + // An empty-or-backwards move is no move. The clamp can pull the target to the near side of + // current utilization, and sizing the opposite leg would invert what the classifier asked + // for; this generalizes the at-target skip to the whole wrong-side span. Deliberately NOT a + // market-level policy skip — a dead cold market is still exited whenever it sits below the + // clamped target. + if ( + side === 'deallocate' ? utilization >= targetUtilization : utilization <= targetUtilization + ) { + return [] + } + return [ + { marketData, side, target: { targetUtilization, clearsMinDelta: verdict.clearsMinDelta } } + ] + }) + + const deallocateMoves: SizedMove[] = [] + const allocateMoves: SizedMove[] = [] + let totalAmountToDeallocate = 0n + let totalAmountToAllocate = 0n + + const sizingPools = createDepositPools(vaultData, options.capBufferWad) + for (const { marketData, side, target } of classified) { + if (side !== 'deallocate') continue + const amount = getWithdrawableAmount(marketData, target.targetUtilization) + totalAmountToDeallocate += amount + creditPools(sizingPools, marketData.params.collateralToken, amount) + if (amount > 0n) { + deallocateMoves.push({ marketData, amount, clearsMinDelta: target.clearsMinDelta }) + } + } + for (const { marketData, side, target } of classified) { + if (side !== 'allocate') continue + const amount = takeFromPools( + sizingPools, + marketData.params.collateralToken, + getDepositableAmount( + marketData, + vaultData.totalAssets, + target.targetUtilization, + options.capBufferWad + ) + ) + totalAmountToAllocate += amount + if (amount > 0n) { + allocateMoves.push({ marketData, amount, clearsMinDelta: target.clearsMinDelta }) + } + } + + if (totalAmountToDeallocate > totalAmountToAllocate && !options.allowIdleParking) { + totalAmountToDeallocate = totalAmountToAllocate + } else if (totalAmountToAllocate > totalAmountToDeallocate) { + // `allocate` pulls from the vault's asset balance: only this plan's own deallocations plus + // the existing idle can fund allocations — anything beyond reverts the whole multicall. + totalAmountToAllocate = + totalAmountToDeallocate + + min(totalAmountToAllocate - totalAmountToDeallocate, vaultData.idleAssets) + } + + if (min(totalAmountToDeallocate, totalAmountToAllocate) === 0n) return undefined + + let remainingAmountToDeallocate = totalAmountToDeallocate + let remainingAmountToAllocate = totalAmountToAllocate + + type EmittedLeg = { move: SizedMove; assets: bigint } + const emittedDeallocations: EmittedLeg[] = [] + const emittedAllocations: EmittedLeg[] = [] + + const legPools = createDepositPools(vaultData, options.capBufferWad) + for (const move of deallocateMoves) { + if (remainingAmountToDeallocate === 0n) break + // Sized amounts and the remaining budget are both positive here, so the take always is. + const toDeallocate = min(move.amount, remainingAmountToDeallocate) + remainingAmountToDeallocate -= toDeallocate + creditPools(legPools, move.marketData.params.collateralToken, toDeallocate) + emittedDeallocations.push({ move, assets: toDeallocate }) + } + for (const move of allocateMoves) { + if (remainingAmountToAllocate === 0n) break + const toAllocate = takeFromPools( + legPools, + move.marketData.params.collateralToken, + min(move.amount, remainingAmountToAllocate) + ) + remainingAmountToAllocate -= toAllocate + if (toAllocate > 0n) emittedAllocations.push({ move, assets: toAllocate }) + } + + if (!options.allowIdleParking) { + // The deallocation budget trim can keep a leg whose planned counterpart allocation was then + // clamped away by the cap pools — the sides no longer match, and the mismatch would park in + // idle against the operator's setting. Shrink deallocations in reverse by credits no + // allocation consumed: each reduction is bounded by the leg's own collateral leftover and + // the shared adapter leftover, so every emitted allocation keeps its on-chain funding. + const allocated = emittedAllocations.reduce((acc, leg) => acc + leg.assets, 0n) + let surplus = emittedDeallocations.reduce((acc, leg) => acc + leg.assets, 0n) - allocated + for (let i = emittedDeallocations.length - 1; i >= 0 && surplus > 0n; i--) { + const leg = emittedDeallocations[i]! + const room = legPools.byCollateral.get( + getAddress(leg.move.marketData.params.collateralToken) + ) + const reducible = min( + min(surplus, leg.assets), + min(legPools.adapter.headroom, room?.headroom ?? 0n) + ) + leg.assets -= reducible + legPools.adapter.headroom -= reducible + if (room) room.headroom -= reducible + surplus -= reducible + } + // Unreachable by construction — every credit either funded an allocation or is still pool + // leftover — but refusing to plan beats parking assets in idle. + if (surplus > 0n) return undefined + } + + // The min-delta gate judges the FINAL legs' realized endpoints (see the TSDoc above). + let didClearMinDelta = false + const allocations: ReallocationAction[] = [] + const deallocations: ReallocationAction[] = [] + for (const { move, assets } of emittedDeallocations) { + if (assets === 0n) continue + didClearMinDelta ||= move.clearsMinDelta( + getUtilizationAfter(move.marketData.state, 'deallocate', assets) + ) + deallocations.push(toLeg(move, assets)) + } + for (const { move, assets } of emittedAllocations) { + didClearMinDelta ||= move.clearsMinDelta( + getUtilizationAfter(move.marketData.state, 'allocate', assets) + ) + allocations.push(toLeg(move, assets)) + } + + if (!didClearMinDelta) return undefined + return { allocations, deallocations } + } +} diff --git a/bots/vault-v2-reallocation/src/strategies/strategy.ts b/bots/vault-v2-reallocation/src/strategies/strategy.ts new file mode 100644 index 00000000..4b666e14 --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategies/strategy.ts @@ -0,0 +1,25 @@ +import type { InputMarketParams } from '@morpho-org/blue-sdk' +import type { Hex } from 'viem' + +import type { VaultV2Data } from '../vault-data' + +/** One delta leg: assets to allocate to (or deallocate from) the market with these params. */ +export type ReallocationAction = { + /** The Blue market id — carried for log correlation only; encoding uses `marketParams`. */ + marketId: Hex + marketParams: InputMarketParams + assets: bigint +} + +/** A vault's planned move: exact-amount deltas, executed deallocations-first in one multicall. */ +export type Reallocation = { + allocations: ReallocationAction[] + deallocations: ReallocationAction[] +} + +/** + * Finds the reallocation for one vault, or undefined when no move clears the strategy's + * thresholds. Unlike V1's absolute targets, V2 legs are deltas and need not balance: surplus + * deallocation parks in the vault's idle balance, surplus allocation draws from it. + */ +export type Strategy = (vaultData: VaultV2Data) => Reallocation | undefined diff --git a/bots/vault-v2-reallocation/src/strategy-config.ts b/bots/vault-v2-reallocation/src/strategy-config.ts new file mode 100644 index 00000000..c186d464 --- /dev/null +++ b/bots/vault-v2-reallocation/src/strategy-config.ts @@ -0,0 +1,70 @@ +import type { Address, Hex } from 'viem' + +import { InvalidConfigError } from './invalid-config.error' + +type ApyRangePercent = { min: number; max: number } + +/** Global default borrow-APY range (percent) when no vault or market override matches. */ +export const DEFAULT_APY_RANGE: ApyRangePercent = { min: 3, max: 8 } + +// Curator policy tables, reviewed via PR. Keyed by chainId, then by CHECKSUMMED vault address +// (viem `getAddress` form) or lowercase market id — lookups are exact string matches. +// Template — add entries like: +// export const vaultApyRanges: Record> = { +// [mainnet.id]: { [getAddress('0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB')]: { min: 4, max: 6 } } +// } + +export const vaultApyRanges: Record> = {} + +export const marketApyRanges: Record> = {} + +export const vaultMinApyDeltaBips: Record> = {} + +export const marketMinApyDeltaBips: Record> = {} + +export const vaultMinUtilizationDeltaBips: Record> = {} + +/** + * Rejects an inverted or empty APY range: the classifier picks `upperBound` first, so a market + * sitting between an inverted pair would flip to allocate every pass. Runs at module load over the + * checked-in tables, so a bad entry fails the bot at startup instead of silently thrashing. + */ +export const assertApyRangeValid = (range: ApyRangePercent, label: string): void => { + if (!(range.min < range.max)) { + throw new InvalidConfigError( + `APY range for ${label} must satisfy min < max, got min=${range.min} max=${range.max}` + ) + } +} + +assertApyRangeValid(DEFAULT_APY_RANGE, 'DEFAULT_APY_RANGE') +for (const [table, name] of [ + [vaultApyRanges, 'vaultApyRanges'], + [marketApyRanges, 'marketApyRanges'] +] as const) { + for (const [chainId, entries] of Object.entries(table)) { + for (const [key, range] of Object.entries(entries)) { + assertApyRangeValid(range, `${name}[${chainId}][${key}]`) + } + } +} + +/** Borrow-APY range for (vault, market); precedence: market override > vault override > default. */ +export const resolveApyRange = (chainId: number, vault: Address, marketId: Hex): ApyRangePercent => + marketApyRanges[chainId]?.[marketId] ?? vaultApyRanges[chainId]?.[vault] ?? DEFAULT_APY_RANGE + +/** ApyRange firing threshold (bips); precedence: market override > vault override > `fallback`. */ +export const resolveMinApyDeltaBips = ( + chainId: number, + vault: Address, + marketId: Hex, + fallback: number +): number => + marketMinApyDeltaBips[chainId]?.[marketId] ?? vaultMinApyDeltaBips[chainId]?.[vault] ?? fallback + +/** EqualizeUtilizations firing threshold (bips); precedence: vault override > `fallback`. */ +export const resolveMinUtilizationDeltaBips = ( + chainId: number, + vault: Address, + fallback: number +): number => vaultMinUtilizationDeltaBips[chainId]?.[vault] ?? fallback diff --git a/bots/vault-v2-reallocation/src/tx-error.ts b/bots/vault-v2-reallocation/src/tx-error.ts new file mode 100644 index 00000000..9503de3e --- /dev/null +++ b/bots/vault-v2-reallocation/src/tx-error.ts @@ -0,0 +1,11 @@ +import { vaultV2Abi } from '@morpho-org/blue-sdk-viem' +import { abiRevertDecoder, revertReason as revertReasonWith } from '@repo/bot-kit' + +const decodeVaultV2Revert = abiRevertDecoder(vaultV2Abi) + +/** + * VaultV2-aware revert formatter: decodes the vault's custom ABI errors on top of the standard + * shapes. Injected into the runner and the pending queue so their `tick.error` / `tx.*` log lines + * carry decoded VaultV2 reasons instead of raw hex. + */ +export const revertReason = (error: unknown): string => revertReasonWith(error, decodeVaultV2Revert) diff --git a/bots/vault-v2-reallocation/src/vault-checks.ts b/bots/vault-v2-reallocation/src/vault-checks.ts new file mode 100644 index 00000000..ebfaf1c2 --- /dev/null +++ b/bots/vault-v2-reallocation/src/vault-checks.ts @@ -0,0 +1,45 @@ +import type { Logger } from '@repo/bot-kit' +import type { Address } from 'viem' + +import type { VaultV2Data } from './vault-data' + +export type VaultCheckReads = { + /** Fatal liveness gate; throws when the address holds no code on this chain. */ + assertDeployed: (vault: Address) => Promise + /** + * Block-pinned lens fetch; throws `InvalidVaultError` when the address is not a + * factory-made VaultV2 with exactly one Morpho Blue market adapter. Carries the EOA's strict + * `isAllocator` bit (VaultV2.allocate admits no curator/owner fallback), and the adapter comes + * from the vault's own `adapters` enumeration, so no recognition cross-check is needed. + */ + fetchVault: (vault: Address) => Promise +} + +/** + * Startup validation of the whitelist, run concurrently across vaults: each must hold code and + * resolve as a factory-made VaultV2 with exactly one Morpho Blue market adapter — the signing + * policy authorizes every whitelisted address as a tx target and pins its adapter, so any mismatch + * throws `InvalidVaultError`. The allocator role is only probed and warned about + * (`allocator.missing_role`): a pending grant must not crash-loop the bot, and the tick re-checks + * and resumes on its own. Returns the vault → adapter map the signing policy binds to. + */ +export const checkVaults = async ( + vaults: readonly Address[], + reads: VaultCheckReads, + logger: Logger +): Promise> => { + const entries = await Promise.all( + vaults.map(async vault => { + await reads.assertDeployed(vault) + const vaultData = await reads.fetchVault(vault) + if (!vaultData.isAllocator) { + logger.warn('allocator.missing_role', { + vault, + detail: 'grant the allocator role to the EOA' + }) + } + return [vault, [vaultData.adapterAddress]] as const + }) + ) + return Object.fromEntries(entries) +} diff --git a/bots/vault-v2-reallocation/src/vault-data.ts b/bots/vault-v2-reallocation/src/vault-data.ts new file mode 100644 index 00000000..98eacc60 --- /dev/null +++ b/bots/vault-v2-reallocation/src/vault-data.ts @@ -0,0 +1,193 @@ +import type { InputMarketParams } from '@morpho-org/blue-sdk' +import type { BatchLensTransportType } from '@repo/utils' +import type { Address, Client, Hex, Transport } from 'viem' + +import { getChainAddresses } from '@morpho-org/blue-sdk' +import { getAddress, isAddressEqual, zeroAddress } from 'viem' + +import type { LensVaultOut } from './state/lens.sol' + +import { InvalidVaultError } from './invalid-vault.error' +import { KIND_UNKNOWN, readVaultV2Lens } from './state/lens.sol' + +export type MarketState = { + totalSupplyAssets: bigint + totalBorrowAssets: bigint +} + +/** + * One cap id's state: the vault's absolute cap, WAD-scaled relative cap (fraction of totalAssets), + * and the on-chain `allocation(id)` the contract enforces both caps against. + */ +export type CapState = { + absolute: bigint + relative: bigint + allocation: bigint +} + +export type VaultV2MarketData = { + /** The Blue market id (what strategy-config overrides key on). */ + id: Hex + /** The vault cap id (`keccak256(abi.encode("this/marketParams", adapter, params))`). */ + capId: Hex + params: InputMarketParams + state: MarketState + cap: CapState + /** The adapter's accrued position in this market, in assets. */ + vaultAssets: bigint + /** AdaptiveCurveIRM `rateAtTarget` after accrual; 0 for markets not on that IRM. */ + rateAtTarget: bigint + /** + * Whether this market runs the chain's canonical AdaptiveCurveIRM. Only then is `rateAtTarget` + * meaningful, so only then may APY↔utilization inversion be applied — see + * {@link isAdaptiveCurveMarket}. + */ + isAdaptiveCurve: boolean + /** A zero-collateral Blue market never borrows, so no rate strategy applies to it. */ + isIdle: boolean +} + +export type VaultV2Data = { + vaultAddress: Address + adapterAddress: Address + /** + * Strict `isAllocator(eoa)` on the vault, read in the same call as the snapshot — deliberately + * narrower than the V1 bot's allocator|curator|owner check, because VaultV2.allocate admits no + * curator/owner fallback. + */ + isAllocator: boolean + /** The vault's total assets, accrued on-chain to the pinned block. */ + totalAssets: bigint + /** The vault's un-allocated asset balance (deallocate parks here; allocate draws from here). */ + idleAssets: bigint + /** Adapter-level ("this") cap state — an aggregate ceiling over every allocation. */ + adapterCap: CapState + /** Collateral-level cap state per distinct collateral token (checksummed key). */ + collateralCaps: Record + marketsData: VaultV2MarketData[] + /** + * Ids of the markets excluded from `apy-range` for running a foreign IRM. Precomputed here rather + * than in the tick — the mapping below already walks every market. + */ + nonAdaptiveCurveMarketIds: Hex[] +} + +/** + * A market qualifies only if it runs the chain's canonical AdaptiveCurveIRM **and** reports a + * non-zero `rateAtTarget`. The second half is belt-and-suspenders: `AdaptiveCurveIrmLib`'s inverse + * returns WAD for every rate when `rateAtTarget` is 0, which would silently read as "far below + * range" and drain the adapter's whole position out of the market on valid, simulation-passing + * calldata. + */ +const isAdaptiveCurveMarket = (irm: Address, rateAtTarget: bigint, chainId: number): boolean => + rateAtTarget > 0n && isAddressEqual(irm, getChainAddresses(chainId).adaptiveCurveIrm) + +const toCapState = (caps: { + absoluteCap: bigint + relativeCap: bigint + allocation: bigint +}): CapState => ({ + absolute: caps.absoluteCap, + relative: caps.relativeCap, + allocation: caps.allocation +}) + +/** + * Shapes one decoded lens row into {@link VaultV2Data}. Throws {@link InvalidVaultError} when the + * row is not a factory-made VaultV2 with exactly one factory-verified Morpho Blue market adapter + * (either adapter-contract generation) — the signing policy authorizes the vault as a tx target and + * pins its adapter, so any other shape must fail loud. + */ +export const toVaultV2Data = (vault: Address, row: LensVaultOut, chainId: number): VaultV2Data => { + if (!row.isVaultV2) { + throw new InvalidVaultError(`VAULT_WHITELIST entry ${vault} is not a factory-made VaultV2`) + } + const qualifying = row.adapters.filter(({ kind }) => kind !== KIND_UNKNOWN) + if (row.adapters.length !== 1 || qualifying.length !== 1) { + throw new InvalidVaultError( + `vault ${vault} must have exactly one Morpho Blue market adapter; found ` + + `${row.adapters.length} adapter(s) of which ${qualifying.length} qualify` + ) + } + const adapterAddress = getAddress(qualifying[0]!.adapter) + + const marketsData = row.markets.map( + (market): VaultV2MarketData => ({ + id: market.id, + capId: market.capId, + params: market.params, + state: { + totalSupplyAssets: market.totalSupplyAssets, + totalBorrowAssets: market.totalBorrowAssets + }, + cap: toCapState(market.cap), + vaultAssets: market.vaultAssets, + rateAtTarget: market.rateAtTarget, + isAdaptiveCurve: isAdaptiveCurveMarket(market.params.irm, market.rateAtTarget, chainId), + isIdle: isAddressEqual(market.params.collateralToken, zeroAddress) + }) + ) + + // Markets sharing a collateral share one cap id; the lens reports the triple per market, so the + // duplicates collapse to identical values here. + const collateralCaps = Object.fromEntries( + row.markets.map(market => [ + getAddress(market.params.collateralToken), + toCapState(market.collateralCap) + ]) + ) + + return { + vaultAddress: vault, + adapterAddress, + isAllocator: row.isAllocator, + totalAssets: row.totalAssets, + idleAssets: row.idleAssets, + adapterCap: toCapState(row.adapterCap), + collateralCaps, + marketsData, + nonAdaptiveCurveMarketIds: marketsData + .filter(marketData => !marketData.isAdaptiveCurve && !marketData.isIdle) + .map(marketData => marketData.id) + } +} + +/** + * Reads one VaultV2's full reallocation input — factory identity, the EOA's allocator bit, idle + * balance, adapter set, and per-market Blue state, position, `rateAtTarget`, and all three cap + * levels — in a single deployless `eth_call` pinned to `blockNumber`, so the snapshot is coherent + * across markets and reproducible. The lens accrues each market on-chain inside that call, so there + * is no client-side accrual and no block-timestamp handling here. + * + * Throws {@link InvalidVaultError} on a non-VaultV2 address or an unsupported adapter shape (see + * {@link toVaultV2Data}); a revert inside the lens propagates as-is. The tick catches per vault + * either way. + */ +export const fetchVaultV2Data = async ( + client: Client>, + vault: Address, + { chainId, blockNumber, eoa }: { chainId: number; blockNumber: bigint; eoa: Address } +): Promise => { + const { + morpho, + adaptiveCurveIrm, + vaultV2Factory, + morphoMarketV1AdapterFactory, + morphoMarketV1AdapterV2Factory + } = getChainAddresses(chainId) + if (!vaultV2Factory) throw new InvalidVaultError(`chain ${chainId} has no VaultV2 factory`) + const rows = await readVaultV2Lens( + client, + { + morpho, + adaptiveCurveIrm, + vaultV2Factory, + marketV1AdapterFactory: morphoMarketV1AdapterFactory ?? zeroAddress, + marketV1AdapterV2Factory: morphoMarketV1AdapterV2Factory ?? zeroAddress + }, + [{ vault, eoa }], + blockNumber + ) + // `readDeploylessBatchLens` already validates one output row per input, so the key is present. + return toVaultV2Data(vault, rows.get(vault.toLowerCase())!, chainId) +} diff --git a/bots/vault-v2-reallocation/test/config.test.ts b/bots/vault-v2-reallocation/test/config.test.ts new file mode 100644 index 00000000..caba7791 --- /dev/null +++ b/bots/vault-v2-reallocation/test/config.test.ts @@ -0,0 +1,89 @@ +import { getAddress, parseGwei } from 'viem' +import { describe, expect, it } from 'vitest' + +import { loadConfig } from '../src/config' +import { InvalidConfigError } from '../src/invalid-config.error' + +const VAULT_A = getAddress(`0x${'11'.repeat(20)}`) +const VAULT_B = getAddress(`0x${'22'.repeat(20)}`) + +const BASE_ENV = { + CHAIN_ID: '8453', + RPC_URL: 'https://rpc.example', + REALLOCATOR_PRIVATE_KEY: `0x${'aa'.repeat(32)}`, + VAULT_WHITELIST: `${VAULT_A}, ${VAULT_B.toLowerCase()}` +} + +describe('loadConfig', () => { + it('loads a minimal env with defaults', () => { + const config = loadConfig(BASE_ENV) + expect(config.chainId).toBe(8453) + expect(config.chain.id).toBe(8453) + expect(config.vaultWhitelist).toEqual([VAULT_A, VAULT_B]) + expect(config.strategy).toBe('equalize-utilizations') + // Case-variant and exact duplicates collapse to one entry after checksumming. + expect( + loadConfig({ ...BASE_ENV, VAULT_WHITELIST: `${VAULT_A},${VAULT_A.toLowerCase()},${VAULT_A}` }) + .vaultWhitelist + ).toEqual([VAULT_A]) + expect(config.reallocationIntervalMs).toBe(600_000) + expect(config.minApyDeltaBips).toBe(25) + expect(config.minUtilizationDeltaBips).toBe(250) + expect(config.allowIdleReallocation).toBe(true) + expect(config.dryRun).toBe(false) + expect(config.maxFeeWei).toBe(parseGwei('300')) + expect(config.logLevel).toBe('info') + expect(config.rpcUrlFallback).toBeUndefined() + }) + + it('honors explicit overrides', () => { + const config = loadConfig({ + ...BASE_ENV, + CHAIN_ID: '1', + RPC_URL_FALLBACK: 'https://fallback.example', + STRATEGY: 'apy-range', + REALLOCATION_INTERVAL_MS: '60000', + MIN_APY_DELTA_BIPS: '50', + MIN_UTILIZATION_DELTA_BIPS: '100', + ALLOW_IDLE_REALLOCATION: 'false', + DRY_RUN: 'true', + MAX_FEE_GWEI: '25.5', + LOG_LEVEL: 'debug' + }) + expect(config.chain.id).toBe(1) + expect(config.rpcUrlFallback).toBe('https://fallback.example') + expect(config.strategy).toBe('apy-range') + expect(config.reallocationIntervalMs).toBe(60_000) + expect(config.minApyDeltaBips).toBe(50) + expect(config.minUtilizationDeltaBips).toBe(100) + expect(config.allowIdleReallocation).toBe(false) + expect(config.dryRun).toBe(true) + expect(config.maxFeeWei).toBe(parseGwei('25.5')) + expect(config.logLevel).toBe('debug') + }) + + it('trims required values so padded env vars never reach the transport', () => { + const config = loadConfig({ ...BASE_ENV, RPC_URL: ' https://rpc.example ' }) + expect(config.rpcUrl).toBe('https://rpc.example') + }) + + it.each([ + ['CHAIN_ID missing', { ...BASE_ENV, CHAIN_ID: undefined }], + ['CHAIN_ID unsupported', { ...BASE_ENV, CHAIN_ID: '42' }], + ['CHAIN_ID hex form', { ...BASE_ENV, CHAIN_ID: '0x1' }], + ['RPC_URL missing', { ...BASE_ENV, RPC_URL: '' }], + ['private key malformed', { ...BASE_ENV, REALLOCATOR_PRIVATE_KEY: '0x1234' }], + ['whitelist missing', { ...BASE_ENV, VAULT_WHITELIST: undefined }], + ['whitelist empty', { ...BASE_ENV, VAULT_WHITELIST: ' , ' }], + ['whitelist bad address', { ...BASE_ENV, VAULT_WHITELIST: `${VAULT_A},0x1234` }], + ['unknown strategy', { ...BASE_ENV, STRATEGY: 'yolo' }], + ['interval not integer', { ...BASE_ENV, REALLOCATION_INTERVAL_MS: '5m' }], + ['interval zero', { ...BASE_ENV, REALLOCATION_INTERVAL_MS: '0' }], + ['interval beyond 2^53', { ...BASE_ENV, REALLOCATION_INTERVAL_MS: '9007199254740993' }], + ['bool malformed', { ...BASE_ENV, DRY_RUN: 'yes' }], + ['fee malformed', { ...BASE_ENV, MAX_FEE_GWEI: '-1' }], + ['log level unknown', { ...BASE_ENV, LOG_LEVEL: 'trace' }] + ] as const)('fails loud on %s', (_label, env) => { + expect(() => loadConfig(env)).toThrow(InvalidConfigError) + }) +}) diff --git a/bots/vault-v2-reallocation/test/encode.test.ts b/bots/vault-v2-reallocation/test/encode.test.ts new file mode 100644 index 00000000..aa5ce43f --- /dev/null +++ b/bots/vault-v2-reallocation/test/encode.test.ts @@ -0,0 +1,53 @@ +import { marketParamsAbi } from '@morpho-org/blue-sdk' +import { vaultV2Abi } from '@morpho-org/blue-sdk-viem' +import { decodeAbiParameters, decodeFunctionData, getAddress, parseUnits } from 'viem' +import { describe, expect, it } from 'vitest' + +import { encodeReallocation } from '../src/encode' +import { ADAPTER, makeMarketParams } from './helpers' + +describe('encodeReallocation', () => { + it('encodes deallocate legs strictly before allocate legs with exact args', () => { + const hotParams = makeMarketParams() + const coldParams = makeMarketParams() + const data = encodeReallocation(ADAPTER, { + allocations: [{ marketId: '0x01', marketParams: hotParams, assets: parseUnits('123', 6) }], + deallocations: [{ marketId: '0x02', marketParams: coldParams, assets: parseUnits('456', 6) }] + }) + + const outer = decodeFunctionData({ abi: vaultV2Abi, data }) + if (outer.functionName !== 'multicall') throw new Error('expected multicall') + const calls = outer.args[0] + expect(calls.length).toBe(2) + + const first = decodeFunctionData({ abi: vaultV2Abi, data: calls[0]! }) + const second = decodeFunctionData({ abi: vaultV2Abi, data: calls[1]! }) + if (first.functionName !== 'deallocate') throw new Error('expected deallocate first') + if (second.functionName !== 'allocate') throw new Error('expected allocate second') + + // Each leg: (adapter, abi.encode(marketParams), assets). + expect(getAddress(first.args[0])).toBe(ADAPTER) + expect(first.args[2]).toBe(parseUnits('456', 6)) + const [decodedCold] = decodeAbiParameters([marketParamsAbi], first.args[1]) + expect(decodedCold).toEqual(coldParams) + + expect(getAddress(second.args[0])).toBe(ADAPTER) + expect(second.args[2]).toBe(parseUnits('123', 6)) + const [decodedHot] = decodeAbiParameters([marketParamsAbi], second.args[1]) + expect(decodedHot).toEqual(hotParams) + }) + + it('encodes a deallocate-only plan (surplus parks in idle)', () => { + const params = makeMarketParams() + const data = encodeReallocation(ADAPTER, { + allocations: [], + deallocations: [{ marketId: '0x01', marketParams: params, assets: 1n }] + }) + const outer = decodeFunctionData({ abi: vaultV2Abi, data }) + if (outer.functionName !== 'multicall') throw new Error('expected multicall') + expect(outer.args[0].length).toBe(1) + expect(decodeFunctionData({ abi: vaultV2Abi, data: outer.args[0][0]! }).functionName).toBe( + 'deallocate' + ) + }) +}) diff --git a/bots/vault-v2-reallocation/test/helpers.ts b/bots/vault-v2-reallocation/test/helpers.ts new file mode 100644 index 00000000..eae3fc3b --- /dev/null +++ b/bots/vault-v2-reallocation/test/helpers.ts @@ -0,0 +1,87 @@ +import type { InputMarketParams } from '@morpho-org/blue-sdk' +import type { Hex } from 'viem' + +import { MathLib, SECONDS_PER_YEAR } from '@morpho-org/blue-sdk' +import { getAddress, maxUint256, parseUnits } from 'viem' + +import type { CapState, VaultV2Data, VaultV2MarketData } from '../src/vault-data' + +export const VAULT = getAddress('0x0000000000000000000000000000000000000001') +export const ADAPTER = getAddress('0x0000000000000000000000000000000000000002') +const LOAN_TOKEN = getAddress('0x0000000000000000000000000000000000000010') +const COLLATERAL = getAddress('0x0000000000000000000000000000000000000020') +const ORACLE = getAddress('0x0000000000000000000000000000000000000030') +const IRM = getAddress('0x0000000000000000000000000000000000000040') + +// ~3% APY at target utilization, per second. +export const RATE_AT_TARGET = parseUnits('0.03', 18) / SECONDS_PER_YEAR + +const UNLIMITED_CAP: CapState = { + absolute: maxUint256 / 10n ** 18n, // large but buffer-multiplication-safe + relative: 10n ** 18n, // 100% + allocation: 0n +} + +// Ids only need uniqueness; the counter never resets, which keeps every market distinct across a +// whole test file. +let marketCounter = 0 + +export const makeMarketParams = (overrides?: Partial): InputMarketParams => { + marketCounter++ + return { + loanToken: LOAN_TOKEN, + collateralToken: COLLATERAL, + oracle: ORACLE, + irm: IRM, + lltv: parseUnits('0.8', 18) + BigInt(marketCounter), // unique per market + ...overrides + } +} + +const makeMarketId = (): Hex => `0x${(++marketCounter).toString(16).padStart(64, '0')}` + +/** Builds a market whose state realizes the requested WAD utilization. */ +export const makeMarket = (opts: { + utilization: bigint + vaultAssets: bigint + cap?: Partial + rateAtTarget?: bigint + params?: InputMarketParams + isAdaptiveCurve?: boolean + isIdle?: boolean + supplyAssets?: bigint +}): VaultV2MarketData => { + const totalSupplyAssets = opts.supplyAssets ?? parseUnits('100000', 6) + const totalBorrowAssets = MathLib.wMulDown(totalSupplyAssets, opts.utilization) + return { + id: makeMarketId(), + capId: makeMarketId(), + params: opts.params ?? makeMarketParams(), + state: { totalSupplyAssets, totalBorrowAssets }, + // Default the enforced allocation to the accrued position value — tests that exercise the + // divergence override `cap.allocation` explicitly. + cap: { ...UNLIMITED_CAP, allocation: opts.vaultAssets, ...opts.cap }, + vaultAssets: opts.vaultAssets, + rateAtTarget: opts.rateAtTarget ?? RATE_AT_TARGET, + isAdaptiveCurve: opts.isAdaptiveCurve ?? true, + isIdle: opts.isIdle ?? false + } +} + +export const makeVaultData = ( + markets: VaultV2MarketData[], + overrides: Partial> = {} +): VaultV2Data => ({ + vaultAddress: VAULT, + adapterAddress: ADAPTER, + isAllocator: true, + totalAssets: markets.reduce((acc, m) => acc + m.vaultAssets, 0n) + (overrides.idleAssets ?? 0n), + idleAssets: 0n, + adapterCap: UNLIMITED_CAP, + collateralCaps: Object.fromEntries( + markets.map(m => [getAddress(m.params.collateralToken), UNLIMITED_CAP]) + ), + marketsData: markets, + nonAdaptiveCurveMarketIds: markets.filter(m => !m.isAdaptiveCurve && !m.isIdle).map(m => m.id), + ...overrides +}) diff --git a/bots/vault-v2-reallocation/test/interval-gate.test.ts b/bots/vault-v2-reallocation/test/interval-gate.test.ts new file mode 100644 index 00000000..75377108 --- /dev/null +++ b/bots/vault-v2-reallocation/test/interval-gate.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' + +import { createIntervalGate } from '../src/interval-gate' + +const clockAt = (value: { now: number }) => () => value.now + +describe('createIntervalGate', () => { + it('passes on the first call', () => { + const clock = { now: 0 } + expect(createIntervalGate(1000, clockAt(clock))()).toBe(true) + }) + + it('blocks a call inside the interval', () => { + const clock = { now: 0 } + const gate = createIntervalGate(1000, clockAt(clock)) + expect(gate()).toBe(true) + clock.now = 999 + expect(gate()).toBe(false) + }) + + it('passes once the interval has elapsed', () => { + const clock = { now: 0 } + const gate = createIntervalGate(1000, clockAt(clock)) + expect(gate()).toBe(true) + clock.now = 1000 + expect(gate()).toBe(true) + }) + + it('measures from the last admitted pass, not from the last call', () => { + const clock = { now: 0 } + const gate = createIntervalGate(1000, clockAt(clock)) + expect(gate()).toBe(true) + clock.now = 900 + expect(gate()).toBe(false) + clock.now = 1500 + expect(gate()).toBe(true) + clock.now = 2000 + expect(gate()).toBe(false) + }) +}) diff --git a/bots/vault-v2-reallocation/test/math.test.ts b/bots/vault-v2-reallocation/test/math.test.ts new file mode 100644 index 00000000..c9c6b443 --- /dev/null +++ b/bots/vault-v2-reallocation/test/math.test.ts @@ -0,0 +1,232 @@ +import { wholePercentToWAD } from '@repo/utils' +import { getAddress, parseUnits } from 'viem' +import { describe, expect, it } from 'vitest' + +import { + createDepositPools, + creditPools, + getCapHeadroom, + getDepositableAmount, + getUtilization, + getWithdrawableAmount, + takeFromPools +} from '../src/math' +import { makeMarket, makeVaultData, RATE_AT_TARGET } from './helpers' + +const WAD = 10n ** 18n + +describe('getCapHeadroom', () => { + const totalAssets = parseUnits('100000', 6) + + it('measures headroom from the caller-provided basis', () => { + const cap = { + absolute: parseUnits('11000', 6), + relative: WAD, + allocation: parseUnits('10000', 6) + } + // The accrued position (basis) sits above the stored allocation — headroom shrinks with it, + // because allocate trues allocation up to the accrued position before the cap check. + expect(getCapHeadroom(cap, parseUnits('10500', 6), totalAssets, WAD)).toBe(parseUnits('500', 6)) + }) + + it('treats a WAD relative cap as no relative constraint, never a 100% ceiling', () => { + // The production shape: fully deployed vault, relativeCap = WAD, huge absolute cap. A binding + // 100%-of-totalAssets reading would return ~0 here and silently no-op the bot forever. + const cap = { absolute: parseUnits('1000000', 6), relative: WAD, allocation: totalAssets } + expect(getCapHeadroom(cap, totalAssets, totalAssets, WAD)).toBe(parseUnits('900000', 6)) + }) + + it('applies the buffer and floors at zero', () => { + const cap = { + absolute: parseUnits('10000', 6), + relative: WAD, + allocation: parseUnits('10000', 6) + } + // Buffered absolute (99.99%) is below the basis → zero headroom. + expect(getCapHeadroom(cap, parseUnits('10000', 6), totalAssets, wholePercentToWAD(99.99))).toBe( + 0n + ) + }) + + it('binds on a sub-WAD relative cap when it is the smaller ceiling', () => { + const cap = { + absolute: parseUnits('50000', 6), + relative: parseUnits('0.1', 18), // 10% of totalAssets = 10k + allocation: parseUnits('9000', 6) + } + expect(getCapHeadroom(cap, parseUnits('9000', 6), totalAssets, WAD)).toBe(parseUnits('1000', 6)) + }) +}) + +describe('getDepositableAmount / getWithdrawableAmount', () => { + it('bounds deposits by min(utilization headroom, market cap headroom)', () => { + const vaultAssets = parseUnits('10000', 6) + const market = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets, + cap: { absolute: vaultAssets + parseUnits('100', 6), relative: WAD, allocation: vaultAssets }, + rateAtTarget: RATE_AT_TARGET + }) + // Utilization headroom to 45% target would be huge; the cap allows only ~100 more. + const depositable = getDepositableAmount( + market, + parseUnits('100000', 6), + (45n * WAD) / 100n, + WAD + ) + expect(depositable).toBe(parseUnits('100', 6)) + + // Accrual drift: the position accrued 40 past the stored allocation; allocate trues the + // allocation up before the cap check, so the drift eats into the headroom. + const drifted = { + ...market, + cap: { ...market.cap, allocation: vaultAssets - parseUnits('40', 6) } + } + expect(getDepositableAmount(drifted, parseUnits('100000', 6), (45n * WAD) / 100n, WAD)).toBe( + parseUnits('100', 6) + ) + const driftedAboveCapBase = { ...market, vaultAssets: vaultAssets + parseUnits('40', 6) } + expect( + getDepositableAmount(driftedAboveCapBase, parseUnits('100000', 6), (45n * WAD) / 100n, WAD) + ).toBe(parseUnits('60', 6)) + }) + + it('bounds withdrawals by the adapter position', () => { + const market = makeMarket({ + utilization: (45n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(getWithdrawableAmount(market, (90n * WAD) / 100n)).toBe(market.vaultAssets) + }) + + it('sizes both directions to zero for a 0 target instead of dividing by zero', () => { + const market = makeMarket({ + utilization: (45n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(getDepositableAmount(market, parseUnits('100000', 6), 0n, WAD)).toBe(0n) + const emptyMarket = makeMarket({ + utilization: 0n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(getWithdrawableAmount(emptyMarket, 0n)).toBe(0n) + }) + + it('returns 0 utilization for an empty market instead of dividing by zero', () => { + expect(getUtilization({ totalSupplyAssets: 0n, totalBorrowAssets: 0n })).toBe(0n) + }) +}) + +describe('deposit pools', () => { + it('consumes the adapter pool and the per-collateral pool independently', () => { + const market = makeMarket({ + utilization: (50n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const collateral = getAddress(market.params.collateralToken) + const vaultData = makeVaultData([market], { + idleAssets: parseUnits('1000', 6), // raises totalAssets so the 100% relative caps have headroom + adapterCap: { + absolute: parseUnits('10300', 6), + relative: WAD, + allocation: parseUnits('10000', 6) + }, + collateralCaps: { + [collateral]: { + absolute: parseUnits('10200', 6), + relative: WAD, + allocation: parseUnits('10000', 6) + } + } + }) + const pools = createDepositPools(vaultData, WAD) + // Collateral pool (200) binds before the adapter pool (300). + expect(takeFromPools(pools, collateral, parseUnits('500', 6))).toBe(parseUnits('200', 6)) + // Both pools are now drained for this collateral. + expect(takeFromPools(pools, collateral, parseUnits('500', 6))).toBe(0n) + expect(pools.adapter.headroom).toBe(parseUnits('100', 6)) + }) + + it('repays an existing over-cap deficit before crediting deallocations as headroom', () => { + const market = makeMarket({ + utilization: (50n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const collateral = getAddress(market.params.collateralToken) + const vaultData = makeVaultData([market], { + // The adapter allocation already sits 100 OVER its cap (accrual, or a curator lowering the + // cap): a deallocation must relieve that overage before any of it becomes new headroom. + adapterCap: { + absolute: parseUnits('10000', 6), + relative: WAD, + allocation: parseUnits('10100', 6) + } + }) + const pools = createDepositPools(vaultData, WAD) + expect(pools.adapter).toEqual({ headroom: 0n, deficit: parseUnits('100', 6) }) + creditPools(pools, collateral, parseUnits('150', 6)) + // Only the 50 past the deficit is drawable — allocating the full 150 back would restore the + // over-cap balance the deallocation just relieved. + expect(takeFromPools(pools, collateral, parseUnits('1000', 6))).toBe(parseUnits('50', 6)) + }) + + it("includes every market's accrual drift in the aggregate pool bases", () => { + const vaultAssets = parseUnits('10000', 6) + const market = makeMarket({ + utilization: (50n * WAD) / 100n, + vaultAssets, + // Position accrued 100 past the stored market allocation. + cap: { + absolute: parseUnits('50000', 6), + relative: WAD, + allocation: vaultAssets - parseUnits('100', 6) + }, + rateAtTarget: RATE_AT_TARGET + }) + const vaultData = makeVaultData([market], { + adapterCap: { + absolute: parseUnits('10300', 6), + relative: WAD, + allocation: parseUnits('10000', 6) + } + }) + const pools = createDepositPools(vaultData, WAD) + // Adapter basis = stored 10000 + drift 100 → headroom 200 instead of 300. + expect(pools.adapter.headroom).toBe(parseUnits('200', 6)) + }) + + it('credits deallocation legs back into both pools', () => { + const market = makeMarket({ + utilization: (50n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const collateral = getAddress(market.params.collateralToken) + const vaultData = makeVaultData([market], { + adapterCap: { + absolute: parseUnits('10000', 6), + relative: WAD, + allocation: parseUnits('10000', 6) + } + }) + const pools = createDepositPools(vaultData, WAD) + expect(pools.adapter.headroom).toBe(0n) + creditPools(pools, collateral, parseUnits('700', 6)) + expect(takeFromPools(pools, collateral, parseUnits('1000', 6))).toBe(parseUnits('700', 6)) + }) + + it('treats an unknown collateral as having zero headroom', () => { + const market = makeMarket({ + utilization: (50n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const pools = createDepositPools(makeVaultData([market]), WAD) + expect(takeFromPools(pools, getAddress(`0x${'77'.repeat(20)}`), parseUnits('1', 6))).toBe(0n) + }) +}) diff --git a/bots/vault-v2-reallocation/test/runner/tick.test.ts b/bots/vault-v2-reallocation/test/runner/tick.test.ts new file mode 100644 index 00000000..25d45291 --- /dev/null +++ b/bots/vault-v2-reallocation/test/runner/tick.test.ts @@ -0,0 +1,169 @@ +import type { Logger } from '@repo/bot-kit' +import type { Address } from 'viem' + +import { getAddress, parseUnits } from 'viem' +import { describe, expect, it, vi } from 'vitest' + +import type { TickDeps } from '../../src/runner/tick' +import type { Reallocation } from '../../src/strategies' +import type { VaultV2Data } from '../../src/vault-data' + +import { runTick } from '../../src/runner/tick' +import { makeMarket, makeMarketParams, makeVaultData, RATE_AT_TARGET } from '../helpers' + +const spyLogger = () => { + const events: { level: string; event: string; fields?: Record }[] = [] + const make = (level: string) => (event: string, fields?: Record) => + events.push({ level, event, fields }) + const logger: Logger = { + debug: make('debug'), + info: make('info'), + warn: make('warn'), + error: make('error') + } + return { logger, events } +} + +const VAULT_A: Address = getAddress(`0x${'aa'.repeat(20)}`) +const VAULT_B: Address = getAddress(`0x${'bb'.repeat(20)}`) +const DATA = '0xdeadbeef' as const + +const someVaultData = (): VaultV2Data => + makeVaultData([makeMarket({ utilization: 0n, vaultAssets: 0n, rateAtTarget: RATE_AT_TARGET })]) + +const someReallocation = (): Reallocation => ({ + allocations: [{ marketId: '0x01', marketParams: makeMarketParams(), assets: parseUnits('1', 6) }], + deallocations: [ + { marketId: '0x02', marketParams: makeMarketParams(), assets: parseUnits('1', 6) } + ] +}) + +const makeDeps = (overrides: Partial = {}) => { + const { logger, events } = spyLogger() + const deps: TickDeps = { + vaults: [VAULT_A], + chainHead: 100n, + expectedAdapter: vi.fn(() => undefined), + fetchVault: vi.fn(async () => someVaultData()), + strategy: vi.fn(() => undefined), + encodeReallocation: vi.fn(() => DATA), + simulate: vi.fn(async () => ({ status: 'ok' as const })), + submit: vi.fn(async () => true), + dryRun: false, + inflightLabels: () => new Set(), + revertReason: error => (error instanceof Error ? error.message : String(error)), + logger, + ...overrides + } + return { deps, events } +} + +const tickEnd = (events: ReturnType['events']) => + events.find(e => e.event === 'tick.end')?.fields + +describe('runTick', () => { + it('submits a sim-ok reallocation and counts it', async () => { + const { deps, events } = makeDeps({ strategy: vi.fn(() => someReallocation()) }) + await runTick(deps) + expect(deps.submit).toHaveBeenCalledWith({ vault: VAULT_A, data: DATA, blockNumber: 100n }) + expect(tickEnd(events)).toMatchObject({ reallocations_found: 1, submitted: 1, errors: 0 }) + expect(events.some(e => e.event === 'reallocation.found')).toBe(true) + }) + + it('does not count a submit the queue refused to broadcast', async () => { + const { deps, events } = makeDeps({ + strategy: vi.fn(() => someReallocation()), + submit: vi.fn(async () => false) + }) + await runTick(deps) + expect(events.some(e => e.event === 'reallocation.not_broadcast')).toBe(true) + expect(tickEnd(events)).toMatchObject({ reallocations_found: 1, submitted: 0 }) + }) + + it('passes the tick chainHead into the vault fetch (block-pinned snapshot)', async () => { + const { deps } = makeDeps({ chainHead: 123n }) + await runTick(deps) + expect(deps.fetchVault).toHaveBeenCalledWith(VAULT_A, 123n) + }) + + it('does nothing when the strategy finds no reallocation', async () => { + const { deps, events } = makeDeps() + await runTick(deps) + expect(deps.simulate).not.toHaveBeenCalled() + expect(deps.submit).not.toHaveBeenCalled() + expect(tickEnd(events)).toMatchObject({ reallocations_found: 0, submitted: 0 }) + }) + + it('does not submit on a sim revert and logs the reason', async () => { + const { deps, events } = makeDeps({ + strategy: vi.fn(() => someReallocation()), + simulate: vi.fn(async () => ({ status: 'revert' as const, reason: 'AbsoluteCapExceeded' })) + }) + await runTick(deps) + expect(deps.submit).not.toHaveBeenCalled() + expect(events).toContainEqual({ + level: 'warn', + event: 'reallocation.sim_revert', + fields: { vault: VAULT_A, reason: 'AbsoluteCapExceeded' } + }) + expect(tickEnd(events)).toMatchObject({ sim_reverts: 1, submitted: 0 }) + }) + + it('logs instead of submitting in dry-run mode', async () => { + const { deps, events } = makeDeps({ strategy: vi.fn(() => someReallocation()), dryRun: true }) + await runTick(deps) + expect(deps.simulate).toHaveBeenCalled() + expect(deps.submit).not.toHaveBeenCalled() + expect(events.some(e => e.event === 'reallocation.dry_run')).toBe(true) + expect(tickEnd(events)).toMatchObject({ dry_runs: 1, submitted: 0 }) + }) + + it('skips a vault whose label is in flight', async () => { + const { deps, events } = makeDeps({ inflightLabels: () => new Set([VAULT_A]) }) + await runTick(deps) + expect(deps.fetchVault).not.toHaveBeenCalled() + expect(tickEnd(events)).toMatchObject({ skipped_inflight: 1 }) + }) + + it('skips a vault whose adapter changed since the policy was pinned', async () => { + const OTHER_ADAPTER = getAddress(`0x${'cc'.repeat(20)}`) + const { deps, events } = makeDeps({ + strategy: vi.fn(() => someReallocation()), + expectedAdapter: vi.fn(() => OTHER_ADAPTER) + }) + await runTick(deps) + expect(deps.submit).not.toHaveBeenCalled() + expect(events.some(e => e.event === 'adapter.changed' && e.level === 'warn')).toBe(true) + expect(tickEnd(events)).toMatchObject({ adapter_changed: 1, submitted: 0 }) + }) + + it('skips strategy/simulate while the allocator role is missing', async () => { + const { deps, events } = makeDeps({ + fetchVault: vi.fn(async () => ({ ...someVaultData(), isAllocator: false })) + }) + await runTick(deps) + expect(deps.strategy).not.toHaveBeenCalled() + expect(events).toContainEqual({ + level: 'warn', + event: 'allocator.missing_role', + fields: { vault: VAULT_A } + }) + expect(tickEnd(events)).toMatchObject({ missing_role: 1 }) + }) + + it('continues past a failing vault and reports vault.error', async () => { + const fetchVault = vi.fn(async (vault: Address) => { + if (vault === VAULT_A) throw new Error('rpc exploded') + return someVaultData() + }) + const { deps, events } = makeDeps({ vaults: [VAULT_A, VAULT_B], fetchVault }) + await runTick(deps) + expect(fetchVault).toHaveBeenCalledTimes(2) + expect(events).toContainEqual({ + level: 'error', + event: 'vault.error', + fields: { vault: VAULT_A, reason: 'rpc exploded' } + }) + expect(tickEnd(events)).toMatchObject({ errors: 1 }) + }) +}) diff --git a/bots/vault-v2-reallocation/test/state/lens.sol.test.ts b/bots/vault-v2-reallocation/test/state/lens.sol.test.ts new file mode 100644 index 00000000..8bac6818 --- /dev/null +++ b/bots/vault-v2-reallocation/test/state/lens.sol.test.ts @@ -0,0 +1,120 @@ +import { decodeFunctionResult, encodeFunctionResult, getAddress, parseUnits } from 'viem' +import { describe, expect, it } from 'vitest' + +import { VaultV2ReallocationLens } from '../../src/state/lens.sol' + +const MORPHO = getAddress('0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb') +const ADAPTIVE_CURVE_IRM = getAddress('0x46415998764C29aB2a25CbeA6254146D50D22687') +const VAULT_V2_FACTORY = getAddress('0x4501125508079A99ebBebCE205DeC9593C2b5857') +const MARKET_V1_ADAPTER_FACTORY = getAddress('0x133baC94306B99f6dAD85c381a5be851d8DD717c') +const MARKET_V1_ADAPTER_V2_FACTORY = getAddress('0x9a1B378C43BA535cDB89934230F0D3890c51C0EB') + +const compiled = () => + VaultV2ReallocationLens.with( + MORPHO, + ADAPTIVE_CURVE_IRM, + VAULT_V2_FACTORY, + MARKET_V1_ADAPTER_FACTORY, + MARKET_V1_ADAPTER_V2_FACTORY + ) + +const capsOut = (base: bigint) => ({ + absoluteCap: base, + relativeCap: base + 1n, + allocation: base + 2n +}) + +describe('VaultV2ReallocationLens', () => { + it('compiles via soltag and binds all five addresses into the factory call', () => { + // Proves the soltag/vite transform compiled the inline Solidity (sol``` would otherwise throw) + // and that constructor binding produced a deployless factory call. + const lens = compiled() + expect(lens.factoryData.length).toBeGreaterThan(2) + expect(lens.factory).toMatch(/^0x[0-9a-fA-F]{40}$/) + // Every immutable is appended to the creation bytecode, so each must appear in factoryData. + for (const immutable of [ + MORPHO, + ADAPTIVE_CURVE_IRM, + VAULT_V2_FACTORY, + MARKET_V1_ADAPTER_FACTORY, + MARKET_V1_ADAPTER_V2_FACTORY + ]) { + expect(lens.factoryData.toLowerCase()).toContain(immutable.slice(2).toLowerCase()) + } + }) + + it('exposes a single-array-in / single-array-out lens entrypoint', () => { + // The struct shape is what lets viem encode/decode natively (no hand-written ABI). It also + // guards the backtick-truncation footgun: a stray backtick in a Solidity comment terminates the + // sol``` template early, silently yielding an empty ABI — this would then find no `lens`. + const { abi } = compiled() + const lens = abi.find(item => item.type === 'function' && item.name === 'lens') + expect(lens).toBeDefined() + expect(lens?.inputs).toHaveLength(1) + expect(lens?.inputs[0]?.type).toBe('tuple[]') + expect(lens?.outputs).toHaveLength(1) + expect(lens?.outputs[0]?.type).toBe('tuple[]') + }) + + it('declares the entrypoint state-changing, since it accrues interest on-chain', () => { + // The accrual is the whole point of the lens — if this ever reads `view`, the `accrueInterest` + // call was dropped and the snapshot silently reverted to pre-accrual state. + const { abi } = compiled() + const lens = abi.find(item => item.type === 'function' && item.name === 'lens') + expect(lens?.stateMutability).toBe('nonpayable') + }) + + it('round-trips a VaultOut through the soltag-generated ABI in field order', () => { + // Exercises the exact decode path the fetcher relies on: viem decoding the soltag ABI directly + // (nested tuple[]s of adapters and markets, the cap triples, and the field ORDER the mapping in + // vault-data.ts assumes). + const { abi } = compiled() + const sample = { + isVaultV2: true, + isAllocator: true, + totalAssets: parseUnits('1000000', 6), + idleAssets: parseUnits('5000', 6), + adapters: [{ adapter: getAddress(`0x${'22'.repeat(20)}`), kind: 2 }], + adapterCap: capsOut(300n), + markets: [ + { + id: `0x${'ab'.repeat(32)}` as const, + capId: `0x${'cd'.repeat(32)}` as const, + params: { + loanToken: getAddress(`0x${'33'.repeat(20)}`), + collateralToken: getAddress(`0x${'44'.repeat(20)}`), + oracle: getAddress(`0x${'55'.repeat(20)}`), + irm: ADAPTIVE_CURVE_IRM, + lltv: parseUnits('0.86', 18) + }, + totalSupplyAssets: parseUnits('1000000', 6), + totalBorrowAssets: parseUnits('900000', 6), + cap: capsOut(100n), + collateralCap: capsOut(200n), + vaultAssets: parseUnits('250000', 6), + rateAtTarget: 951293759n + } + ] + } + const encoded = encodeFunctionResult({ abi, functionName: 'lens', result: [sample] }) + const decoded = decodeFunctionResult({ abi, functionName: 'lens', data: encoded }) + expect(decoded).toEqual([sample]) + }) + + it('decodes a non-VaultV2 row as zeroed fields for the fetcher to reject', () => { + // The lens bails before touching a non-factory address: only isVaultV2 is meaningful and every + // other field decodes to its zero value — the fetcher throws InvalidVaultError off the bit. + const { abi } = compiled() + const sample = { + isVaultV2: false, + isAllocator: false, + totalAssets: 0n, + idleAssets: 0n, + adapters: [], + adapterCap: { absoluteCap: 0n, relativeCap: 0n, allocation: 0n }, + markets: [] + } + const encoded = encodeFunctionResult({ abi, functionName: 'lens', result: [sample] }) + expect(decodeFunctionResult({ abi, functionName: 'lens', data: encoded })).toEqual([sample]) + }) +}) diff --git a/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts new file mode 100644 index 00000000..60fbda4f --- /dev/null +++ b/bots/vault-v2-reallocation/test/strategies/apy-range.test.ts @@ -0,0 +1,397 @@ +import type { Hex } from 'viem' + +import { wholePercentToWAD } from '@repo/utils' +import { parseUnits } from 'viem' +import { describe, expect, it } from 'vitest' + +import type { ApyRangeConfig } from '../../src/strategies/apy-range' + +import { + apyToRate, + MAX_TARGET_UTILIZATION, + rateToApy, + rateToUtilization, + utilizationToRate, + wadToBips +} from '../../src/math' +import { createApyRangeStrategy } from '../../src/strategies/apy-range' +import { makeMarket, makeVaultData, RATE_AT_TARGET } from '../helpers' + +type ApyRangePercent = { min: number; max: number } + +const makeStrategy = ( + overrides: Partial<{ + allowIdleReallocation: boolean + defaultApyRange: ApyRangePercent + minApyDeltaBips: number + marketApyRanges: Record + }> = {} +) => { + const defaultApyRange = overrides.defaultApyRange ?? { min: 2, max: 8 } + const config: ApyRangeConfig = { + allowIdleReallocation: overrides.allowIdleReallocation ?? true, + capBufferWad: wholePercentToWAD(99.99), + apyRange: (_vault, marketId) => { + const range = overrides.marketApyRanges?.[marketId] ?? defaultApyRange + return { min: wholePercentToWAD(range.min), max: wholePercentToWAD(range.max) } + }, + // No min delta threshold by default so tests are predictable. + minApyDeltaBips: () => overrides.minApyDeltaBips ?? 0 + } + return createApyRangeStrategy(config) +} + +/** The utilization at which the market yields the given borrow APY. */ +const apyToUtilization = (apyPercent: number, rateAtTarget: bigint): bigint => + rateToUtilization(apyToRate(wholePercentToWAD(apyPercent)), rateAtTarget) + +describe('createApyRangeStrategy', () => { + describe('no reallocation needed', () => { + it('returns undefined when all markets are within APY range', () => { + const strategy = makeStrategy() + const market = makeMarket({ + utilization: apyToUtilization(5, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(strategy(makeVaultData([market]))).toBeUndefined() + }) + + it('returns undefined when only one market is out of range with no counterpart or idle', () => { + const strategy = makeStrategy() + const market = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(strategy(makeVaultData([market]))).toBeUndefined() + }) + }) + + describe('basic reallocation', () => { + it('deallocates from below-range and allocates to above-range markets (deltas)', () => { + const strategy = makeStrategy() + const hotMarket = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + + const result = strategy(makeVaultData([hotMarket, coldMarket])) + + expect(result).toBeDefined() + expect(result!.allocations.length).toBe(1) + expect(result!.deallocations.length).toBe(1) + const allocation = result!.allocations[0]! + const deallocation = result!.deallocations[0]! + expect(allocation.marketParams).toEqual(hotMarket.params) + expect(deallocation.marketParams).toEqual(coldMarket.params) + expect(allocation.assets).toBeGreaterThan(0n) + expect(deallocation.assets).toBeGreaterThan(0n) + expect(deallocation.assets).toBeLessThanOrEqual(coldMarket.vaultAssets) + }) + + it('does not include in-range markets', () => { + const strategy = makeStrategy() + const inRangeMarket = makeMarket({ + utilization: apyToUtilization(5, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const hotMarket = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + + const result = strategy(makeVaultData([inRangeMarket, hotMarket, coldMarket])) + + expect(result).toBeDefined() + const legs = [...result!.allocations, ...result!.deallocations].map(l => l.marketParams) + expect(legs).not.toContainEqual(inRangeMarket.params) + }) + + it('honors a per-market APY range override', () => { + const overridden = makeMarket({ + utilization: apyToUtilization(5, RATE_AT_TARGET), // in default 2-8%, above overridden 2-4% + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const strategy = makeStrategy({ marketApyRanges: { [overridden.id]: { min: 2, max: 4 } } }) + + const result = strategy(makeVaultData([overridden, coldMarket])) + + expect(result).toBeDefined() + expect(result!.allocations.map(l => l.marketParams)).toContainEqual(overridden.params) + }) + }) + + describe('min APY delta threshold', () => { + it('returns undefined when APY delta is below threshold', () => { + const strategy = makeStrategy({ minApyDeltaBips: 10_000 }) // 100% + const hotMarket = makeMarket({ + utilization: apyToUtilization(9, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(1.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(strategy(makeVaultData([hotMarket, coldMarket]))).toBeUndefined() + }) + }) + + describe('idle handling', () => { + it('funds surplus allocations from the idle balance', () => { + const strategy = makeStrategy() + const hotMarket = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const result = strategy(makeVaultData([hotMarket], { idleAssets: parseUnits('50000', 6) })) + // Only an allocation candidate: min(dealloc, alloc) === 0 with dealloc 0 — idle-only funding + // still requires at least one deallocation leg. Verify surplus-topping instead: + expect(result).toBeUndefined() + + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('100', 6), // tiny deallocation source + rateAtTarget: RATE_AT_TARGET + }) + const topped = strategy( + makeVaultData([hotMarket, coldMarket], { idleAssets: parseUnits('50000', 6) }) + ) + expect(topped).toBeDefined() + const totalAllocated = topped!.allocations.reduce((acc, l) => acc + l.assets, 0n) + const totalDeallocated = topped!.deallocations.reduce((acc, l) => acc + l.assets, 0n) + expect(totalAllocated).toBeGreaterThan(totalDeallocated) + }) + + it('clamps deallocations to allocations when idle reallocation is disabled', () => { + const strategy = makeStrategy({ allowIdleReallocation: false }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + // Only a deallocation candidate: with idle parking disabled the totals clamp to zero. + expect(strategy(makeVaultData([coldMarket]))).toBeUndefined() + }) + }) + + describe('degenerate bounds', () => { + it('handles an APY range at/below the curve minimum (zero utilization bound) without throwing', () => { + // apyToRate(0.0001%) sits below the curve's minimum rate, so both bounds resolve to + // utilization 0 — every market reads "above range" with an unreachable 0 target. + const strategy = makeStrategy({ defaultApyRange: { min: 0.0001, max: 0.0002 } }) + const hotMarket = makeMarket({ + utilization: apyToUtilization(5, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(strategy(makeVaultData([hotMarket, coldMarket]))).toBeUndefined() + }) + + it('drops a decayed-market leg the ceiling clamp would invert while siblings still trade', () => { + const strategy = makeStrategy() + // rateAtTarget decayed toward the curve minimum → lowerBound inverts to WAD; utilization + // already sits past the clamped ceiling, so the intended deallocation is empty — it must be + // dropped, not flipped into an allocation, and the siblings' plan must survive. + const decayedMarket = makeMarket({ + utilization: parseUnits('0.9995', 18), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: 1n + }) + const hotMarket = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + + const result = strategy(makeVaultData([decayedMarket, hotMarket, coldMarket])) + + expect(result).toBeDefined() + const legs = [...result!.allocations, ...result!.deallocations] + expect(legs.some(l => l.marketId === decayedMarket.id)).toBe(false) + }) + + it('still exits a decayed market sitting below the ceiling (clamp, not skip)', () => { + const strategy = makeStrategy() + // Same degenerate lowerBound = WAD, but utilization is far below the ceiling: the market + // must still be exited — toward the clamp, never draining its entire free liquidity. + const decayedMarket = makeMarket({ + utilization: parseUnits('0.5', 18), + vaultAssets: parseUnits('100000', 6), + rateAtTarget: 1n + }) + const hotMarket = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + + const result = strategy(makeVaultData([decayedMarket, hotMarket])) + + expect(result).toBeDefined() + const deallocation = result!.deallocations.find(l => l.marketId === decayedMarket.id) + expect(deallocation).toBeDefined() + const freeLiquidity = + decayedMarket.state.totalSupplyAssets - decayedMarket.state.totalBorrowAssets + expect(deallocation!.assets).toBeGreaterThan(0n) + expect(deallocation!.assets).toBeLessThan(freeLiquidity) + }) + + it('gates on the APY delta to the clamped bound, not the raw inverted bound', () => { + // min APY above the curve's max at this rateAtTarget inverts lowerBound to WAD; the emitted + // leg only travels to MAX_TARGET_UTILIZATION, so the gate must measure that shorter move. + const utilization = parseUnits('0.97', 18) + const gatedMarket = makeMarket({ + utilization, + vaultAssets: parseUnits('50000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const hotMarket = makeMarket({ + utilization: apyToUtilization(8.5, RATE_AT_TARGET), // small move: never arms the gate below + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const apyAt = (u: bigint) => rateToApy(utilizationToRate(u, RATE_AT_TARGET)) + const effectiveDelta = Math.abs(wadToBips(apyAt(MAX_TARGET_UTILIZATION) - apyAt(utilization))) + const rawDelta = Math.abs(wadToBips(apyAt(10n ** 18n) - apyAt(utilization))) + // The threshold below discriminates: gating on the raw bound would have fired. + expect(rawDelta).toBeGreaterThan(effectiveDelta) + + const marketApyRanges = { [gatedMarket.id]: { min: 13, max: 20 } } + const gated = makeStrategy({ marketApyRanges, minApyDeltaBips: effectiveDelta }) + expect(gated(makeVaultData([gatedMarket, hotMarket]))).toBeUndefined() + + const armed = makeStrategy({ marketApyRanges, minApyDeltaBips: effectiveDelta - 1 }) + const result = armed(makeVaultData([gatedMarket, hotMarket])) + expect(result).toBeDefined() + expect(result!.deallocations.some(l => l.marketId === gatedMarket.id)).toBe(true) + }) + }) + + describe('foreign-IRM exclusion', () => { + it('excludes non-AdaptiveCurve markets from both legs', () => { + const strategy = makeStrategy() + // Would look "far below range" if the degenerate inversion were applied. + const foreignMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('50000', 6), + rateAtTarget: 0n, + isAdaptiveCurve: false + }) + const hotMarket = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + // The foreign market is the only deallocation candidate — excluding it means no plan at all. + expect(strategy(makeVaultData([foreignMarket, hotMarket]))).toBeUndefined() + }) + }) + + describe('cap enforcement', () => { + it('clamps allocations to the market cap headroom measured against allocation(id)', () => { + const strategy = makeStrategy() + const vaultAssets = parseUnits('10000', 6) + const hotMarket = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets, + cap: { + absolute: vaultAssets + parseUnits('100', 6), + relative: 10n ** 18n, + allocation: vaultAssets + }, + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + + const result = strategy(makeVaultData([hotMarket, coldMarket])) + + expect(result).toBeDefined() + expect(result!.allocations[0]!.assets).toBeLessThanOrEqual(parseUnits('100', 6)) + }) + + it('returns undefined when the cap is already reached (no allocation headroom)', () => { + const strategy = makeStrategy() + const vaultAssets = parseUnits('10000', 6) + const cappedMarket = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets, + cap: { absolute: vaultAssets, relative: 10n ** 18n, allocation: vaultAssets }, + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(strategy(makeVaultData([cappedMarket, coldMarket]))).toBeUndefined() + }) + + it('clamps total allocations to the adapter-level cap pool', () => { + const strategy = makeStrategy() + const hotMarket = makeMarket({ + utilization: apyToUtilization(12, RATE_AT_TARGET), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: apyToUtilization(0.5, RATE_AT_TARGET), + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const adapterAllocation = parseUnits('30000', 6) + const result = strategy( + makeVaultData([hotMarket, coldMarket], { + idleAssets: parseUnits('1000', 6), // raises totalAssets so the 100% relative cap has headroom + adapterCap: { + absolute: adapterAllocation + parseUnits('50', 6), + relative: 10n ** 18n, + allocation: adapterAllocation + } + }) + ) + expect(result).toBeDefined() + const totalAllocated = result!.allocations.reduce((acc, l) => acc + l.assets, 0n) + const totalDeallocated = result!.deallocations.reduce((acc, l) => acc + l.assets, 0n) + // The pool cap plus the capacity this plan's own deallocations free (they execute first). + expect(totalAllocated).toBeLessThanOrEqual(parseUnits('50', 6) + totalDeallocated) + expect(totalAllocated).toBeGreaterThan(0n) + }) + }) +}) diff --git a/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts new file mode 100644 index 00000000..965ea7f0 --- /dev/null +++ b/bots/vault-v2-reallocation/test/strategies/equalize-utilizations.test.ts @@ -0,0 +1,421 @@ +import { wholePercentToWAD } from '@repo/utils' +import { getAddress, parseUnits } from 'viem' +import { describe, expect, it } from 'vitest' + +import { createEqualizeUtilizationsStrategy } from '../../src/strategies/equalize-utilizations' +import { makeMarket, makeMarketParams, makeVaultData, RATE_AT_TARGET, VAULT } from '../helpers' + +const makeStrategy = (minUtilizationDeltaBips: (vault: `0x${string}`) => number = () => 0) => + createEqualizeUtilizationsStrategy({ + capBufferWad: wholePercentToWAD(99.99), + minUtilizationDeltaBips + }) + +const WAD = 10n ** 18n + +describe('createEqualizeUtilizationsStrategy', () => { + it('deallocates from below-average and allocates into above-average markets (deltas)', () => { + const strategy = makeStrategy() + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + + const result = strategy(makeVaultData([hotMarket, coldMarket])) + + expect(result).toBeDefined() + expect(result!.deallocations.length).toBe(1) + expect(result!.allocations.length).toBe(1) + const deallocation = result!.deallocations[0]! + const allocation = result!.allocations[0]! + expect(deallocation.marketParams).toEqual(coldMarket.params) + expect(deallocation.assets).toBeGreaterThan(0n) + expect(deallocation.assets).toBeLessThanOrEqual(coldMarket.vaultAssets) + expect(allocation.marketParams).toEqual(hotMarket.params) + expect(allocation.assets).toBeGreaterThan(0n) + }) + + it('folds idle assets into the target-utilization denominator', () => { + const strategy = makeStrategy() + const market = makeMarket({ + utilization: (50n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + // Without idle the single market sits exactly at target (no move); a large idle balance drags + // the target below the market's utilization, making it an allocation candidate — but with no + // deallocation source min(dealloc, alloc) is 0, so still no reallocation. + expect(strategy(makeVaultData([market]))).toBeUndefined() + expect( + strategy(makeVaultData([market], { idleAssets: parseUnits('1000000', 6) })) + ).toBeUndefined() + + // Observable with a pair: allocations may exceed deallocations by EXACTLY the idle balance + // when the allocation appetite outruns the deallocation budget by more than idle. + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('50000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('100', 6), + supplyAssets: parseUnits('1000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const totals = (idleAssets: bigint) => { + const result = strategy(makeVaultData([hotMarket, coldMarket], { idleAssets })) + expect(result).toBeDefined() + return { + allocated: result!.allocations.reduce((acc, l) => acc + l.assets, 0n), + deallocated: result!.deallocations.reduce((acc, l) => acc + l.assets, 0n) + } + } + const dry = totals(0n) + expect(dry.allocated).toBe(dry.deallocated) + const idleAssets = parseUnits('500', 6) + const funded = totals(idleAssets) + expect(funded.allocated).toBe(funded.deallocated + idleAssets) + }) + + it('clamps the target utilization at 100% in bad-debt states', () => { + const strategy = makeStrategy() + // Aggregate borrow > aggregate supply: unclamped, the target would exceed WAD and deallocation + // sizing would ask for more than the markets hold. + const badDebtMarket = makeMarket({ + utilization: (150n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const result = strategy(makeVaultData([badDebtMarket, coldMarket])) + expect(result).toBeDefined() + const deallocation = result!.deallocations[0] + expect(deallocation?.assets ?? 0n).toBeLessThanOrEqual(coldMarket.vaultAssets) + }) + + it('lands a bad-debt aggregate target at the clamp, never draining free liquidity', () => { + const strategy = makeStrategy() + // Aggregate utilization > 100% (bad debt dominates): the raw target exceeds WAD, which + // unclamped would size the cold market's deallocation past its entire free liquidity. + const badDebtMarket = makeMarket({ + utilization: (300n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), // the adapter holds the whole market + supplyAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const result = strategy(makeVaultData([badDebtMarket, coldMarket])) + expect(result).toBeDefined() + const deallocation = result!.deallocations.find(l => l.marketId === coldMarket.id) + expect(deallocation).toBeDefined() + const freeLiquidity = coldMarket.state.totalSupplyAssets - coldMarket.state.totalBorrowAssets + expect(deallocation!.assets).toBeLessThan(freeLiquidity) + expect(deallocation!.assets).toBeGreaterThan(0n) + }) + + it('never inverts a near-ceiling market when bad debt pushes the aggregate target past the clamp', () => { + const strategy = makeStrategy() + // Raw aggregate target > WAD: the near-ceiling market's intent is a (tiny) deallocation, but + // its utilization already sits past the clamped target — under a naive clamp it would flip + // into an allocation into an almost-drained market. + const badDebtMarket = makeMarket({ + utilization: (300n * WAD) / 100n, + supplyAssets: parseUnits('10000', 6), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const nearMaxMarket = makeMarket({ + utilization: parseUnits('0.9995', 18), + vaultAssets: parseUnits('1000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + + // nearMax listed first so a wrong-side allocation could not hide behind budget trimming. + const result = strategy(makeVaultData([nearMaxMarket, badDebtMarket, coldMarket])) + + expect(result).toBeDefined() + const legs = [...result!.allocations, ...result!.deallocations] + expect(legs.some(l => l.marketId === nearMaxMarket.id)).toBe(false) + expect(result!.deallocations.some(l => l.marketId === coldMarket.id)).toBe(true) + expect(result!.allocations.some(l => l.marketId === badDebtMarket.id)).toBe(true) + }) + + it('handles a dust aggregate borrow whose target rounds to zero without throwing', () => { + const strategy = makeStrategy() + // borrow = 1 wei against a huge supply → wDivDown target rounds to 0n, past the + // totalBorrow === 0n early return. + const dustMarket = makeMarket({ + utilization: 0n, + vaultAssets: parseUnits('10000', 6), + supplyAssets: parseUnits('1000000000000', 6), + rateAtTarget: RATE_AT_TARGET + }) + dustMarket.state.totalBorrowAssets = 1n + expect(strategy(makeVaultData([dustMarket]))).toBeUndefined() + }) + + it('does not arm the min-delta trigger from a market that contributes nothing', () => { + // A deviates hugely but is cap-exhausted (contributes 0); B and C deviate under the threshold + // but carry the actual legs — the plan must NOT fire off A's deviation alone. + const strategy = makeStrategy(() => 100) // 1% threshold + const hotCapped = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('500', 6), + supplyAssets: parseUnits('1000', 6), + cap: { absolute: parseUnits('500', 6), relative: WAD, allocation: parseUnits('500', 6) }, + rateAtTarget: RATE_AT_TARGET + }) + const slightlyHot = makeMarket({ + utilization: (505n * WAD) / 1000n, + vaultAssets: parseUnits('10000', 6), + supplyAssets: parseUnits('1000000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const slightlyCold = makeMarket({ + utilization: (495n * WAD) / 1000n, + vaultAssets: parseUnits('10000', 6), + supplyAssets: parseUnits('1000000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(strategy(makeVaultData([hotCapped, slightlyHot, slightlyCold]))).toBeUndefined() + }) + + it('returns undefined when nothing is borrowed anywhere', () => { + const strategy = makeStrategy() + const market = makeMarket({ + utilization: 0n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(strategy(makeVaultData([market]))).toBeUndefined() + }) + + it('returns undefined when no deviation clears the vault min-delta threshold', () => { + const strategy = makeStrategy(() => 10_000) // 100% + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + expect(strategy(makeVaultData([hotMarket, coldMarket]))).toBeUndefined() + }) + + it('resolves the min-delta threshold by vault address', () => { + const other = getAddress(`0x${'99'.repeat(20)}`) + const seen: string[] = [] + const strategy = makeStrategy(vault => { + seen.push(vault) + return 0 + }) + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + strategy(makeVaultData([hotMarket, coldMarket], { vaultAddress: other })) + expect(seen).toEqual([other]) + expect(seen).not.toContain(VAULT) + }) + + it('clamps allocations to the market cap headroom measured from the accrued position', () => { + const strategy = makeStrategy() + const vaultAssets = parseUnits('10000', 6) + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets, + // allocate trues allocation(id) up to the accrued position before the cap check, so + // headroom is cap − accrued assets, even while the stored allocation lags behind. + cap: { + absolute: vaultAssets + parseUnits('100', 6), + relative: WAD, + allocation: vaultAssets - parseUnits('30', 6) + }, + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + + const result = strategy(makeVaultData([hotMarket, coldMarket])) + + expect(result).toBeDefined() + expect(result!.allocations[0]!.assets).toBeLessThanOrEqual(parseUnits('100', 6)) + }) + + it('fires on a fully-deployed vault whose relative caps are the WAD no-constraint sentinel', () => { + // The real production shape: everything allocated, relativeCap = WAD everywhere, generous + // absolute caps. A 100%-of-totalAssets reading of WAD would zero the pools and silently + // no-op the bot forever. + const strategy = makeStrategy() + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const deployed = makeVaultData([hotMarket, coldMarket], { + idleAssets: 0n, + adapterCap: { + absolute: parseUnits('1000000', 6), + relative: WAD, + allocation: parseUnits('30000', 6) // fully deployed: allocation == totalAssets + } + }) + expect(strategy(deployed)).toBeDefined() + }) + + it('never allocates more than deallocations plus idle (allocate pulls from vault balance)', () => { + const strategy = makeStrategy() + // Cold market: huge supply but the adapter only holds 100 — deallocatable is tiny. Hot market: + // big depositable demand. Unclamped, allocations would exceed what the vault balance can fund + // and the whole multicall would revert. + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('100', 6), + supplyAssets: parseUnits('1000000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('5000', 6), + supplyAssets: parseUnits('100000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const result = strategy(makeVaultData([coldMarket, hotMarket], { idleAssets: 0n })) + expect(result).toBeDefined() + const totalAllocated = result!.allocations.reduce((acc, l) => acc + l.assets, 0n) + const totalDeallocated = result!.deallocations.reduce((acc, l) => acc + l.assets, 0n) + expect(totalAllocated).toBeLessThanOrEqual(totalDeallocated) + }) + + it('uses capacity freed by its own deallocations under a full adapter cap', () => { + const strategy = makeStrategy() + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + // Adapter cap exactly full: no pre-existing headroom — only the deallocation legs (which + // execute first) free capacity for the allocations. + const result = strategy( + makeVaultData([hotMarket, coldMarket], { + adapterCap: { + absolute: parseUnits('30000', 6), + relative: WAD, + allocation: parseUnits('30000', 6) + } + }) + ) + expect(result).toBeDefined() + const totalAllocated = result!.allocations.reduce((acc, l) => acc + l.assets, 0n) + const totalDeallocated = result!.deallocations.reduce((acc, l) => acc + l.assets, 0n) + expect(totalAllocated).toBeGreaterThan(0n) + expect(totalAllocated).toBeLessThanOrEqual(totalDeallocated) + }) + + it('clamps total allocations to the adapter-level cap pool', () => { + const strategy = makeStrategy() + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const adapterAllocation = parseUnits('30000', 6) + const result = strategy( + makeVaultData([hotMarket, coldMarket], { + idleAssets: parseUnits('1000', 6), // raises totalAssets so the 100% relative cap has headroom + adapterCap: { + absolute: adapterAllocation + parseUnits('50', 6), + relative: WAD, + allocation: adapterAllocation + } + }) + ) + + expect(result).toBeDefined() + const totalAllocated = result!.allocations.reduce((acc, leg) => acc + leg.assets, 0n) + const totalDeallocated = result!.deallocations.reduce((acc, leg) => acc + leg.assets, 0n) + // The pool cap plus the capacity this plan's own deallocations free (they execute first). + expect(totalAllocated).toBeLessThanOrEqual(parseUnits('50', 6) + totalDeallocated) + expect(totalAllocated).toBeGreaterThan(0n) + }) + + it('blocks allocations into a collateral whose cap pool is exhausted', () => { + const strategy = makeStrategy() + const hotMarket = makeMarket({ + utilization: (95n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + // A DIFFERENT collateral, so its deallocation credit cannot revive the hot market's pool. + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + params: makeMarketParams({ collateralToken: getAddress(`0x${'55'.repeat(20)}`) }), + rateAtTarget: RATE_AT_TARGET + }) + const base = makeVaultData([hotMarket, coldMarket]) + const collateral = getAddress(hotMarket.params.collateralToken) + const collateralAllocation = parseUnits('30000', 6) + const result = strategy({ + ...base, + collateralCaps: { + ...base.collateralCaps, + [collateral]: { + absolute: collateralAllocation, + relative: WAD, + allocation: collateralAllocation + } + } + }) + + // No allocation headroom under the collateral cap → min(dealloc, alloc) = 0 → no reallocation. + expect(result).toBeUndefined() + }) +}) diff --git a/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts new file mode 100644 index 00000000..2a52d8f3 --- /dev/null +++ b/bots/vault-v2-reallocation/test/strategies/reconcile.test.ts @@ -0,0 +1,414 @@ +import { wholePercentToWAD } from '@repo/utils' +import { getAddress, parseUnits } from 'viem' +import { describe, expect, it } from 'vitest' + +import type { Classify, MarketTarget } from '../../src/strategies/reconcile' + +import { getUtilization, wadToBips } from '../../src/math' +import { createReconciler } from '../../src/strategies/reconcile' +import { makeMarket, makeMarketParams, makeVaultData, RATE_AT_TARGET } from '../helpers' + +const WAD = 10n ** 18n + +const makeReconciler = ( + classify: Classify, + overrides: Partial<{ allowIdleParking: boolean; capBufferWad: bigint }> = {} +) => + createReconciler({ + capBufferWad: overrides.capBufferWad ?? wholePercentToWAD(99.99), + allowIdleParking: overrides.allowIdleParking ?? true, + classifierFor: () => classify + }) + +// Classifier contract in miniature: intent from the RAW target, target handed over pre-clamped +// (the reconciler's own clamp is only a backstop) — tests pass raw targets to exercise it. +const toTarget = + (rawTarget: bigint, clearsMinDelta = true): Classify => + (marketData): MarketTarget => ({ + targetUtilization: rawTarget, + intent: getUtilization(marketData.state) > rawTarget ? 'allocate' : 'deallocate', + clearsMinDelta: () => clearsMinDelta + }) + +// Every market converges on 50% utilization and always clears the gate. +const toHalf: Classify = toTarget((50n * WAD) / 100n) + +describe('createReconciler', () => { + it('clamps a WAD target so no leg drains a market to zero free liquidity', () => { + // A decayed-rateAtTarget cold market can classify with lowerBound = WAD: unclamped, the + // deallocation sizes to the market's ENTIRE free liquidity — exact to the snapshot and + // unrealizable one accrual later. + const toWad: Classify = toTarget(WAD) + const coldMarket = makeMarket({ + utilization: (50n * WAD) / 100n, + vaultAssets: parseUnits('100000', 6), // the adapter holds the whole market + rateAtTarget: RATE_AT_TARGET + }) + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + // hotMarket sits below the WAD target too — classify it away so coldMarket is the only dealloc + // and hotMarket the only alloc candidate via a per-market verdict. + const classify: Classify = marketData => + marketData.id === coldMarket.id ? toWad(marketData) : toHalf(marketData) + const result = makeReconciler(classify)(makeVaultData([coldMarket, hotMarket])) + expect(result).toBeDefined() + const deallocation = result!.deallocations.find(l => l.marketId === coldMarket.id) + expect(deallocation).toBeDefined() + const freeLiquidity = coldMarket.state.totalSupplyAssets - coldMarket.state.totalBorrowAssets + expect(deallocation!.assets).toBeLessThan(freeLiquidity) + expect(deallocation!.assets).toBeGreaterThan(0n) + }) + + it('drops — never inverts — a move the ceiling clamp leaves empty or backwards', () => { + // Intent says deallocate (raw target WAD), but the market already sits past the clamped + // ceiling: emitting the clamped leg would flip it into an allocation. + const nearFullMarket = makeMarket({ + utilization: parseUnits('0.9995', 18), + vaultAssets: parseUnits('100000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (50n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const classify: Classify = marketData => + marketData.id === nearFullMarket.id + ? toTarget(WAD)(marketData) + : marketData.id === coldMarket.id + ? toTarget((80n * WAD) / 100n)(marketData) + : toHalf(marketData) + const result = makeReconciler(classify)(makeVaultData([nearFullMarket, coldMarket, hotMarket])) + expect(result).toBeDefined() + const legs = [...result!.allocations, ...result!.deallocations] + expect(legs.some(l => l.marketId === nearFullMarket.id)).toBe(false) + }) + + it('sizes both sides toward the classifier target and emits delta legs', () => { + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const result = makeReconciler(toHalf)(makeVaultData([hotMarket, coldMarket])) + expect(result).toBeDefined() + expect(result!.deallocations.map(l => l.marketId)).toEqual([coldMarket.id]) + expect(result!.allocations.map(l => l.marketId)).toEqual([hotMarket.id]) + expect(result!.deallocations[0]!.assets).toBeGreaterThan(0n) + expect(result!.allocations[0]!.assets).toBeGreaterThan(0n) + }) + + it('leaves out markets the classifier returns undefined for', () => { + const excluded = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const classify: Classify = marketData => + marketData.id === excluded.id ? undefined : toHalf(marketData) + // The only allocation candidate is excluded → one-sided → no plan. + expect(makeReconciler(classify)(makeVaultData([excluded, coldMarket]))).toBeUndefined() + }) + + it('skips a market sitting exactly at its target', () => { + const atTarget = makeMarket({ + utilization: (50n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const result = makeReconciler(toHalf)(makeVaultData([atTarget, hotMarket, coldMarket])) + expect(result).toBeDefined() + const legIds = [...result!.allocations, ...result!.deallocations].map(l => l.marketId) + expect(legIds).not.toContain(atTarget.id) + }) + + it('arms the min-delta gate only from markets that contribute assets', () => { + // The only gate-clearing market is cap-exhausted (contributes 0); the contributing pair does + // not clear the gate → no plan. + const vaultAssets = parseUnits('500', 6) + const gateOnlyMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets, + cap: { absolute: vaultAssets, relative: WAD, allocation: vaultAssets }, + rateAtTarget: RATE_AT_TARGET + }) + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const classify: Classify = marketData => + toTarget((50n * WAD) / 100n, marketData.id === gateOnlyMarket.id)(marketData) + expect( + makeReconciler(classify)(makeVaultData([gateOnlyMarket, hotMarket, coldMarket])) + ).toBeUndefined() + }) + + it('does not fire off a clearing market whose take is fully trimmed away', () => { + // hotA (clears) is behind hotB (does not clear) in market order — but the budget check happens + // per surviving leg: give hotA a leg the budget can't reach. Budget = deallocatable (tiny); + // hotB (first in order) consumes it entirely, so hotA's clearing move never survives. + const hotB = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const hotA = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('100', 6), + supplyAssets: parseUnits('1000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const classify: Classify = marketData => + toTarget((50n * WAD) / 100n, marketData.id === hotA.id)(marketData) + expect(makeReconciler(classify)(makeVaultData([hotB, hotA, coldMarket]))).toBeUndefined() + }) + + it('fires when the clearing market survives trimming (positive control)', () => { + const hotA = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const hotB = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('100', 6), + supplyAssets: parseUnits('1000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const classify: Classify = marketData => + toTarget((50n * WAD) / 100n, marketData.id === hotA.id)(marketData) + const result = makeReconciler(classify)(makeVaultData([hotA, hotB, coldMarket])) + expect(result).toBeDefined() + expect(result!.allocations.map(l => l.marketId)).toEqual([hotA.id]) + }) + + it('clamps deallocations to the allocation total when idle parking is off', () => { + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('100', 6), + supplyAssets: parseUnits('1000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('50000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const result = makeReconciler(toHalf, { allowIdleParking: false })( + makeVaultData([hotMarket, coldMarket]) + ) + expect(result).toBeDefined() + const totalAllocated = result!.allocations.reduce((acc, l) => acc + l.assets, 0n) + const totalDeallocated = result!.deallocations.reduce((acc, l) => acc + l.assets, 0n) + expect(totalDeallocated).toBe(totalAllocated) + }) + + it('lets allocations exceed deallocations by at most the idle balance', () => { + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('40000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('100', 6), + supplyAssets: parseUnits('1000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const idleAssets = parseUnits('500', 6) + const result = makeReconciler(toHalf)(makeVaultData([hotMarket, coldMarket], { idleAssets })) + expect(result).toBeDefined() + const totalAllocated = result!.allocations.reduce((acc, l) => acc + l.assets, 0n) + const totalDeallocated = result!.deallocations.reduce((acc, l) => acc + l.assets, 0n) + expect(totalAllocated).toBeGreaterThan(totalDeallocated) + expect(totalAllocated).toBeLessThanOrEqual(totalDeallocated + idleAssets) + }) + + it('funds allocations under a full adapter cap only from its own deallocation credits', () => { + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const result = makeReconciler(toHalf)( + makeVaultData([hotMarket, coldMarket], { + adapterCap: { + absolute: parseUnits('30000', 6), + relative: WAD, + allocation: parseUnits('30000', 6) + } + }) + ) + expect(result).toBeDefined() + const totalAllocated = result!.allocations.reduce((acc, l) => acc + l.assets, 0n) + const totalDeallocated = result!.deallocations.reduce((acc, l) => acc + l.assets, 0n) + expect(totalAllocated).toBeGreaterThan(0n) + expect(totalAllocated).toBeLessThanOrEqual(totalDeallocated) + }) + + it('does not arm off a clearing move trimmed to a fragment (realized delta)', () => { + // The hot market's FULL move (90% -> 50%) would clear 500 bips easily, but the only funding is + // a 1-wei deallocation: the realized endpoint barely moves, so the plan must not fire. + const minDeltaBips = 500 + const gated: Classify = marketData => { + const target = (50n * WAD) / 100n + const utilization = getUtilization(marketData.state) + return { + targetUtilization: target, + intent: utilization > target ? 'allocate' : 'deallocate', + clearsMinDelta: utilizationAfter => + Math.abs(wadToBips(utilization - utilizationAfter)) > minDeltaBips + } + } + const hotMarket = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('100000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const dustSource = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: 1n, + rateAtTarget: RATE_AT_TARGET + }) + expect(makeReconciler(gated)(makeVaultData([hotMarket, dustSource]))).toBeUndefined() + + // Funded control: with a real deallocation source the same classifier fires. + const realSource = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('100000', 6), + supplyAssets: parseUnits('200000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const funded = makeReconciler(gated)(makeVaultData([hotMarket, realSource])) + expect(funded).toBeDefined() + + // Partial-trim boundary: funding covers only part of the hot market's want, but the realized + // endpoint still clears the threshold — the trimmed leg fires at the trimmed size. + const partialSource = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('40000', 6), + supplyAssets: parseUnits('100000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const partial = makeReconciler(gated)(makeVaultData([hotMarket, partialSource])) + expect(partial).toBeDefined() + const hotLeg = partial!.allocations.find(l => l.marketId === hotMarket.id) + expect(hotLeg).toBeDefined() + const totalDeallocated = partial!.deallocations.reduce((acc, l) => acc + l.assets, 0n) + expect(hotLeg!.assets).toBe(totalDeallocated) + }) + + it('never parks a stranded deallocation in idle when idle parking is off', () => { + // The deallocation budget trim keeps the FIRST (collateral-A) leg, dropping the collateral-B + // leg whose credit was the only funding for the collateral-B allocation — which the cap pools + // then clamp to zero. Unfixed, the surviving A deallocation ships alone and its assets park in + // idle despite allowIdleParking: false. + const coldA = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('20000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const collateralB = getAddress(`0x${'77'.repeat(20)}`) + const coldB = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('1000', 6), + supplyAssets: parseUnits('2000', 6), + params: makeMarketParams({ collateralToken: collateralB }), + rateAtTarget: RATE_AT_TARGET + }) + const hotB = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + params: makeMarketParams({ collateralToken: collateralB }), + rateAtTarget: RATE_AT_TARGET + }) + const base = makeVaultData([coldA, coldB, hotB]) + const exhausted = parseUnits('11000', 6) + const vaultData = { + ...base, + // Collateral B's cap is exhausted: the B allocation can only be funded by B deallocations. + collateralCaps: { + ...base.collateralCaps, + [collateralB]: { absolute: exhausted, relative: WAD, allocation: exhausted } + } + } + expect(makeReconciler(toHalf, { allowIdleParking: false })(vaultData)).toBeUndefined() + + // With idle parking allowed the same shape ships — the surplus parks in idle by design. + const parked = makeReconciler(toHalf)(vaultData) + expect(parked).toBeDefined() + }) + + it('trims the smaller side in market order', () => { + // Two allocation candidates but the single deallocation funds less than both want: the + // first-in-queue market takes the whole budget. + const hotA = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const hotB = makeMarket({ + utilization: (90n * WAD) / 100n, + vaultAssets: parseUnits('10000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const coldMarket = makeMarket({ + utilization: (10n * WAD) / 100n, + vaultAssets: parseUnits('100', 6), + supplyAssets: parseUnits('1000', 6), + rateAtTarget: RATE_AT_TARGET + }) + const result = makeReconciler(toHalf)(makeVaultData([hotA, hotB, coldMarket])) + expect(result).toBeDefined() + expect(result!.allocations.map(l => l.marketId)).toEqual([hotA.id]) + }) +}) diff --git a/bots/vault-v2-reallocation/test/strategy-config.test.ts b/bots/vault-v2-reallocation/test/strategy-config.test.ts new file mode 100644 index 00000000..cdd98773 --- /dev/null +++ b/bots/vault-v2-reallocation/test/strategy-config.test.ts @@ -0,0 +1,89 @@ +import { getAddress } from 'viem' +import { afterEach, describe, expect, it } from 'vitest' + +import { InvalidConfigError } from '../src/invalid-config.error' +import { + assertApyRangeValid, + DEFAULT_APY_RANGE, + marketApyRanges, + marketMinApyDeltaBips, + resolveApyRange, + resolveMinApyDeltaBips, + resolveMinUtilizationDeltaBips, + vaultApyRanges, + vaultMinApyDeltaBips, + vaultMinUtilizationDeltaBips +} from '../src/strategy-config' + +const CHAIN_ID = 1 +const VAULT = getAddress(`0x${'11'.repeat(20)}`) +const MARKET = `0x${'22'.repeat(32)}` as const + +// The tables ship empty (template); tests exercise precedence by populating and restoring them. +afterEach(() => { + for (const table of [ + vaultApyRanges, + marketApyRanges, + vaultMinApyDeltaBips, + marketMinApyDeltaBips, + vaultMinUtilizationDeltaBips + ]) { + delete table[CHAIN_ID] + } +}) + +describe('resolveApyRange', () => { + it('falls back to the global default', () => { + expect(resolveApyRange(CHAIN_ID, VAULT, MARKET)).toEqual(DEFAULT_APY_RANGE) + }) + + it('prefers a vault override over the default', () => { + vaultApyRanges[CHAIN_ID] = { [VAULT]: { min: 4, max: 6 } } + expect(resolveApyRange(CHAIN_ID, VAULT, MARKET)).toEqual({ min: 4, max: 6 }) + }) + + it('prefers a market override over a vault override', () => { + vaultApyRanges[CHAIN_ID] = { [VAULT]: { min: 4, max: 6 } } + marketApyRanges[CHAIN_ID] = { [MARKET]: { min: 5, max: 7 } } + expect(resolveApyRange(CHAIN_ID, VAULT, MARKET)).toEqual({ min: 5, max: 7 }) + }) + + it('scopes overrides to their chain', () => { + vaultApyRanges[CHAIN_ID] = { [VAULT]: { min: 4, max: 6 } } + expect(resolveApyRange(8453, VAULT, MARKET)).toEqual(DEFAULT_APY_RANGE) + }) +}) + +describe('resolveMinApyDeltaBips', () => { + it('falls back to the caller-provided default', () => { + expect(resolveMinApyDeltaBips(CHAIN_ID, VAULT, MARKET, 25)).toBe(25) + }) + + it('applies market > vault precedence', () => { + vaultMinApyDeltaBips[CHAIN_ID] = { [VAULT]: 50 } + expect(resolveMinApyDeltaBips(CHAIN_ID, VAULT, MARKET, 25)).toBe(50) + marketMinApyDeltaBips[CHAIN_ID] = { [MARKET]: 75 } + expect(resolveMinApyDeltaBips(CHAIN_ID, VAULT, MARKET, 25)).toBe(75) + }) +}) + +describe('resolveMinUtilizationDeltaBips', () => { + it('prefers a vault override over the caller-provided default', () => { + expect(resolveMinUtilizationDeltaBips(CHAIN_ID, VAULT, 250)).toBe(250) + vaultMinUtilizationDeltaBips[CHAIN_ID] = { [VAULT]: 100 } + expect(resolveMinUtilizationDeltaBips(CHAIN_ID, VAULT, 250)).toBe(100) + }) +}) + +// The checked-in tables themselves are guarded at module load — importing this module with a bad +// entry throws, which the imports above already exercise for the shipped (empty) tables. +describe('assertApyRangeValid', () => { + it('rejects inverted and empty ranges', () => { + expect(() => assertApyRangeValid({ min: 8, max: 3 }, 'test')).toThrow(InvalidConfigError) + expect(() => assertApyRangeValid({ min: 5, max: 5 }, 'test')).toThrow(InvalidConfigError) + }) + + it('accepts a well-formed range', () => { + expect(() => assertApyRangeValid(DEFAULT_APY_RANGE, 'default')).not.toThrow() + }) +}) diff --git a/bots/vault-v2-reallocation/test/tx-error.test.ts b/bots/vault-v2-reallocation/test/tx-error.test.ts new file mode 100644 index 00000000..7964b4b1 --- /dev/null +++ b/bots/vault-v2-reallocation/test/tx-error.test.ts @@ -0,0 +1,23 @@ +import { vaultV2Abi } from '@morpho-org/blue-sdk-viem' +import { BaseError, encodeErrorResult } from 'viem' +import { describe, expect, it } from 'vitest' + +import { revertReason } from '../src/tx-error' + +// A viem-style error chain whose cause carries an ABI-encoded revert payload, the shape +// `revertReason` walks for (mirrors bot-kit's own tx-error tests). +const revertError = (data: `0x${string}`): BaseError => + new BaseError('execution reverted', { + cause: Object.assign(new Error('execution reverted'), { data }) + }) + +describe('revertReason', () => { + it('decodes a VaultV2 custom error from revert data', () => { + const data = encodeErrorResult({ abi: vaultV2Abi, errorName: 'AbsoluteCapExceeded' }) + expect(revertReason(revertError(data))).toBe('AbsoluteCapExceeded') + }) + + it('falls back to the message for non-revert errors', () => { + expect(revertReason(new Error('rpc exploded'))).toContain('rpc exploded') + }) +}) diff --git a/bots/vault-v2-reallocation/test/vault-checks.test.ts b/bots/vault-v2-reallocation/test/vault-checks.test.ts new file mode 100644 index 00000000..4941e5d1 --- /dev/null +++ b/bots/vault-v2-reallocation/test/vault-checks.test.ts @@ -0,0 +1,64 @@ +import type { Logger } from '@repo/bot-kit' + +import { getAddress } from 'viem' +import { describe, expect, it, vi } from 'vitest' + +import type { VaultCheckReads } from '../src/vault-checks' + +import { InvalidVaultError } from '../src/invalid-vault.error' +import { checkVaults } from '../src/vault-checks' +import { ADAPTER, makeMarket, makeVaultData, RATE_AT_TARGET } from './helpers' + +const spyLogger = () => { + const events: { level: string; event: string; fields?: Record }[] = [] + const make = (level: string) => (event: string, fields?: Record) => + events.push({ level, event, fields }) + const logger: Logger = { + debug: make('debug'), + info: make('info'), + warn: make('warn'), + error: make('error') + } + return { logger, events } +} + +const VAULT = getAddress(`0x${'aa'.repeat(20)}`) + +const someVaultData = () => + makeVaultData([makeMarket({ utilization: 0n, vaultAssets: 0n, rateAtTarget: RATE_AT_TARGET })]) + +const makeReads = (overrides: Partial = {}): VaultCheckReads => ({ + assertDeployed: vi.fn(async () => undefined), + fetchVault: vi.fn(async () => someVaultData()), + ...overrides +}) + +describe('checkVaults', () => { + it('returns the vault → adapter map for the signing policy', async () => { + const { logger, events } = spyLogger() + const adapterByVault = await checkVaults([VAULT], makeReads(), logger) + expect(adapterByVault).toEqual({ [VAULT]: [ADAPTER] }) + expect(events).toEqual([]) + }) + + it('propagates a fetch rejection (non-V2 address, unsupported adapter shape)', async () => { + const { logger } = spyLogger() + await expect( + checkVaults( + [VAULT], + makeReads({ fetchVault: vi.fn(async () => Promise.reject(new InvalidVaultError('nope'))) }), + logger + ) + ).rejects.toBeInstanceOf(InvalidVaultError) + }) + + it('warns but does not throw when the allocator role is missing', async () => { + const { logger, events } = spyLogger() + await checkVaults( + [VAULT], + makeReads({ fetchVault: vi.fn(async () => ({ ...someVaultData(), isAllocator: false })) }), + logger + ) + expect(events.some(e => e.event === 'allocator.missing_role' && e.level === 'warn')).toBe(true) + }) +}) diff --git a/bots/vault-v2-reallocation/test/vault-data.test.ts b/bots/vault-v2-reallocation/test/vault-data.test.ts new file mode 100644 index 00000000..8b6f3551 --- /dev/null +++ b/bots/vault-v2-reallocation/test/vault-data.test.ts @@ -0,0 +1,146 @@ +import type { Address } from 'viem' + +import { getChainAddresses } from '@morpho-org/blue-sdk' +import { getAddress, parseUnits } from 'viem' +import { describe, expect, it } from 'vitest' + +import type { LensVaultOut } from '../src/state/lens.sol' + +import { InvalidVaultError } from '../src/invalid-vault.error' +import { toVaultV2Data } from '../src/vault-data' + +const CHAIN_ID = 8453 +const VAULT = getAddress(`0x${'aa'.repeat(20)}`) +const ADAPTER = getAddress(`0x${'bb'.repeat(20)}`) +const COLLATERAL = getAddress(`0x${'cc'.repeat(20)}`) +const ADAPTIVE_CURVE_IRM = getChainAddresses(CHAIN_ID).adaptiveCurveIrm + +const caps = (base: bigint) => ({ + absoluteCap: base, + relativeCap: base + 1n, + allocation: base + 2n +}) + +let marketCounter = 0 +const makeLensMarket = ( + overrides: Partial = {} +): LensVaultOut['markets'][number] => { + marketCounter++ + return { + id: `0x${marketCounter.toString(16).padStart(64, '0')}`, + capId: `0x${(1000 + marketCounter).toString(16).padStart(64, '0')}`, + params: { + loanToken: getAddress(`0x${'10'.repeat(20)}`), + collateralToken: COLLATERAL, + oracle: getAddress(`0x${'30'.repeat(20)}`), + irm: ADAPTIVE_CURVE_IRM, + lltv: parseUnits('0.8', 18) + }, + totalSupplyAssets: parseUnits('100000', 6), + totalBorrowAssets: parseUnits('50000', 6), + cap: caps(100n), + collateralCap: caps(200n), + vaultAssets: parseUnits('10000', 6), + rateAtTarget: parseUnits('0.03', 18) / (365n * 24n * 60n * 60n), + ...overrides + } +} + +const makeRow = (overrides: Partial = {}): LensVaultOut => ({ + isVaultV2: true, + isAllocator: true, + totalAssets: parseUnits('100000', 6), + idleAssets: parseUnits('5000', 6), + adapters: [{ adapter: ADAPTER, kind: 2 }], + adapterCap: caps(300n), + markets: [makeLensMarket()], + ...overrides +}) + +describe('toVaultV2Data', () => { + it('rejects an address the factory does not recognize', () => { + expect(() => toVaultV2Data(VAULT, makeRow({ isVaultV2: false }), CHAIN_ID)).toThrow( + InvalidVaultError + ) + }) + + it('rejects any adapter shape other than exactly one market adapter', () => { + const other: Address = getAddress(`0x${'dd'.repeat(20)}`) + // Two adapters, even if one qualifies. + expect(() => + toVaultV2Data( + VAULT, + makeRow({ + adapters: [ + { adapter: ADAPTER, kind: 2 }, + { adapter: other, kind: 0 } + ] + }), + CHAIN_ID + ) + ).toThrow(InvalidVaultError) + // One adapter of an unrecognized generation (e.g. a MorphoVaultV1Adapter). + expect(() => + toVaultV2Data(VAULT, makeRow({ adapters: [{ adapter: other, kind: 0 }] }), CHAIN_ID) + ).toThrow(InvalidVaultError) + }) + + it('shapes a lens row, renaming cap triples and checksumming addresses', () => { + const market = makeLensMarket() + const data = toVaultV2Data(VAULT, makeRow({ markets: [market] }), CHAIN_ID) + expect(data.vaultAddress).toBe(VAULT) + expect(data.adapterAddress).toBe(ADAPTER) + expect(data.isAllocator).toBe(true) + expect(data.adapterCap).toEqual({ absolute: 300n, relative: 301n, allocation: 302n }) + const shaped = data.marketsData[0]! + expect(shaped.id).toBe(market.id) + expect(shaped.capId).toBe(market.capId) + expect(shaped.cap).toEqual({ absolute: 100n, relative: 101n, allocation: 102n }) + expect(shaped.state).toEqual({ + totalSupplyAssets: market.totalSupplyAssets, + totalBorrowAssets: market.totalBorrowAssets + }) + expect(shaped.isAdaptiveCurve).toBe(true) + expect(shaped.isIdle).toBe(false) + expect(data.collateralCaps).toEqual({ + [COLLATERAL]: { absolute: 200n, relative: 201n, allocation: 202n } + }) + }) + + it('collapses per-market collateral cap duplicates to one entry per token', () => { + const shared = caps(500n) + const data = toVaultV2Data( + VAULT, + makeRow({ + markets: [ + makeLensMarket({ collateralCap: shared }), + makeLensMarket({ collateralCap: shared }) + ] + }), + CHAIN_ID + ) + expect(Object.keys(data.collateralCaps)).toEqual([COLLATERAL]) + }) + + it('excludes foreign-IRM and zero-rate markets from the adaptive set, and idle from the report', () => { + const foreignIrm = makeLensMarket({ + params: { ...makeLensMarket().params, irm: getAddress(`0x${'40'.repeat(20)}`) } + }) + const zeroRate = makeLensMarket({ rateAtTarget: 0n }) + const idle = makeLensMarket({ + params: { + ...makeLensMarket().params, + collateralToken: '0x0000000000000000000000000000000000000000' + }, + rateAtTarget: 0n + }) + const data = toVaultV2Data( + VAULT, + makeRow({ markets: [makeLensMarket(), foreignIrm, zeroRate, idle] }), + CHAIN_ID + ) + expect(data.marketsData.map(m => m.isAdaptiveCurve)).toEqual([true, false, false, false]) + expect(data.marketsData[3]!.isIdle).toBe(true) + expect(data.nonAdaptiveCurveMarketIds).toEqual([foreignIrm.id, zeroRate.id]) + }) +}) diff --git a/bots/vault-v2-reallocation/tsconfig.json b/bots/vault-v2-reallocation/tsconfig.json new file mode 100644 index 00000000..dccc6c59 --- /dev/null +++ b/bots/vault-v2-reallocation/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/typescript-config/base", + "compilerOptions": { + "types": ["node"], + "plugins": [{ "name": "soltag/plugin" }] + }, + "include": ["src", "test", "scripts", ".soltag/types.d.ts"], + "exclude": ["node_modules"] +} diff --git a/bots/vault-v2-reallocation/vitest.config.ts b/bots/vault-v2-reallocation/vitest.config.ts new file mode 100644 index 00000000..faf2d50e --- /dev/null +++ b/bots/vault-v2-reallocation/vitest.config.ts @@ -0,0 +1,13 @@ +import soltag from 'soltag/vite' +import { defineConfig } from 'vitest/config' + +// soltag is a build-time-only transform: its `sol``` tagged templates throw at runtime unless a +// plugin compiles them (via solc). The vite plugin transforms this project's TS sources and leaves +// files without templates unchanged. Enable the optimizer: the lens's per-market loop has enough +// locals to hit "stack too deep" without it. +export default defineConfig({ + plugins: [soltag({ solc: { optimizer: { enabled: true, runs: 200 } } })], + test: { + name: 'vault-v2-reallocation' + } +}) diff --git a/knip.json b/knip.json index b2beef30..d6927413 100644 --- a/knip.json +++ b/knip.json @@ -26,6 +26,9 @@ "bots/vault-v1-reallocation": { "entry": ["src/index.ts", "scripts/probe-live-lens.ts"] }, + "bots/vault-v2-reallocation": { + "entry": ["src/index.ts", "scripts/probe-live-lens.ts"] + }, "bots/midnight-crossed-books": { "ignore": ["src/infrastructure/*/generated/**"] }, diff --git a/packages/bot-kit/src/policy-multicall.utils.ts b/packages/bot-kit/src/policy-multicall.utils.ts new file mode 100644 index 00000000..3db08d09 --- /dev/null +++ b/packages/bot-kit/src/policy-multicall.utils.ts @@ -0,0 +1,53 @@ +import type { Address, Hex } from 'viem' + +import { decodeAbiParameters, isAddress, isAddressEqual } from 'viem' + +import type { MulticallPolicy, PolicyTx } from './policy' + +// One inner call must span at least selector (4 bytes) + one 32-byte argument word. +const MIN_INNER_CALL_HEX_LENGTH = 2 + 8 + 64 + +const decodeMulticallData = (data: Hex): readonly Hex[] | undefined => { + try { + const [calls] = decodeAbiParameters([{ type: 'bytes[]' }] as const, `0x${data.slice(10)}`) + return calls + } catch { + return undefined + } +} + +/** + * Deep authorization for a `multicall(bytes[])` envelope (see {@link MulticallPolicy}): returns a + * denial reason, or `undefined` when every inner call is allowed. `evaluatePolicy` maps any return + * or throw to a default-deny `data` decision. + */ +export const checkMulticall = (spec: MulticallPolicy, tx: PolicyTx): string | undefined => { + const calls = decodeMulticallData(tx.data) + if (calls === undefined) return 'calldata does not decode as multicall(bytes[])' + if (calls.length === 0) return 'multicall bundle must not be empty' + const allowedTargets = Object.entries(spec.innerTargetsByOuter).find(([outer]) => + isAddressEqual(tx.to, outer as Address) + )?.[1] + if (allowedTargets === undefined || allowedTargets.length === 0) { + return `no inner targets configured for outer target ${tx.to}` + } + const selectors = spec.innerSelectors.map(s => s.toLowerCase()) + for (const call of calls) { + if (call.length < MIN_INNER_CALL_HEX_LENGTH) { + return `inner call ${call} is too short to carry a selector and an address argument` + } + const innerSelector = call.slice(0, 10).toLowerCase() + if (!selectors.includes(innerSelector)) { + return `inner selector ${innerSelector} is not allowed` + } + const word = call.slice(10, 74) + const firstArg = `0x${word.slice(24)}` + if (!/^0{24}$/.test(word.slice(0, 24)) || !isAddress(firstArg, { strict: false })) { + return 'inner call first argument is not an address' + } + if (!allowedTargets.some(target => isAddressEqual(firstArg, target))) { + return `inner call targets unregistered address ${firstArg}` + } + } + return undefined +} diff --git a/packages/bot-kit/src/policy.ts b/packages/bot-kit/src/policy.ts index 3fb434c3..efe679b2 100644 --- a/packages/bot-kit/src/policy.ts +++ b/packages/bot-kit/src/policy.ts @@ -1,7 +1,10 @@ import type { Address, Hex } from 'viem' +import { tryCatch } from '@repo/utils' import { isAddressEqual } from 'viem' +import { checkMulticall } from './policy-multicall.utils' + /** The only Executor entrypoint the signer authorizes: exec_606BaXt(bytes[]). */ export const EXECUTOR_SELECTOR = '0x00000001' @@ -11,6 +14,20 @@ export const DEFAULT_MAX_GAS_LIMIT = 15_000_000n /** Default calldata byte ceiling (matches the daemon-era signer policy default). */ export const DEFAULT_MAX_DATA_BYTES = 65_536 +/** + * Deep authorization for a `multicall(bytes[])` envelope, evaluated declaratively by + * {@link evaluatePolicy}: the calldata must decode as a single non-empty `bytes[]`, every inner + * call's selector must be listed, and every inner call's FIRST argument (which must be an + * address — this rule only suits inner functions shaped like VaultV2's + * `allocate/deallocate(address, …)`) must be registered for the transaction's outer target. + */ +export type MulticallPolicy = { + /** Allowed inner selectors (e.g. allocate/deallocate). */ + innerSelectors: readonly Hex[] + /** Allowed first-argument addresses per outer target (e.g. vault → its adapters). */ + innerTargetsByOuter: Readonly> +} + /** One signer authorizes one entrypoint on a fixed target set on one chain, under fixed fee/gas/size ceilings. */ export type Policy = { chainId: number @@ -21,6 +38,8 @@ export type Policy = { maxDataBytes: number /** Allowed outer selector; defaults to Executor.exec_606BaXt. */ selector?: Hex + /** When set, calldata must also satisfy the {@link MulticallPolicy} envelope rules. */ + multicall?: MulticallPolicy } /** The prepared-transaction fields the pre-broadcast guard evaluates against a {@link Policy}. */ @@ -41,6 +60,7 @@ export type PolicyCheck = | 'gas' | 'maxDataBytes' | 'selector' + | 'data' export type PolicyDecision = { ok: true } | { ok: false; check: PolicyCheck; message: string } @@ -65,7 +85,9 @@ export class PolicyViolationError extends Error { * ever sending value, hitting the wrong contract, or exceeding a ceiling. * * OUTER-ENVELOPE guard: target, selector, zero value, and fee/gas/size ceilings are pinned. It does - * not interpret calldata beyond the selector; each bot simulates its exact request before submit. + * not interpret calldata beyond the selector — unless a {@link MulticallPolicy} is configured, in + * which case the envelope also authorizes each inner call declaratively (still no bot-supplied + * code). Each bot simulates its exact request before submit regardless. */ export function evaluatePolicy(policy: Policy, tx: PolicyTx): PolicyDecision { const deny = (check: PolicyCheck, message: string): PolicyDecision => ({ @@ -94,5 +116,12 @@ export function evaluatePolicy(policy: Policy, tx: PolicyTx): PolicyDecision { if (tx.data.slice(0, 10).toLowerCase() !== selector) { return deny('selector', `calldata must call configured selector ${selector}`) } + const { multicall } = policy + if (multicall) { + // A throw out of the deep check must never escape the PolicyDecision contract — default-deny. + const { data: reason, error } = tryCatch(() => checkMulticall(multicall, tx)) + if (error) return deny('data', 'multicall authorization threw; denying') + if (reason !== undefined) return deny('data', reason) + } return { ok: true } } diff --git a/packages/bot-kit/test/policy.test.ts b/packages/bot-kit/test/policy.test.ts index cf56f6a6..201f449b 100644 --- a/packages/bot-kit/test/policy.test.ts +++ b/packages/bot-kit/test/policy.test.ts @@ -1,4 +1,4 @@ -import { getAddress } from 'viem' +import { encodeFunctionData, getAddress, parseAbi, toFunctionSelector } from 'viem' import { describe, expect, it } from 'vitest' import type { Policy, PolicyTx } from '../src/policy' @@ -85,3 +85,119 @@ describe('evaluatePolicy', () => { expect(evaluatePolicy(POLICY, tx({ data: '0xdeadbeef' }))).toMatchObject({ check: 'selector' }) }) }) + +describe('evaluatePolicy multicall envelope', () => { + const VAULT_ABI = parseAbi([ + 'function multicall(bytes[] data)', + 'function allocate(address adapter, bytes data, uint256 assets)', + 'function deallocate(address adapter, bytes data, uint256 assets)', + 'function setIsAllocator(address account, bool newIsAllocator)' + ]) + const VAULT_A = getAddress(`0x${'aa'.repeat(20)}`) + const VAULT_B = getAddress(`0x${'bb'.repeat(20)}`) + const ADAPTER_A = getAddress(`0x${'a1'.repeat(20)}`) + const ADAPTER_B = getAddress(`0x${'b1'.repeat(20)}`) + + const leg = (functionName: 'allocate' | 'deallocate', adapter: `0x${string}`) => + encodeFunctionData({ abi: VAULT_ABI, functionName, args: [adapter, '0x1234', 1_000n] }) + const bundle = (calls: `0x${string}`[]) => + encodeFunctionData({ abi: VAULT_ABI, functionName: 'multicall', args: [calls] }) + + const MULTICALL_POLICY: Policy = { + chainId: 8453, + targets: [VAULT_A, VAULT_B], + maxFeePerGasWei: 300_000_000_000n, + maxGasLimit: 15_000_000n, + maxDataBytes: 65_536, + selector: toFunctionSelector('function multicall(bytes[])'), + multicall: { + innerSelectors: [ + toFunctionSelector('function allocate(address, bytes, uint256)'), + toFunctionSelector('function deallocate(address, bytes, uint256)') + ], + innerTargetsByOuter: { [VAULT_A]: [ADAPTER_A], [VAULT_B]: [ADAPTER_B] } + } + } + const mtx = (overrides: Partial = {}): PolicyTx => + tx({ + to: VAULT_A, + data: bundle([leg('deallocate', ADAPTER_A), leg('allocate', ADAPTER_A)]), + ...overrides + }) + + it('accepts a valid allocate/deallocate bundle to the registered adapter', () => { + expect(evaluatePolicy(MULTICALL_POLICY, mtx())).toEqual({ ok: true }) + expect( + evaluatePolicy( + MULTICALL_POLICY, + mtx({ to: VAULT_B, data: bundle([leg('allocate', ADAPTER_B)]) }) + ) + ).toEqual({ ok: true }) + }) + + it('rejects a smuggled inner selector', () => { + const smuggled = encodeFunctionData({ + abi: VAULT_ABI, + functionName: 'setIsAllocator', + args: [ADAPTER_A, true] + }) + expect( + evaluatePolicy( + MULTICALL_POLICY, + mtx({ data: bundle([leg('deallocate', ADAPTER_A), smuggled]) }) + ) + ).toMatchObject({ ok: false, check: 'data' }) + }) + + it("rejects another vault's adapter (cross-vault)", () => { + expect( + evaluatePolicy(MULTICALL_POLICY, mtx({ data: bundle([leg('allocate', ADAPTER_B)]) })) + ).toMatchObject({ ok: false, check: 'data' }) + }) + + it('rejects an outer target with no registered inner targets', () => { + const policy: Policy = { + ...MULTICALL_POLICY, + targets: [getAddress(`0x${'cc'.repeat(20)}`)] + } + expect(evaluatePolicy(policy, mtx({ to: getAddress(`0x${'cc'.repeat(20)}`) }))).toMatchObject({ + ok: false, + check: 'data' + }) + }) + + it('rejects an empty bundle and malformed bytes', () => { + expect(evaluatePolicy(MULTICALL_POLICY, mtx({ data: bundle([]) }))).toMatchObject({ + ok: false, + check: 'data' + }) + const selector = MULTICALL_POLICY.selector ?? '0x' + expect(evaluatePolicy(MULTICALL_POLICY, mtx({ data: `${selector}deadbeef` }))).toMatchObject({ + ok: false, + check: 'data' + }) + }) + + it('rejects an inner call too short to carry an address argument', () => { + const allocateSelector = toFunctionSelector('function allocate(address, bytes, uint256)') + expect( + evaluatePolicy(MULTICALL_POLICY, mtx({ data: bundle([`${allocateSelector}beef`]) })) + ).toMatchObject({ ok: false, check: 'data' }) + }) + + it('rejects a first argument word with dirty upper bits', () => { + const allocateSelector = toFunctionSelector('function allocate(address, bytes, uint256)') + // A registered adapter smuggled inside a word whose top 12 bytes are non-zero. + const dirtyWord = `${'de'.repeat(12)}${ADAPTER_A.slice(2)}` + expect( + evaluatePolicy( + MULTICALL_POLICY, + mtx({ data: bundle([`${allocateSelector}${dirtyWord}${'00'.repeat(64)}`]) }) + ) + ).toMatchObject({ ok: false, check: 'data' }) + }) + + it('leaves policies without a multicall spec unchanged', () => { + expect(evaluatePolicy(POLICY, tx())).toEqual({ ok: true }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 399389ec..a5229a70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,6 +455,55 @@ importers: specifier: 'catalog:' version: 4.1.2(@types/node@24.7.0)(vite@7.3.6(@types/node@24.7.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + bots/vault-v2-reallocation: + dependencies: + '@morpho-org/blue-sdk': + specifier: 'catalog:' + version: 6.5.0(@morpho-org/morpho-ts@2.8.0) + '@morpho-org/blue-sdk-viem': + specifier: 'catalog:' + version: 5.2.1(@morpho-org/blue-sdk@6.5.0(@morpho-org/morpho-ts@2.8.0))(@morpho-org/morpho-ts@2.8.0)(viem@2.47.17(typescript@6.0.2)(zod@4.4.3)) + '@repo/bot-kit': + specifier: workspace:* + version: link:../../packages/bot-kit + '@repo/utils': + specifier: workspace:* + version: link:../../packages/utils + solc: + specifier: 'catalog:' + version: 0.8.35 + soltag: + specifier: 'catalog:' + version: 0.0.17(esbuild@0.28.1)(rollup@4.62.4)(solc@0.8.35)(typescript@6.0.2)(viem@2.47.17(typescript@6.0.2)(zod@4.4.3))(vite@7.3.6(@types/node@24.7.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + viem: + specifier: 'catalog:' + version: 2.47.17(typescript@6.0.2)(zod@4.4.3) + devDependencies: + '@repo/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@types/node': + specifier: 'catalog:' + version: 24.7.0 + esbuild: + specifier: 'catalog:' + version: 0.28.1 + execa: + specifier: 'catalog:' + version: 9.6.0 + tsx: + specifier: 'catalog:' + version: 4.21.0 + typescript: + specifier: 'catalog:' + version: 6.0.2 + vite: + specifier: 'catalog:' + version: 7.3.6(@types/node@24.7.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 4.1.2(@types/node@24.7.0)(vite@7.3.6(@types/node@24.7.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + packages/bot-kit: dependencies: '@loglayer/transport-betterstack': diff --git a/vitest.config.ts b/vitest.config.ts index 9f7c10e7..82bde731 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ 'packages/offers', 'bots/blue-liquidation', 'bots/vault-v1-reallocation', + 'bots/vault-v2-reallocation', 'bots/midnight-liquidation', 'bots/midnight-crossed-books', 'bots/quoter-bot'